mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-06-01 18:48:13 +02:00
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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user