fix(metadata): let the availability gate reopen without a restart

hasReachableSource() derived its answer from a source list only the walks
refreshed, and standing down stopped the walks. An install that booted
with nothing configured therefore stayed shut after its first connection
was added, until the process restarted.

It now re-reads out of band whenever the list it holds is empty or past
its TTL, deduped so concurrent callers share one read. The docker
overview also claimed nothing is requested from sportarr.net, which is
only true of metadata: Sportarr mints absolute artwork URLs there, so a
browser loads the images either way.

Changes:
- refreshConfiguredSources() shared by the gate and the awaited path
- regression test for a connection added after an empty boot
- Refresh Metadata sits left of Test Connection in the dialog footer
- alias helper stops testing the window its own helper already tests
This commit is contained in:
Sportarr
2026-08-27 14:35:07 -04:00
parent dcb17ca7f6
commit 77f27778eb
7 changed files with 76 additions and 40 deletions
@@ -1,5 +1,4 @@
import {
isSportarrTvdbAlias,
MediaProviderIds,
sportarrLeagueId,
sportarrLeagueNumber,
@@ -23,10 +22,6 @@ function leagueExternalIdFromNativeId(
export function sportarrLeagueExternalIdFromTvdbAlias(
tvdbAlias: number | undefined | null,
): string | undefined {
if (!isSportarrTvdbAlias(tvdbAlias)) {
return undefined;
}
const n = sportarrLeagueNumberFromTvdbAlias(tvdbAlias);
return n === undefined ? undefined : sportarrLeagueId(n);
}
@@ -187,6 +187,32 @@ describe('SportarrMetadataApiService', () => {
await expect(service.hasSource()).resolves.toBe(true);
});
it('opens again when the first connection is added after an empty boot', async () => {
// The gate is what stops the walks, and the walks are what refresh the
// source list, so a first run with nothing configured could otherwise
// stay shut until a restart.
delete process.env.SPORTARR_NET;
withConnections();
await service.onModuleInit();
expect(service.hasReachableSource()).toBe(false);
await new Promise((resolve) => setImmediate(resolve));
// The user adds their first connection.
withConnections('http://sportarr.local:1867');
answer(`${CONNECTION}/agents/series/lg-000278`, {
title: 'Sample League',
});
// The stand-down answer, and the read it kicks off behind it.
expect(service.hasReachableSource()).toBe(false);
await new Promise((resolve) => setImmediate(resolve));
expect(service.hasReachableSource()).toBe(true);
await expect(service.getLeague('lg-000278')).resolves.toEqual({
title: 'Sample League',
});
});
it('never touches sportarr.net unless the environment asks for it', async () => {
// An install that has never heard of Sportarr must not make an outbound
// request for a carried id it happens to hold.
@@ -67,6 +67,7 @@ export class SportarrMetadataApiService
implements OnModuleInit
{
private configuredSources?: { sources: string[]; readAt: number };
private sourceRead?: Promise<string[]>;
private readonly unreachableUntil = new Map<string, number>();
constructor(
@@ -141,18 +142,41 @@ export class SportarrMetadataApiService
*/
hasReachableSource(): boolean {
const now = Date.now();
return (this.configuredSources?.sources ?? []).some(
const cached = this.configuredSources;
// Standing down stops the walks, and the walks are what refresh this
// list, so an install that boots with nothing configured would stay shut
// until a restart. Read again, out of band, whenever what we hold is
// empty or old. The answer below is still the one we hold; the next call
// sees the new list.
if (
!cached ||
cached.sources.length === 0 ||
now - cached.readAt >= SOURCE_LIST_TTL_MS
) {
void this.refreshConfiguredSources();
}
return (cached?.sources ?? []).some(
(source) => (this.unreachableUntil.get(source) ?? 0) <= now,
);
}
/** One read at a time, however many callers ask while it is in flight. */
private refreshConfiguredSources(): Promise<string[]> {
this.sourceRead ??= this.readConfiguredSources().finally(() => {
this.sourceRead = undefined;
});
return this.sourceRead;
}
/** Every source worth asking right now, in order. */
private async sources(): Promise<string[]> {
const cached = this.configuredSources;
const configured =
cached && Date.now() - cached.readAt < SOURCE_LIST_TTL_MS
? cached.sources
: await this.readConfiguredSources();
: await this.refreshConfiguredSources();
const now = Date.now();
return configured.filter(
@@ -552,16 +552,11 @@ function ProviderSection({
const MetadataSettings = () => {
const { t } = useLingui()
const {
feedback,
clear,
showUpdated,
showUpdateError,
showWarning,
} = useSettingsFeedback({
updated: t`Metadata provider preference updated`,
updateError: t`Metadata provider preference could not be updated`,
})
const { feedback, clear, showUpdated, showUpdateError, showWarning } =
useSettingsFeedback({
updated: t`Metadata provider preference updated`,
updateError: t`Metadata provider preference could not be updated`,
})
const {
data: preference = MetadataProviderPreference.TMDB_PRIMARY,
isLoading: preferenceLoading,
@@ -290,9 +290,7 @@ describe('ServarrSettingsModal', () => {
})
await waitFor(() => {
expect(
screen.getByText('Sportarr metadata refresh started'),
).toBeTruthy()
expect(screen.getByText('Sportarr metadata refresh started')).toBeTruthy()
})
})
})
@@ -1,9 +1,6 @@
import { RefreshIcon } from '@heroicons/react/solid'
import { Trans, useLingui } from '@lingui/react/macro'
import {
BasicResponseDto,
stripTrailingSlashes,
} from '@maintainerr/contracts'
import { BasicResponseDto, stripTrailingSlashes } from '@maintainerr/contracts'
import { useMemo, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import {
@@ -264,8 +261,10 @@ const ServarrSettingsModal = <TSetting extends ServarrSettingShape>({
setRefreshing(true)
try {
const response =
await PostApiHandler<BasicResponseDto>(metadataRefreshPath, {})
const response = await PostApiHandler<BasicResponseDto>(
metadataRefreshPath,
{},
)
setRefreshMessage({
status: response?.code === 1,
@@ -407,6 +406,18 @@ const ServarrSettingsModal = <TSetting extends ServarrSettingShape>({
isPending={testing}
feedbackStatus={testFeedbackStatus}
/>
{metadataRefreshPath ? (
<Button
buttonType="default"
className="ml-3"
type="button"
onClick={() => void refreshMetadata()}
disabled={refreshing}
>
<RefreshIcon />
<Trans>Refresh Metadata</Trans>
</Button>
) : null}
</>
}
>
@@ -522,19 +533,6 @@ const ServarrSettingsModal = <TSetting extends ServarrSettingShape>({
<span className="m-auto rounded-md shadow-xs sm:mr-auto sm:ml-3">
<DocsButton page={docsPage} />
</span>
{metadataRefreshPath ? (
<span className="m-auto rounded-md shadow-xs sm:ml-3">
<Button
buttonType="default"
type="button"
onClick={() => void refreshMetadata()}
disabled={refreshing}
>
<RefreshIcon />
<Trans>Refresh Metadata</Trans>
</Button>
</span>
) : null}
</div>
</div>
</Modal>
+1 -1
View File
@@ -101,7 +101,7 @@ A list of all available environment variables are below. No other env variables
| BASE_PATH | (*none*) | If reverse proxying with a subfolder you'll want to set this. Must be in the format of `/subfolder` |
| GITHUB_TOKEN | (*none*) | GitHub Personal Access Token for higher API rate limits |
| TELEMETRY | (*none*) | Set to `off` to disable the anonymous weekly usage report, whatever the stored setting says. |
| SPORTARR_NET | (*none*) | Set to `on` to read artwork and descriptions for a Sportarr league none of your Sportarr connections tracks from sportarr.net. Off by default, so nothing is requested from it. |
| SPORTARR_NET | (*none*) | Set to `on` to read artwork links and descriptions for a Sportarr league none of your Sportarr connections tracks from sportarr.net. Off by default, so Maintainerr asks it for nothing. Sportarr hosts the artwork files themselves on sportarr.net, so a browser still loads images from there either way. |
| CORS_ALLOWED_ORIGINS | (*none*) | Comma-separated list of origins allowed to call the API cross-origin, e.g. `https://maintainerr.example.com`. The bundled UI is served from the same origin as the API and does not need this; only set it if a separate front end calls the API. |
# Features