feat(rules): metadata fallback when series absent from Sonarr (#3002)

Items can exist in the media server but be absent from Sonarr (deleted,
never added, or movies mis-classified as shows by Jellyfin/Emby). For
show-level properties whose semantics are intrinsic to the show rather
than to Sonarr's local state, the Sonarr getter now falls back to the
configured metadata provider after a confirmed Sonarr miss.

- Fallback set: `ended`, `firstAirDate` (show-level), `seasons`
  (show-level, specials-filtered to match Sonarr's `statistics.seasonCount`).
- Excluded by design: `status`, `originalLanguage`, `rating` —
  provider vocabularies / scales don't compare to Sonarr's. Sonarr-only
  state (`monitored`, `tags`, `filePath`, `diskSize*`, …) also stays null.
- TVDB extended series interface gains `seasons[]` typing so the count
  can be filtered to the default ordering before excluding Season 0.
- "Not in Sonarr/Radarr" warns now end with "Is the series/movie tracked
  in Sonarr/Radarr?" to prompt the user.

Sonarr is always primary; fallback only fires on the empty `getSeriesByTvdbId`
return, and the existing ArrLookupCache still dedupes the miss per run.
This commit is contained in:
enoch85
2026-05-28 14:02:43 +02:00
committed by GitHub
parent 0ab35ae6cd
commit bcf68ece95
13 changed files with 782 additions and 22 deletions
@@ -109,7 +109,14 @@ export class SonarrApi extends ServarrApi<{
// (unmonitor + file deletes) by the empty-show cleanup, and also drives
// rule evaluation — both need Sonarr's current truth, not a snapshot that
// can be up to DEFAULT_TTL stale (see issue #2757 / #2891).
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
// Returns `null` when Sonarr confirms the series isn't tracked (empty
// response) and `undefined` when the lookup itself failed (transport, auth,
// 5xx). Callers must keep these distinct: a confirmed miss is safe to fall
// back from, a failure must fail closed so a transient Sonarr outage can't
// silently change rule evaluation.
public async getSeriesByTvdbId(
id: number,
): Promise<SonarrSeries | null | undefined> {
try {
const response = await this.getWithoutCache<SonarrSeries[]>(
`/series?tvdbId=${id}`,
@@ -117,13 +124,14 @@ export class SonarrApi extends ServarrApi<{
if (!response?.[0]) {
this.logger.warn(`Could not retrieve show by tvdb ID ${id}`);
return undefined;
return null;
}
return response[0];
} catch (error) {
this.logger.warn(`Error retrieving show by tvdb ID ${id}`);
this.logger.debug(error);
return undefined;
}
}
@@ -25,6 +25,21 @@ export interface TvdbRemoteId {
sourceName: string;
}
export interface TvdbSeasonType {
id: number;
name: string;
type: string;
alternateName: string | null;
}
export interface TvdbSeason {
id: number;
seriesId: number;
type: TvdbSeasonType;
number: number;
name: string | null;
}
export interface TvdbSeriesBase {
id: number;
name: string;
@@ -53,6 +68,7 @@ export interface TvdbSeriesBase {
year: string;
artworks: TvdbArtwork[];
remoteIds: TvdbRemoteId[];
seasons?: TvdbSeason[];
}
export interface TvdbMovieBase {
@@ -28,4 +28,12 @@ export interface MetadataDetails {
rating?: number;
externalIds: ResolvedMediaIds;
type: 'movie' | 'tv';
// Show-only fallback fields. Limited to values whose semantics match across
// Sonarr / TMDB / TVDB; status strings, language codes vs names, and the
// rating scale differ enough between sources that exposing them would let
// rules silently mis-evaluate.
ended?: boolean;
firstAirDate?: string;
// Excludes Season 0 / specials to match Sonarr's `statistics.seasonCount`.
seasonCount?: number;
}
@@ -808,4 +808,130 @@ describe('MetadataService', () => {
expect.stringContaining('TMDB returned 2097'),
);
});
describe('getDetails({ merge: true })', () => {
it('fills missing show-only fields from the secondary provider', async () => {
const { service, tmdbProvider, tvdbProvider } = createService({});
// Preference defaults to TVDB_PRIMARY in createService; for this test we
// make TMDB primary by reordering. Easiest: make TVDB unavailable then
// re-run, OR mock getOrderedProviders. Instead, just match the existing
// order (TVDB first) and have TVDB return partial, TMDB fill in.
tvdbProvider.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
// No `ended` from TVDB — say its status was 'Unknown'.
ended: undefined,
firstAirDate: '2017-04-25',
seasonCount: 4,
});
tmdbProvider.getDetails.mockResolvedValue({
id: 2,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tmdb: 2 },
ended: true,
firstAirDate: '2017-04-25',
seasonCount: 4,
});
const merged = await service.getDetails(
{ type: 'tv', tmdb: 2, tvdb: 1 },
'tv',
{ merge: true },
);
// Primary (TVDB) provided everything except `ended`; secondary (TMDB)
// filled `ended: true`.
expect(merged?.ended).toBe(true);
expect(merged?.firstAirDate).toBe('2017-04-25');
expect(merged?.seasonCount).toBe(4);
});
it('keeps the primary provider value when both providers have the field', async () => {
const { service, tmdbProvider, tvdbProvider } = createService({});
tvdbProvider.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
ended: false,
firstAirDate: '2017-04-25',
seasonCount: 2,
});
tmdbProvider.getDetails.mockResolvedValue({
id: 2,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tmdb: 2 },
ended: true,
firstAirDate: '2018-01-01',
seasonCount: 9,
});
const merged = await service.getDetails(
{ type: 'tv', tmdb: 2, tvdb: 1 },
'tv',
{ merge: true },
);
// Primary (TVDB) wins for every field it supplied.
expect(merged?.ended).toBe(false);
expect(merged?.firstAirDate).toBe('2017-04-25');
expect(merged?.seasonCount).toBe(2);
});
it('walks every available provider so new providers compose automatically', async () => {
const { service, tmdbProvider, tvdbProvider } = createService({});
tvdbProvider.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
ended: undefined,
firstAirDate: undefined,
seasonCount: undefined,
});
tmdbProvider.getDetails.mockResolvedValue({
id: 2,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tmdb: 2 },
ended: true,
firstAirDate: '2017-04-25',
seasonCount: 4,
});
const merged = await service.getDetails(
{ type: 'tv', tmdb: 2, tvdb: 1 },
'tv',
{ merge: true },
);
expect(tvdbProvider.getDetails).toHaveBeenCalled();
expect(tmdbProvider.getDetails).toHaveBeenCalled();
expect(merged?.ended).toBe(true);
expect(merged?.firstAirDate).toBe('2017-04-25');
expect(merged?.seasonCount).toBe(4);
});
it('returns undefined when no provider has the series', async () => {
const { service, tmdbProvider, tvdbProvider } = createService({});
tvdbProvider.getDetails.mockResolvedValue(undefined);
tmdbProvider.getDetails.mockResolvedValue(undefined);
const merged = await service.getDetails(
{ type: 'tv', tmdb: 2, tvdb: 1 },
'tv',
{ merge: true },
);
expect(merged).toBeUndefined();
});
});
});
@@ -495,28 +495,90 @@ export class MetadataService {
);
}
/**
* Returns metadata details for the given IDs.
*
* Default behavior (no options): walks providers in preference order and
* returns the first non-undefined record — the fast path that existing
* callers (poster/backdrop lookups, ID resolution) rely on.
*
* `{ merge: true }`: walks every available provider and fills any optional
* field the primary left undefined from later providers. Field-agnostic, so
* any new optional field on `MetadataDetails` is picked up automatically.
* Use this for fallback paths where it's worth doubling cold-cache API
* calls to avoid silent nulls when the primary returns a partial record.
*/
async getDetails(
ids: ProviderIds,
type: 'movie' | 'tv',
options: { merge?: boolean } = {},
): Promise<MetadataDetails | undefined> {
const providerResult = await this.withProviderFallbackDetailed(
ids,
(provider, id) => provider.getDetails(id, type),
);
if (!options.merge) {
const providerResult = await this.withProviderFallbackDetailed(
ids,
(provider, id) => provider.getDetails(id, type),
);
if (!providerResult) {
if (!providerResult) {
return undefined;
}
if (providerResult.result.externalIds) {
this.applyIdCorrections(
ids,
providerResult.result.externalIds,
providerResult.provider,
);
}
return providerResult.result;
}
let merged: MetadataDetails | undefined;
let primaryProviderName: string | undefined;
for (const provider of this.getOrderedProviders()) {
const id = provider.extractId(ids);
if (id === undefined) {
continue;
}
const details = await provider.getDetails(id, type);
if (!details) {
continue;
}
if (!merged) {
merged = { ...details };
primaryProviderName = provider.name;
continue;
}
// Safe write: optional MetadataDetails fields are the only ones that
// can be undefined on `merged`; required fields (id, title, type,
// externalIds) are always set by the primary, so the assignment only
// ever fills holes in optional slots.
const mergedRecord = merged as unknown as Record<string, unknown>;
const detailsRecord = details as unknown as Record<string, unknown>;
for (const key of Object.keys(detailsRecord)) {
if (
mergedRecord[key] === undefined &&
detailsRecord[key] !== undefined
) {
mergedRecord[key] = detailsRecord[key];
}
}
}
if (!merged) {
return undefined;
}
if (providerResult.result.externalIds) {
this.applyIdCorrections(
ids,
providerResult.result.externalIds,
providerResult.provider,
);
if (merged.externalIds && primaryProviderName) {
this.applyIdCorrections(ids, merged.externalIds, primaryProviderName);
}
return providerResult.result;
return merged;
}
private applyIdCorrections(
@@ -0,0 +1,95 @@
import { Mocked, TestBed } from '@suites/unit';
import { TmdbApiService } from '../../api/tmdb-api/tmdb.service';
import { TmdbMetadataProvider } from './tmdb-metadata.provider';
describe('TmdbMetadataProvider', () => {
let provider: TmdbMetadataProvider;
let tmdbApi: Mocked<TmdbApiService>;
beforeEach(async () => {
const { unit, unitRef } =
await TestBed.solitary(TmdbMetadataProvider).compile();
provider = unit;
tmdbApi = unitRef.get(TmdbApiService);
});
const baseTvRecord = {
id: 1,
name: 'Sample Series',
first_air_date: '2017-04-25',
original_language: 'en',
overview: '',
vote_average: 8.1,
poster_path: '/p.jpg',
backdrop_path: '/b.jpg',
external_ids: { tvdb_id: 322399, imdb_id: 'tt5673782' },
seasons: [{ season_number: 0 }, { season_number: 1 }, { season_number: 2 }],
};
it.each<[string, boolean | undefined, string, boolean | undefined]>([
// [status, in_production, label, expectedEnded]
['Ended', false, 'status Ended + in_production false', true],
['Canceled', false, 'status Canceled', true],
['Returning Series', true, 'status Returning Series', false],
['In Production', true, 'status In Production', false],
['Pilot', undefined, 'status Pilot (unknown)', undefined],
])(
'maps %s to ended=%s (%s)',
async (status, inProduction, _label, expected) => {
tmdbApi.getTvShow.mockResolvedValue({
...baseTvRecord,
status,
in_production: inProduction,
} as any);
const details = await provider.getDetails(1, 'tv');
expect(details?.ended).toBe(expected);
expect(details?.firstAirDate).toBe('2017-04-25');
},
);
it('counts only non-special seasons', async () => {
tmdbApi.getTvShow.mockResolvedValue({
...baseTvRecord,
status: 'Ended',
in_production: false,
} as any);
const details = await provider.getDetails(1, 'tv');
expect(details?.seasonCount).toBe(2);
});
it('prefers in_production: true over an "Ended" status string', async () => {
tmdbApi.getTvShow.mockResolvedValue({
...baseTvRecord,
status: 'Ended',
in_production: true,
} as any);
const details = await provider.getDetails(1, 'tv');
expect(details?.ended).toBe(false);
});
it('does not set show-only fields for movie details', async () => {
tmdbApi.getMovie.mockResolvedValue({
id: 1,
title: 'Sample Movie',
release_date: '2010-01-01',
overview: '',
vote_average: 7,
poster_path: '/p.jpg',
backdrop_path: '/b.jpg',
status: 'Released',
external_ids: {},
} as any);
const details = await provider.getDetails(1, 'movie');
expect(details?.ended).toBeUndefined();
expect(details?.firstAirDate).toBeUndefined();
expect(details?.seasonCount).toBeUndefined();
});
});
@@ -81,9 +81,45 @@ export class TmdbMetadataProvider implements IMetadataProvider {
type,
},
type,
ended:
'in_production' in record
? this.deriveEnded(record.status, record.in_production)
: undefined,
firstAirDate:
'first_air_date' in record
? record.first_air_date || undefined
: undefined,
seasonCount:
'seasons' in record ? this.countRealSeasons(record.seasons) : undefined,
};
}
private countRealSeasons(
seasons: { season_number: number }[] | undefined,
): number | undefined {
if (!Array.isArray(seasons)) return undefined;
let count = 0;
for (const season of seasons) {
if (season.season_number > 0) count++;
}
return count;
}
// `in_production` is the authoritative "no more episodes coming" signal;
// status strings (Ended/Canceled/Returning Series/…) are only consulted
// when it's absent.
private deriveEnded(
status: string | undefined,
inProduction: boolean | undefined,
): boolean | undefined {
if (inProduction === true) return false;
if (inProduction === false) return true;
if (status === 'Ended' || status === 'Canceled') return true;
if (status === 'Returning Series' || status === 'In Production')
return false;
return undefined;
}
async getPosterUrl(
tmdbId: number,
type: 'movie' | 'tv',
@@ -0,0 +1,86 @@
import { Mocked, TestBed } from '@suites/unit';
import { TvdbApiService } from '../../api/tvdb-api/tvdb.service';
import { TvdbMetadataProvider } from './tvdb-metadata.provider';
describe('TvdbMetadataProvider', () => {
let provider: TvdbMetadataProvider;
let tvdbApi: Mocked<TvdbApiService>;
beforeEach(async () => {
const { unit, unitRef } =
await TestBed.solitary(TvdbMetadataProvider).compile();
provider = unit;
tvdbApi = unitRef.get(TvdbApiService);
tvdbApi.getPosterUrl.mockReturnValue(undefined);
tvdbApi.getBackdropUrl.mockReturnValue(undefined);
tvdbApi.getTmdbId.mockReturnValue(undefined);
tvdbApi.getImdbId.mockReturnValue(undefined);
});
const baseSeriesRecord = {
id: 322399,
name: 'Sample Series',
firstAired: '2017-04-25',
originalLanguage: 'eng',
overview: '',
score: 9,
year: '2017',
defaultSeasonType: 1,
seasons: [
// Specials in the default ordering — should be filtered out.
{ number: 0, type: { id: 1 } },
{ number: 1, type: { id: 1 } },
{ number: 2, type: { id: 1 } },
// Alternative orderings — should be filtered out by defaultSeasonType.
{ number: 1, type: { id: 2 } },
{ number: 2, type: { id: 2 } },
{ number: 3, type: { id: 3 } },
],
};
it.each<[string, boolean | undefined]>([
['Ended', true],
['Continuing', false],
['Upcoming', false],
['Unknown', undefined],
])('maps TVDB status %s to ended=%s', async (statusName, expected) => {
tvdbApi.getSeries.mockResolvedValue({
...baseSeriesRecord,
status: { name: statusName },
} as any);
const details = await provider.getDetails(322399, 'tv');
expect(details?.ended).toBe(expected);
expect(details?.firstAirDate).toBe('2017-04-25');
});
it('counts seasons in the default ordering, excluding specials', async () => {
tvdbApi.getSeries.mockResolvedValue({
...baseSeriesRecord,
status: { name: 'Ended' },
} as any);
const details = await provider.getDetails(322399, 'tv');
// Only number > 0 in the default ordering (type.id === 1) — 2 seasons.
expect(details?.seasonCount).toBe(2);
});
it('does not derive ended or season count for movie details', async () => {
tvdbApi.getMovie.mockResolvedValue({
id: 1,
name: 'Sample Movie',
year: '2010',
status: { name: 'Released' },
originalLanguage: 'eng',
} as any);
const details = await provider.getDetails(1, 'movie');
expect(details?.ended).toBeUndefined();
expect(details?.firstAirDate).toBeUndefined();
expect(details?.seasonCount).toBeUndefined();
});
});
@@ -67,9 +67,42 @@ export class TvdbMetadataProvider implements IMetadataProvider {
type,
},
type,
ended:
'firstAired' in record
? this.deriveEnded(record.status?.name)
: undefined,
firstAirDate:
'firstAired' in record ? record.firstAired || undefined : undefined,
seasonCount:
'firstAired' in record
? this.countRealSeasons(record.seasons, record.defaultSeasonType)
: undefined,
};
}
private deriveEnded(status: string | undefined): boolean | undefined {
if (status === 'Ended') return true;
if (status === 'Continuing' || status === 'Upcoming') return false;
return undefined;
}
// TVDB returns season entries for every alternative ordering (Aired / DVD /
// Absolute / Alternate / Regional), so filter to the series' default ordering
// before excluding Season 0.
private countRealSeasons(
seasons: { number: number; type: { id: number } }[] | undefined,
defaultSeasonType: number | undefined,
): number | undefined {
if (!Array.isArray(seasons) || defaultSeasonType === undefined) {
return undefined;
}
let count = 0;
for (const season of seasons) {
if (season.type?.id === defaultSeasonType && season.number > 0) count++;
}
return count;
}
async getPosterUrl(
tvdbId: number,
type: 'movie' | 'tv',
@@ -104,7 +104,7 @@ export class RadarrGetterService {
const attemptedIds = formatMetadataLookupCandidates(lookupCandidates);
this.logger.warn(
`None of the resolved external IDs [${attemptedIds}] for '${libItem.title}' matched a movie in Radarr.`,
`None of the resolved external IDs [${attemptedIds}] for '${libItem.title}' matched a movie in Radarr. Is the movie tracked in Radarr?`,
);
return null;
}
@@ -847,6 +847,151 @@ describe('SonarrGetterService', () => {
});
});
describe('metadata fallback (series absent from Sonarr)', () => {
let collectionMedia: CollectionMedia;
let mediaItem: MediaItem;
let mockedSonarrApi: SonarrApi;
beforeEach(() => {
collectionMedia = createCollectionMedia('show');
collectionMedia.collection.sonarrSettingsId = 1;
mediaItem = createMediaItem({ type: 'show', title: 'Sample Series' });
mockedSonarrApi = mockSonarrApi();
// Default: Sonarr confirms the series isn't tracked (null), as opposed
// to a transient error (undefined). Tests that need the error path
// override this.
jest
.spyOn(mockedSonarrApi, 'getSeriesByTvdbId')
.mockResolvedValue(null as any);
});
const callGet = (propId: number) =>
sonarrGetterService.get(
propId,
mediaItem,
'show',
createRulesDto({
collection: collectionMedia.collection,
dataType: 'show',
}),
);
it('returns 1 for ended when metadata says the show ended', async () => {
metadataService.resolveIdsFromMediaItem.mockResolvedValue({
type: 'tv',
tvdb: 322399,
} as any);
metadataService.getDetails.mockResolvedValue({
id: 322399,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 322399 },
ended: true,
} as any);
// id 7 = 'ended'
const response = await callGet(7);
expect(response).toBe(1);
expect(metadataService.getDetails).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tv', tvdb: 322399 }),
'tv',
{ merge: true },
);
});
it('returns 0 for ended when metadata says the show is continuing', async () => {
metadataService.resolveIdsFromMediaItem.mockResolvedValue({
type: 'tv',
tvdb: 1,
} as any);
metadataService.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
ended: false,
} as any);
const response = await callGet(7);
expect(response).toBe(0);
});
it('returns the season count from metadata at show level', async () => {
metadataService.resolveIdsFromMediaItem.mockResolvedValue({
type: 'tv',
tvdb: 1,
} as any);
metadataService.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
seasonCount: 4,
} as any);
// id 5 = 'seasons' (show-level returns seasonCount)
const response = await callGet(5);
expect(response).toBe(4);
});
it('returns null for ended when neither Sonarr nor metadata can supply it', async () => {
metadataService.resolveIdsFromMediaItem.mockResolvedValue(undefined);
const response = await callGet(7);
expect(response).toBeNull();
});
it('returns null for a Sonarr-only property even when metadata is available', async () => {
metadataService.resolveIdsFromMediaItem.mockResolvedValue({
type: 'tv',
tvdb: 1,
} as any);
metadataService.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
ended: true,
} as any);
// id 9 = 'monitored' (Sonarr-only state, no metadata fallback)
const response = await callGet(9);
expect(response).toBeNull();
expect(metadataService.getDetails).not.toHaveBeenCalled();
});
it('does NOT fall back when the Sonarr lookup itself fails (fail closed)', async () => {
// Transient Sonarr outage: getSeriesByTvdbId returns undefined, not null.
jest
.spyOn(mockedSonarrApi, 'getSeriesByTvdbId')
.mockResolvedValue(undefined as any);
metadataService.resolveIdsFromMediaItem.mockResolvedValue({
type: 'tv',
tvdb: 1,
} as any);
metadataService.getDetails.mockResolvedValue({
id: 1,
title: 'Sample Series',
type: 'tv',
externalIds: { type: 'tv', tvdb: 1 },
ended: true,
} as any);
const response = await callGet(7);
// Returns undefined (comparator skips) — must NOT serve metadata's
// 'ended: true' while Sonarr is unreachable, since that would change
// collection membership during an outage.
expect(response).toBeUndefined();
expect(metadataService.getDetails).not.toHaveBeenCalled();
});
});
const mockSonarrApi = (series?: SonarrSeries) => {
const mockedSonarrApi = new SonarrApi(
{ url: 'http://localhost:8989', apiKey: 'test' },
@@ -130,16 +130,43 @@ export class SonarrGetterService {
)
: sonarrApiClient.getSeriesByTvdbId(lookupId);
const matchedResult = await findMetadataLookupMatch(lookupCandidates, {
tvdb: (lookupId) => resolveSeries(lookupId),
});
const showResponse: SonarrSeries | undefined = matchedResult?.result;
const matchedResult = await findMetadataLookupMatch<SonarrSeries | null>(
lookupCandidates,
{
tvdb: (lookupId) => resolveSeries(lookupId),
},
);
const showResponse: SonarrSeries | null | undefined =
matchedResult?.result;
if (showResponse === undefined) {
// The Sonarr lookup itself failed (or every candidate's lookup
// returned undefined) — could be a transient outage. Fail closed
// rather than substituting metadata-provider values, which would
// silently change rule evaluation while Sonarr is down.
return undefined;
}
if (!showResponse?.id) {
// Sonarr confirmed the series isn't tracked. Fall back to the
// configured metadata provider for properties whose value doesn't
// depend on Sonarr's local state.
const fallback = await this.tryMetadataFallback(
libItem,
prop?.name,
dataType,
);
if (fallback.handled) {
this.logger.debug(
`Sonarr-Getter - '${libItem.title}' not in Sonarr; serving '${prop?.name}' from metadata provider. Is the series intentionally absent from Sonarr?`,
);
return fallback.value;
}
const attemptedIds = formatMetadataLookupCandidates(lookupCandidates);
this.logger.warn(
`None of the resolved external IDs [${attemptedIds}] for '${libItem.title}' matched a series in Sonarr.`,
`None of the resolved external IDs [${attemptedIds}] for '${libItem.title}' matched a series in Sonarr. Is the series tracked in Sonarr?`,
);
return null;
}
@@ -515,4 +542,75 @@ export class SonarrGetterService {
'sonarr',
);
}
// Sonarr properties whose semantics match cleanly across Sonarr / TMDB /
// TVDB. Deliberately excluded even though providers expose something
// similar: `status` (Sonarr lowercase enum vs provider free-form strings),
// `originalLanguage` (full name vs ISO 639-1 vs ISO 639-2/B), and `rating`
// (different scales / aggregations). Sonarr-only state (monitored, tags,
// filePath, diskSize, …) is also absent — providers can't supply it.
private static readonly METADATA_FALLBACK_SUPPORTED = new Set([
'ended',
'firstAirDate',
'seasons',
]);
private async tryMetadataFallback(
libItem: MediaItem,
propName: string | undefined,
dataType: MediaItemType | undefined,
): Promise<
{ handled: false } | { handled: true; value: number | string | Date | null }
> {
if (
!propName ||
!SonarrGetterService.METADATA_FALLBACK_SUPPORTED.has(propName)
) {
return { handled: false };
}
// At season/episode scope these properties mean episode-specific values
// that a show-level provider record can't supply.
if (
(propName === 'firstAirDate' || propName === 'seasons') &&
(dataType === 'season' || dataType === 'episode')
) {
return { handled: false };
}
const ids = await this.metadataService.resolveIdsFromMediaItem(libItem);
if (!ids || ids.type !== 'tv') {
return { handled: false };
}
// Merge across every configured provider so a partial primary record
// doesn't mask a field a secondary could supply.
const details = await this.metadataService.getDetails(ids, 'tv', {
merge: true,
});
if (!details) {
return { handled: false };
}
switch (propName) {
case 'ended':
return {
handled: true,
value: details.ended === undefined ? null : details.ended ? 1 : 0,
};
case 'firstAirDate':
return {
handled: true,
value: details.firstAirDate ? new Date(details.firstAirDate) : null,
};
case 'seasons':
// Match the existing Sonarr-path truthiness check (0 → null).
return {
handled: true,
value: details.seasonCount ? details.seasonCount : null,
};
}
return { handled: false };
}
}
+49 -2
View File
@@ -85,7 +85,50 @@ const MOVIES = [
movie('mock-movie-mid', 'Mock Bravo', 6, '2026-02-01'),
movie('mock-movie-low', 'Mock Charlie', 2, '2026-03-01'),
];
const ITEMS_BY_ID = new Map(MOVIES.map((m) => [m.Id, m]));
// Show counterpart, used to drive Sonarr/show-side flows (e.g. the
// metadata-fallback path when a series is absent from Sonarr). Provider IDs
// are synthetic — they won't resolve against real TMDB/TVDB, which is fine
// for any flow that doesn't depend on year-validation passing.
function series(id, name, addDate, providerIds = {}) {
return {
Id: id,
Name: name,
Type: 'Series',
ServerId: 'mockserver',
ParentId: 'jellyfin-shows',
CommunityRating: 7,
CriticRating: 70,
ProductionYear: 2020,
DateCreated: ISO(addDate),
PremiereDate: ISO(addDate),
Genres: ['Placeholder'],
Tags: [],
ProviderIds: providerIds,
ImageTags: { Primary: 'mocktag' },
UserData: {
PlayCount: 0,
Played: false,
PlayedPercentage: 0,
IsFavorite: false,
},
};
}
const SHOWS = [
series('mock-show-1', 'Mock Show Alpha', '2026-01-01', {
Tvdb: '900000001',
Tmdb: '900000001',
}),
series('mock-show-2', 'Mock Show Bravo', '2026-02-01', {
Tvdb: '900000002',
Tmdb: '900000002',
}),
];
const ITEMS_BY_ID = new Map(
[...MOVIES, ...SHOWS].map((item) => [item.Id, item]),
);
// --- HTTP helpers ----------------------------------------------------------------
function send(res, status, body) {
@@ -183,9 +226,13 @@ const server = http.createServer((req, res) => {
);
}
const parentId = u.searchParams.get('parentId');
if (parentId === 'jellyfin-movies' || u.searchParams.get('includeItemTypes') === 'Movie') {
const itemTypes = u.searchParams.get('includeItemTypes');
if (parentId === 'jellyfin-movies' || itemTypes === 'Movie') {
return send(res, 200, itemsResponse(MOVIES));
}
if (parentId === 'jellyfin-shows' || itemTypes === 'Series') {
return send(res, 200, itemsResponse(SHOWS));
}
// BoxSets / collections, episodes, etc. -> empty for now
return send(res, 200, itemsResponse([]));
}