mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment (#1239)
* fix(tmdb): stop a broken provider tmdb_id from suppressing enrichment
Providers ship dead and stale tmdb_id values, and enrich() trusted them
unconditionally:
parseProviderTmdbId(query.tmdbId) ?? await resolveIdBySearch(...)
A garbage-but-integer id short-circuited the title search entirely. The
details fetch then 404'd, the outer catch swallowed it, and the item was
left permanently unenriched — no plot, no cast, no artwork — for a title
the search would have matched. Failed detail fetches cache nothing, so
the wasted request repeated on every re-open. The stale-but-valid case
was worse: it never threw, nothing sanity-checked the resolved title, and
we confidently rendered another film's metadata.
enrich() now treats the provider id as a hint. If it fails to resolve, or
resolves to something whose title matches none of the search variants we
would have queried, the confidence-gated title search gets its turn — and
proven-bad ids are negative-cached (7d, language-independent row) so the
404 is not repeated forever.
Deliberately NOT a hard rejection on title mismatch: TMDB returns titles
in the REQUEST language, so a Russian provider title legitimately fails
the name check against an en-US payload. A mismatch only lets the search
compete; when the search finds nothing confident, the provider payload is
kept. The change can therefore only add enrichment, never remove it.
Extracts the search resolution and the bad-id cache into
TmdbIdResolverService — tmdb-enrichment.service.ts was at 290 lines
against the 300-line target, and the resolver is independently testable.
Tests: new tmdb-enrichment.service.spec.ts covers the happy path issuing
exactly one details call and no search, 404 fallback, stale-id override,
the keep-the-payload safety property, bad-id skip, and the no-match case;
matcher spec covers detailsMatchProviderTitle and the namespaced cache key.
Refs docs/architecture/tmdb-roadmap.md A1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): only blame a provider id when TMDB confirms it does not exist
Review found the bad-id negative cache too eager in two ways, both of
which could deny enrichment to items whose id was fine.
1. Any failure recorded the verdict. A 401, 429, 5xx or an offline blip
would mark a perfectly valid id as dead for seven days, so after the
service recovered — or the user fixed their API key — titles that the
search cannot resolve confidently stayed unenriched until the marker
expired. TmdbApiService now throws a typed TmdbApiError carrying the
status, and only a confirmed 404 is recorded.
2. Title mismatches were recorded too. That id EXISTS; it is merely wrong
for this item. The row is keyed by id alone and shared across
playlists, so a stale mapping on one item disabled the direct lookup
for every other item that legitimately used the same id. Mismatches
are no longer cached at all — the search verdict is cached anyway, so
the repeat cost is a single details fetch.
Documents the row kind in the cache contract, which listed only two of
the (now six) lookup_key shapes.
Tests: 404 records, 429 does not, network error does not, mismatch does
not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): keep provider details when the competing search fails
detailsForProviderId only runs the search to see whether it can beat a
title-mismatched provider payload. A throw from that best-effort search
(offline, rate limit, 5xx) propagated to enrich()'s outer catch and threw
away details we already had — the searched-details fetch right below it
was already tolerant. Fail to the details in hand instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): decide a suspect provider id on evidence, not on the title
The title check alone was both too weak and too dangerous.
Too weak: normalizeTitle strips trailing years, so "Blade Runner 2049"
carrying the 1982 film's id matched and the wrong film was rendered —
exactly the stale-id case this was meant to catch.
Too dangerous: an ALL-CAPS leading token reads as a language tag, so
"IT - Chapter Two" normalizes to "chapter two". The correct payload
failed the name check, and a year-less search for "chapter two" would
confidently return the 1979 film and overwrite it. Master trusted the
provider id here and got it right.
assessProviderId weighs both signals: title or year agrees means use the
details; both years known and incompatible means the search may take
over; a title-only mismatch is inconclusive and keeps the details. The
search branch now always has a year, so its own gate corroborates
whatever it returns instead of matching on name alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tmdb): do not search after a transient provider-id failure
enrich() reads a null from detailsForProviderId as "the id is unusable,
try the search". A 401/429/5xx/offline failure gave it that null, so an
outage turned into a second request that would fail too — and if it did
come back, a title match replaced a provider id that was probably fine.
Only a 404 falls through to the search now; everything else rethrows and
leaves the id retryable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(tmdb): add the release note for the provider-id fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4ec68c1fa
commit
d76b2d2a57
11 files changed
+869
-100
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: tmdb
|
||||
---
|
||||
|
||||
Movies whose provider ships a dead or wrong TMDB id are enriched again. The
|
||||
id is weighed against the title and release year: a dead one falls back to
|
||||
the title search, a stale one that clearly points at another film loses to
|
||||
it, and a working id is no longer thrown away just because the provider
|
||||
spells the title differently.
|
||||
@@ -836,7 +836,7 @@ engine` (restart required) or
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
|
||||
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
|
||||
- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
|
||||
- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
|
||||
- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
|
||||
- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.
|
||||
- Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`)
|
||||
|
||||
@@ -83,8 +83,29 @@ in the settings section validates the API key against `/configuration`.
|
||||
Wrong metadata is worse than no metadata, so id resolution is conservative:
|
||||
|
||||
1. If the provider returns a usable `tmdb_id` (Xtream VOD info often does),
|
||||
it is trusted fully and no search runs. Series have no show-level
|
||||
`tmdb_id`, so they always go through search.
|
||||
its details are fetched directly and normally used as-is. Series have no
|
||||
show-level `tmdb_id`, so they always go through search. The id is a
|
||||
strong hint rather than gospel — panels ship dead and stale ones — so
|
||||
the payload it returns is weighed against the provider item
|
||||
(`assessProviderId`):
|
||||
- a matching title **or** a compatible release year (±1; for series,
|
||||
any earlier premiere) → **corroborated**, use it;
|
||||
- both years known and incompatible → **contradicted**, the stale-id
|
||||
signature ("Blade Runner 2049" carrying the 1982 film's id): the
|
||||
title search may take over, and does so only if it finds a confident
|
||||
match of its own;
|
||||
- title differs with no year to arbitrate → **inconclusive**, keep the
|
||||
details. TMDB returns titles in the request language and
|
||||
normalization strips stylized prefixes ("IT - Chapter Two" →
|
||||
"chapter two"), so a name mismatch alone says more about our inputs
|
||||
than about the id.
|
||||
|
||||
A 404 is the one hard verdict: the id is recorded as dead
|
||||
(`badProviderId:<id>` row), skipped next time, and the title search
|
||||
takes over. Transient failures (auth, rate limit, 5xx, offline) neither
|
||||
disable the id nor trigger a search — that request would hit the same
|
||||
outage, and a title match that did come back would be weaker evidence
|
||||
than the id already in hand.
|
||||
2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized
|
||||
title. Normalization strips bracketed tags, quality markers (`4K`,
|
||||
`1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics,
|
||||
@@ -247,14 +268,18 @@ Filmography has two scopes:
|
||||
|
||||
## Cache
|
||||
|
||||
Single table with two row kinds discriminated by `lookup_key` prefix:
|
||||
Single table with several row kinds discriminated by the `lookup_key`
|
||||
prefix:
|
||||
|
||||
```
|
||||
tmdb_metadata (
|
||||
media_type 'movie' | 'tv' | 'person',
|
||||
lookup_key 'id:<tmdbId>|v2' -- details payload row
|
||||
'id:<tmdbId>|season:<n>' -- season payload row
|
||||
'title:<normalized>|year:<y>|v2' -- search resolution row
|
||||
'person:<personId>' -- person payload row
|
||||
'trending:week' -- trending list row
|
||||
'badProviderId:<tmdbId>' -- id confirmed 404 by TMDB
|
||||
language TEXT, -- TMDB language code
|
||||
tmdb_id INTEGER, -- NULL on a search row = negative cache
|
||||
payload TEXT, -- raw JSON details, NULL for search rows
|
||||
@@ -343,3 +368,18 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
|
||||
### `badProviderId:` rows
|
||||
|
||||
Providers ship `tmdb_id` values that do not exist. A failed details fetch
|
||||
caches nothing, so without a marker the same 404 is re-issued on every
|
||||
detail open, forever. These rows record that verdict: `tmdb_id` NULL,
|
||||
language `any` (a dead id is dead in every language), read with the
|
||||
negative-match TTL.
|
||||
|
||||
Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx,
|
||||
offline) leave no marker — they say nothing about the id. Neither does a
|
||||
title mismatch: that id exists and may be correct for a *different* item,
|
||||
and since the row is keyed by id alone and shared across playlists,
|
||||
recording per-item mismatches here would deny the direct lookup to every
|
||||
other item that legitimately uses the same id.
|
||||
@@ -2,6 +2,7 @@ export * from './tmdb-api.service';
|
||||
export * from './tmdb-cache.service';
|
||||
export * from './tmdb-config';
|
||||
export * from './tmdb-enrichment.service';
|
||||
export * from './tmdb-id-resolver.service';
|
||||
export * from './tmdb-episode-merge';
|
||||
export * from './tmdb-matcher';
|
||||
export * from './tmdb-merge';
|
||||
|
||||
@@ -14,6 +14,25 @@ import {
|
||||
* both the Electron renderer and the PWA can call it without a proxy.
|
||||
* Supports classic v3 keys (query param) and v4 read tokens (Bearer).
|
||||
*/
|
||||
/**
|
||||
* A non-2xx TMDB response. Carries the status so callers can tell a
|
||||
* definitive verdict (404 — this id does not exist) apart from a
|
||||
* transient one (401/429/5xx, offline), which must stay retryable.
|
||||
*/
|
||||
export class TmdbApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
statusText = ''
|
||||
) {
|
||||
super(`TMDB request failed: ${status} ${statusText}`.trim());
|
||||
this.name = 'TmdbApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isTmdbNotFound(error: unknown): boolean {
|
||||
return error instanceof TmdbApiError && error.status === 404;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TmdbApiService {
|
||||
async searchMovie(
|
||||
@@ -155,9 +174,7 @@ export class TmdbApiService {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`TMDB request failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
throw new TmdbApiError(response.status, response.statusText);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { TmdbApiError, TmdbApiService } from './tmdb-api.service';
|
||||
import { TmdbCacheService } from './tmdb-cache.service';
|
||||
import { TmdbEnrichmentService } from './tmdb-enrichment.service';
|
||||
import { TmdbIdResolverService } from './tmdb-id-resolver.service';
|
||||
import { TmdbPersonService } from './tmdb-person.service';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbSeasonService } from './tmdb-season.service';
|
||||
import { TmdbTrendingService } from './tmdb-trending.service';
|
||||
import { TmdbMovieDetails } from './tmdb.types';
|
||||
|
||||
/**
|
||||
* Regression coverage for provider-supplied `tmdb_id` handling. Panels
|
||||
* ship dead and stale ids; before this, a dead one short-circuited the
|
||||
* title search (leaving the item permanently unenriched) and a stale one
|
||||
* silently rendered another film's metadata.
|
||||
*/
|
||||
describe('TmdbEnrichmentService — provider tmdb_id handling', () => {
|
||||
const matrix: TmdbMovieDetails = {
|
||||
id: 603,
|
||||
title: 'The Matrix',
|
||||
original_title: 'The Matrix',
|
||||
overview: 'Plot',
|
||||
videos: { results: [{ key: 'k', site: 'YouTube', type: 'Trailer' }] },
|
||||
};
|
||||
const unrelated: TmdbMovieDetails = {
|
||||
id: 999,
|
||||
title: 'Completely Different Film',
|
||||
original_title: 'Completely Different Film',
|
||||
release_date: '1979-12-14',
|
||||
overview: 'Other plot',
|
||||
videos: { results: [{ key: 'x', site: 'YouTube', type: 'Trailer' }] },
|
||||
};
|
||||
|
||||
let getMovieDetails: jest.Mock;
|
||||
let resolveBySearch: jest.Mock;
|
||||
let isKnownBadProviderId: jest.Mock;
|
||||
let rememberBadProviderId: jest.Mock;
|
||||
|
||||
// The services Jest target has no @angular/core/testing — build the
|
||||
// service in a plain injection context instead of TestBed.
|
||||
function createService(): TmdbEnrichmentService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbRuntimeService,
|
||||
useValue: {
|
||||
isEnabled: () => true,
|
||||
apiKey: () => 'key',
|
||||
language: () => 'en-US',
|
||||
appLanguage: () => 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TmdbApiService,
|
||||
useValue: { getMovieDetails, getTvDetails: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: TmdbCacheService,
|
||||
useValue: {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
isFresh: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TmdbIdResolverService,
|
||||
useValue: {
|
||||
resolveBySearch,
|
||||
isKnownBadProviderId,
|
||||
rememberBadProviderId,
|
||||
},
|
||||
},
|
||||
{ provide: TmdbPersonService, useValue: {} },
|
||||
{ provide: TmdbSeasonService, useValue: {} },
|
||||
{ provide: TmdbTrendingService, useValue: {} },
|
||||
],
|
||||
});
|
||||
return runInInjectionContext(
|
||||
injector,
|
||||
() => new TmdbEnrichmentService()
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMovieDetails = jest.fn().mockResolvedValue(matrix);
|
||||
resolveBySearch = jest.fn().mockResolvedValue(null);
|
||||
isKnownBadProviderId = jest.fn().mockResolvedValue(false);
|
||||
rememberBadProviderId = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses a valid provider id without searching at all', async () => {
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 603,
|
||||
title: 'The Matrix',
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(603);
|
||||
expect(getMovieDetails).toHaveBeenCalledTimes(1);
|
||||
expect(resolveBySearch).not.toHaveBeenCalled();
|
||||
expect(rememberBadProviderId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the title search when the provider id 404s', async () => {
|
||||
getMovieDetails
|
||||
.mockRejectedValueOnce(new TmdbApiError(404, 'Not Found'))
|
||||
.mockResolvedValueOnce(matrix);
|
||||
resolveBySearch.mockResolvedValue(603);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 123456789,
|
||||
title: 'The Matrix',
|
||||
});
|
||||
|
||||
// Previously this returned null: the dead id skipped the search
|
||||
expect(details?.id).toBe(603);
|
||||
expect(resolveBySearch).toHaveBeenCalledTimes(1);
|
||||
expect(rememberBadProviderId).toHaveBeenCalledWith(
|
||||
'movie',
|
||||
123456789
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers a confident search match over a stale provider id', async () => {
|
||||
// The id resolves, but to a 1979 film while our item is from 1999
|
||||
getMovieDetails
|
||||
.mockResolvedValueOnce(unrelated)
|
||||
.mockResolvedValueOnce(matrix);
|
||||
resolveBySearch.mockResolvedValue(603);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 999,
|
||||
title: 'The Matrix',
|
||||
year: 1999,
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(603);
|
||||
// The id EXISTS — it is just wrong for this item. The bad-id row is
|
||||
// keyed by id alone and shared across playlists, so recording a
|
||||
// per-item mismatch would deny the direct lookup to every other
|
||||
// item that legitimately uses the same id.
|
||||
expect(rememberBadProviderId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not blame the id for a transient failure', async () => {
|
||||
// Rate limit, bad key, 5xx or offline: the id may be perfectly
|
||||
// fine, so it must stay retryable rather than be disabled for days
|
||||
getMovieDetails.mockRejectedValue(new TmdbApiError(429, 'Too Many'));
|
||||
resolveBySearch.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' });
|
||||
|
||||
expect(rememberBadProviderId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not search after a transient provider-id failure', async () => {
|
||||
// The search would hit the same outage, and a title match that
|
||||
// does come back is weaker evidence than the id we already have
|
||||
getMovieDetails.mockRejectedValue(new TmdbApiError(503, 'Down'));
|
||||
resolveBySearch.mockResolvedValue(603);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 603,
|
||||
title: 'The Matrix',
|
||||
});
|
||||
|
||||
expect(details).toBeNull();
|
||||
expect(resolveBySearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not blame the id for a network error', async () => {
|
||||
getMovieDetails.mockRejectedValue(new Error('offline'));
|
||||
resolveBySearch.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
await service.enrichMovie({ tmdbId: 603, title: 'The Matrix' });
|
||||
|
||||
expect(rememberBadProviderId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the provider payload when the title differs but the search finds nothing', async () => {
|
||||
// A localized provider title legitimately fails the name check —
|
||||
// TMDB returns titles in the REQUEST language. Never trade real
|
||||
// metadata for none on suspicion alone.
|
||||
getMovieDetails.mockResolvedValue(unrelated);
|
||||
resolveBySearch.mockResolvedValue(null);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 999,
|
||||
title: 'Ирония судьбы',
|
||||
year: 1979,
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(999);
|
||||
expect(rememberBadProviderId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never lets a name mismatch alone unseat a working provider id', async () => {
|
||||
// "IT - Chapter Two" normalizes to "chapter two" (the ALL-CAPS "IT"
|
||||
// reads as a language tag), so the correct payload fails the name
|
||||
// check. With no year to arbitrate, a search for "chapter two"
|
||||
// would confidently return the 1979 film of that name.
|
||||
getMovieDetails.mockResolvedValue({
|
||||
id: 474350,
|
||||
title: 'It Chapter Two',
|
||||
original_title: 'It Chapter Two',
|
||||
overview: 'Plot',
|
||||
});
|
||||
resolveBySearch.mockResolvedValue(30619);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 474350,
|
||||
title: 'IT - Chapter Two',
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(474350);
|
||||
expect(resolveBySearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('catches a stale id that shares the title but not the year', async () => {
|
||||
// The scraper case: "Blade Runner 2049" carrying the 1982 film's id.
|
||||
// Both titles normalize to "blade runner", so only the year sees it.
|
||||
getMovieDetails
|
||||
.mockResolvedValueOnce({
|
||||
id: 78,
|
||||
title: 'Blade Runner',
|
||||
release_date: '1982-06-25',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 335984,
|
||||
title: 'Blade Runner 2049',
|
||||
release_date: '2017-10-04',
|
||||
});
|
||||
resolveBySearch.mockResolvedValue(335984);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 78,
|
||||
title: 'Blade Runner 2049',
|
||||
year: 2017,
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(335984);
|
||||
});
|
||||
|
||||
it('keeps the provider payload when the competing search throws', async () => {
|
||||
// Same reasoning as above, but the search never returns a verdict:
|
||||
// TMDB is offline or rate-limiting. Details in hand beat nothing.
|
||||
getMovieDetails.mockResolvedValue(unrelated);
|
||||
resolveBySearch.mockRejectedValue(new TmdbApiError(503, 'Down'));
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 999,
|
||||
title: 'Ирония судьбы',
|
||||
year: 1999,
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(999);
|
||||
});
|
||||
|
||||
it('skips a provider id already known to be bad', async () => {
|
||||
isKnownBadProviderId.mockResolvedValue(true);
|
||||
resolveBySearch.mockResolvedValue(603);
|
||||
const service = createService();
|
||||
|
||||
const details = await service.enrichMovie({
|
||||
tmdbId: 123456789,
|
||||
title: 'The Matrix',
|
||||
});
|
||||
|
||||
expect(details?.id).toBe(603);
|
||||
// One details call for the searched id — none for the dead one
|
||||
expect(getMovieDetails).toHaveBeenCalledTimes(1);
|
||||
expect(getMovieDetails).toHaveBeenCalledWith(603, 'en-US', 'key');
|
||||
});
|
||||
|
||||
it('returns null when there is no provider id and no search match', async () => {
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.enrichMovie({ title: 'Unknown Thing' })
|
||||
).resolves.toBeNull();
|
||||
expect(getMovieDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,14 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
|
||||
import { TmdbApiService } from './tmdb-api.service';
|
||||
import { TmdbApiService, isTmdbNotFound } from './tmdb-api.service';
|
||||
import { TmdbCacheService } from './tmdb-cache.service';
|
||||
import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config';
|
||||
import {
|
||||
TMDB_DETAILS_CACHE_TTL_MS,
|
||||
TMDB_MATCH_CACHE_TTL_MS,
|
||||
TMDB_NEGATIVE_MATCH_CACHE_TTL_MS,
|
||||
tmdbSearchLanguageForTitle,
|
||||
} from './tmdb-config';
|
||||
import {
|
||||
assessProviderId,
|
||||
buildDetailsLookupKey,
|
||||
buildSearchLookupKey,
|
||||
buildSearchTitleVariants,
|
||||
extractYear,
|
||||
parseProviderTmdbId,
|
||||
pickConfidentMatch,
|
||||
} from './tmdb-matcher';
|
||||
import { TmdbIdResolverService } from './tmdb-id-resolver.service';
|
||||
import {
|
||||
detailsFallbackLanguage,
|
||||
fillDetailsFromFallback,
|
||||
@@ -53,6 +46,7 @@ export class TmdbEnrichmentService {
|
||||
private readonly person = inject(TmdbPersonService);
|
||||
private readonly season = inject(TmdbSeasonService);
|
||||
private readonly trending = inject(TmdbTrendingService);
|
||||
private readonly idResolver = inject(TmdbIdResolverService);
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.runtime.isEnabled();
|
||||
@@ -107,15 +101,31 @@ export class TmdbEnrichmentService {
|
||||
}
|
||||
|
||||
try {
|
||||
const tmdbId =
|
||||
parseProviderTmdbId(query.tmdbId) ??
|
||||
(await this.resolveIdBySearch(mediaType, query));
|
||||
|
||||
if (tmdbId === null) {
|
||||
return null;
|
||||
const providerId = parseProviderTmdbId(query.tmdbId);
|
||||
if (
|
||||
providerId !== null &&
|
||||
!(await this.idResolver.isKnownBadProviderId(
|
||||
mediaType,
|
||||
providerId
|
||||
))
|
||||
) {
|
||||
const details = await this.detailsForProviderId(
|
||||
mediaType,
|
||||
providerId,
|
||||
query
|
||||
);
|
||||
if (details) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.getDetails(mediaType, tmdbId);
|
||||
const searchedId = await this.idResolver.resolveBySearch(
|
||||
mediaType,
|
||||
query
|
||||
);
|
||||
return searchedId === null
|
||||
? null
|
||||
: await this.getDetails(mediaType, searchedId);
|
||||
} catch (error) {
|
||||
console.warn(`TMDB ${mediaType} enrichment failed:`, error);
|
||||
return null;
|
||||
@@ -123,88 +133,79 @@ export class TmdbEnrichmentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a title/year to a TMDB id via /search with the confidence
|
||||
* gate. Both hits and misses are cached; misses use a shorter TTL.
|
||||
* Details for a provider-supplied `tmdb_id`, or `null` when the id is
|
||||
* unusable and the caller should fall back to the title search.
|
||||
*
|
||||
* Providers ship broken ids. A dead one used to short-circuit the
|
||||
* search and leave the item permanently unenriched; a stale-but-valid
|
||||
* one silently rendered another title's plot and cast. Both cases now
|
||||
* defer to the (confidence-gated) search — but only on evidence the id
|
||||
* is wrong, and only when the search actually produces something, so a
|
||||
* localized or stylized title never costs the user their metadata.
|
||||
*/
|
||||
private async resolveIdBySearch(
|
||||
private async detailsForProviderId(
|
||||
mediaType: TmdbMediaType,
|
||||
providerId: number,
|
||||
query: TmdbEnrichmentQuery
|
||||
): Promise<number | null> {
|
||||
// Try the original title, the display title, then language-prefix-
|
||||
// stripped fallbacks; the first confident match wins.
|
||||
const variants = buildSearchTitleVariants(
|
||||
query.title,
|
||||
query.originalTitle
|
||||
);
|
||||
if (variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = query.year ?? extractYear(null, query.title);
|
||||
const cacheLanguage = tmdbSearchLanguageForTitle(
|
||||
variants[0],
|
||||
this.runtime.appLanguage()
|
||||
);
|
||||
const lookupKey = buildSearchLookupKey(variants[0], year);
|
||||
|
||||
const cached = await this.cache.get(
|
||||
mediaType,
|
||||
lookupKey,
|
||||
cacheLanguage
|
||||
);
|
||||
const ttl =
|
||||
cached?.tmdbId !== null && cached?.tmdbId !== undefined
|
||||
? TMDB_MATCH_CACHE_TTL_MS
|
||||
: TMDB_NEGATIVE_MATCH_CACHE_TTL_MS;
|
||||
if (this.cache.isFresh(cached, ttl)) {
|
||||
return cached?.tmdbId ?? null;
|
||||
}
|
||||
|
||||
let match = null;
|
||||
for (const variant of variants) {
|
||||
// Cyrillic (and other non-app-script) titles search in their
|
||||
// own language so TMDB returns comparable titles — see
|
||||
// tmdbSearchLanguageForTitle. Search by title only: TMDB's
|
||||
// year params filter strictly; the ±1/season tolerance lives
|
||||
// in pickConfidentMatch instead.
|
||||
const language = tmdbSearchLanguageForTitle(
|
||||
variant,
|
||||
this.runtime.appLanguage()
|
||||
);
|
||||
const results =
|
||||
mediaType === 'movie'
|
||||
? await this.api.searchMovie(
|
||||
variant,
|
||||
null,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
)
|
||||
: await this.api.searchTv(
|
||||
variant,
|
||||
null,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
);
|
||||
|
||||
match = pickConfidentMatch(
|
||||
results,
|
||||
{ title: variant, year },
|
||||
mediaType
|
||||
);
|
||||
if (match) {
|
||||
break;
|
||||
): Promise<TmdbDetails | null> {
|
||||
let details: TmdbDetails | null;
|
||||
try {
|
||||
details = await this.getDetails(mediaType, providerId);
|
||||
} catch (error) {
|
||||
// Only a 404 proves the id is dead. Auth, rate-limit, 5xx and
|
||||
// offline failures are transient — remembering those would
|
||||
// disable a perfectly good id until the marker expires.
|
||||
if (isTmdbNotFound(error)) {
|
||||
console.warn(
|
||||
`TMDB ${mediaType} provider id ${providerId} does not exist, falling back to search:`,
|
||||
error
|
||||
);
|
||||
await this.idResolver.rememberBadProviderId(
|
||||
mediaType,
|
||||
providerId
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rethrow instead: `enrich` reads a null here as "try the
|
||||
// search", and searching during an outage or a rate limit just
|
||||
// adds a request that will fail too — or, worse, succeeds and
|
||||
// swaps a strong id for a title match. The id stays good for
|
||||
// the next attempt.
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.cache.set({
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language: cacheLanguage,
|
||||
tmdbId: match?.id ?? null,
|
||||
payload: null,
|
||||
});
|
||||
if (!details || assessProviderId(details, query, mediaType) !== 'contradicted') {
|
||||
return details;
|
||||
}
|
||||
|
||||
return match?.id ?? null;
|
||||
// The id resolves to a title whose release year contradicts the
|
||||
// provider's — the stale-id signature. Let the search try to beat
|
||||
// it; keep these details if it cannot.
|
||||
//
|
||||
// Deliberately NOT remembered: the id exists and may be correct for
|
||||
// a DIFFERENT item. The bad-id cache is keyed by id alone and shared
|
||||
// across playlists, so recording a per-item mismatch there would
|
||||
// deny the direct lookup to every other item that legitimately uses
|
||||
// the same id. The search verdict is cached anyway, so the repeat
|
||||
// cost is one details fetch.
|
||||
//
|
||||
// The search is best-effort here: offline, rate-limited or 5xx must
|
||||
// fall back to the details we already have, not discard them. Its
|
||||
// own gate needs a year, which this branch guarantees we have, so a
|
||||
// result that comes back is corroborated rather than merely named
|
||||
// alike.
|
||||
const searchedId = await this.idResolver
|
||||
.resolveBySearch(mediaType, query)
|
||||
.catch(() => null);
|
||||
if (searchedId === null || searchedId === providerId) {
|
||||
return details;
|
||||
}
|
||||
|
||||
const searched = await this.getDetails(mediaType, searchedId).catch(
|
||||
() => null
|
||||
);
|
||||
return searched ?? details;
|
||||
}
|
||||
|
||||
private async getDetails(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
|
||||
import { TmdbApiService } from './tmdb-api.service';
|
||||
import { TmdbCacheService } from './tmdb-cache.service';
|
||||
import {
|
||||
TMDB_MATCH_CACHE_TTL_MS,
|
||||
TMDB_NEGATIVE_MATCH_CACHE_TTL_MS,
|
||||
tmdbSearchLanguageForTitle,
|
||||
} from './tmdb-config';
|
||||
import {
|
||||
buildBadProviderIdLookupKey,
|
||||
buildSearchLookupKey,
|
||||
buildSearchTitleVariants,
|
||||
extractYear,
|
||||
pickConfidentMatch,
|
||||
} from './tmdb-matcher';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbEnrichmentQuery } from './tmdb.types';
|
||||
|
||||
/**
|
||||
* "This id is wrong" verdicts are about the provider's data, not about a
|
||||
* translation, so they are cached under one language-independent row.
|
||||
*/
|
||||
const BAD_ID_CACHE_LANGUAGE = 'any';
|
||||
|
||||
/**
|
||||
* Resolves a provider item to a TMDB id: the confidence-gated title search
|
||||
* plus the negative cache for provider-supplied ids we have proven wrong.
|
||||
* Extracted from the enrichment orchestrator so both concerns stay
|
||||
* testable and the facade keeps its size in check.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TmdbIdResolverService {
|
||||
private readonly runtime = inject(TmdbRuntimeService);
|
||||
private readonly api = inject(TmdbApiService);
|
||||
private readonly cache = inject(TmdbCacheService);
|
||||
|
||||
/**
|
||||
* Resolve a title/year to a TMDB id via /search with the confidence
|
||||
* gate. Both hits and misses are cached; misses use a shorter TTL.
|
||||
*/
|
||||
async resolveBySearch(
|
||||
mediaType: TmdbMediaType,
|
||||
query: TmdbEnrichmentQuery
|
||||
): Promise<number | null> {
|
||||
// Try the original title, the display title, then language-prefix-
|
||||
// stripped fallbacks; the first confident match wins.
|
||||
const variants = buildSearchTitleVariants(
|
||||
query.title,
|
||||
query.originalTitle
|
||||
);
|
||||
if (variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = query.year ?? extractYear(null, query.title);
|
||||
const cacheLanguage = tmdbSearchLanguageForTitle(
|
||||
variants[0],
|
||||
this.runtime.appLanguage()
|
||||
);
|
||||
const lookupKey = buildSearchLookupKey(variants[0], year);
|
||||
|
||||
const cached = await this.cache.get(
|
||||
mediaType,
|
||||
lookupKey,
|
||||
cacheLanguage
|
||||
);
|
||||
const ttl =
|
||||
cached?.tmdbId !== null && cached?.tmdbId !== undefined
|
||||
? TMDB_MATCH_CACHE_TTL_MS
|
||||
: TMDB_NEGATIVE_MATCH_CACHE_TTL_MS;
|
||||
if (this.cache.isFresh(cached, ttl)) {
|
||||
return cached?.tmdbId ?? null;
|
||||
}
|
||||
|
||||
let match = null;
|
||||
for (const variant of variants) {
|
||||
// Cyrillic (and other non-app-script) titles search in their
|
||||
// own language so TMDB returns comparable titles — see
|
||||
// tmdbSearchLanguageForTitle. Search by title only: TMDB's
|
||||
// year params filter strictly; the ±1/season tolerance lives
|
||||
// in pickConfidentMatch instead.
|
||||
const language = tmdbSearchLanguageForTitle(
|
||||
variant,
|
||||
this.runtime.appLanguage()
|
||||
);
|
||||
const results =
|
||||
mediaType === 'movie'
|
||||
? await this.api.searchMovie(
|
||||
variant,
|
||||
null,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
)
|
||||
: await this.api.searchTv(
|
||||
variant,
|
||||
null,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
);
|
||||
|
||||
match = pickConfidentMatch(
|
||||
results,
|
||||
{ title: variant, year },
|
||||
mediaType
|
||||
);
|
||||
if (match) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.cache.set({
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language: cacheLanguage,
|
||||
tmdbId: match?.id ?? null,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this id is known NOT TO EXIST on TMDB. Without this a dead
|
||||
* id costs one wasted 404 on every single detail open, forever —
|
||||
* failed detail fetches cache nothing.
|
||||
*
|
||||
* The verdict is deliberately about the ID, not about the item that
|
||||
* supplied it: the row is keyed by id alone and shared across
|
||||
* playlists, so only "TMDB returned 404 for this id" may be recorded
|
||||
* here. A per-item mismatch is never cached — see
|
||||
* `TmdbEnrichmentService.detailsForProviderId`.
|
||||
*/
|
||||
async isKnownBadProviderId(
|
||||
mediaType: TmdbMediaType,
|
||||
tmdbId: number
|
||||
): Promise<boolean> {
|
||||
const cached = await this.cache.get(
|
||||
mediaType,
|
||||
buildBadProviderIdLookupKey(tmdbId),
|
||||
BAD_ID_CACHE_LANGUAGE
|
||||
);
|
||||
return this.cache.isFresh(cached, TMDB_NEGATIVE_MATCH_CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
async rememberBadProviderId(
|
||||
mediaType: TmdbMediaType,
|
||||
tmdbId: number
|
||||
): Promise<void> {
|
||||
await this.cache.set({
|
||||
mediaType,
|
||||
lookupKey: buildBadProviderIdLookupKey(tmdbId),
|
||||
language: BAD_ID_CACHE_LANGUAGE,
|
||||
tmdbId: null,
|
||||
payload: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
buildBadProviderIdLookupKey,
|
||||
buildDetailsLookupKey,
|
||||
buildSearchLookupKey,
|
||||
detailsMatchProviderTitle,
|
||||
buildSearchTitleVariants,
|
||||
extractYear,
|
||||
normalizeTitle,
|
||||
@@ -295,3 +297,58 @@ describe('pickConfidentMatch', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBadProviderIdLookupKey', () => {
|
||||
it('namespaces the id so it cannot collide with a details row', () => {
|
||||
expect(buildBadProviderIdLookupKey(603)).toBe('badProviderId:603');
|
||||
expect(buildBadProviderIdLookupKey(603)).not.toBe(
|
||||
buildDetailsLookupKey(603)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailsMatchProviderTitle', () => {
|
||||
const details = {
|
||||
title: 'The Matrix',
|
||||
original_title: 'The Matrix',
|
||||
};
|
||||
|
||||
it('accepts a match on a noisy provider title', () => {
|
||||
expect(
|
||||
detailsMatchProviderTitle(details, {
|
||||
title: 'EN - The Matrix (1999) 1080p',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a match against the original title only', () => {
|
||||
expect(
|
||||
detailsMatchProviderTitle(
|
||||
{ title: 'Die Hard', original_title: 'Stirb langsam' },
|
||||
{ title: 'Stirb langsam' }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches series payloads through name/original_name', () => {
|
||||
expect(
|
||||
detailsMatchProviderTitle(
|
||||
{ name: 'The Boys', original_name: 'The Boys' },
|
||||
{ title: 'The Boys s05' }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a mismatch for an unrelated title', () => {
|
||||
expect(
|
||||
detailsMatchProviderTitle(details, { title: 'Blade Runner' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never reports a mismatch when there is nothing to compare', () => {
|
||||
expect(detailsMatchProviderTitle(details, { title: '' })).toBe(true);
|
||||
expect(detailsMatchProviderTitle({}, { title: 'The Matrix' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,11 @@ export function buildDetailsLookupKey(tmdbId: number): string {
|
||||
return `id:${tmdbId}|v2`;
|
||||
}
|
||||
|
||||
/** Negative-cache key for a provider tmdb_id we have proven wrong */
|
||||
export function buildBadProviderIdLookupKey(tmdbId: number): string {
|
||||
return `badProviderId:${tmdbId}`;
|
||||
}
|
||||
|
||||
/** Provider tmdb_id fields arrive as number, numeric string, or garbage */
|
||||
export function parseProviderTmdbId(
|
||||
tmdbId: number | string | null | undefined
|
||||
@@ -90,6 +95,100 @@ export function parseProviderTmdbId(
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the payload behind a provider `tmdb_id` says about that id.
|
||||
*
|
||||
* - `corroborated`: title or release year agrees; use the details.
|
||||
* - `contradicted`: both years are known and they disagree — the classic
|
||||
* stale id ("Blade Runner 2049" carrying the 1982 film's id). Only this
|
||||
* verdict is strong enough to let the title search take over.
|
||||
* - `inconclusive`: the title differs and there is no year to arbitrate
|
||||
* with. Keep the details: TMDB returns titles in the REQUEST language,
|
||||
* and normalization strips stylized prefixes ("IT - Chapter Two"), so a
|
||||
* name mismatch alone says more about our own inputs than about the id.
|
||||
*/
|
||||
export type ProviderIdVerdict =
|
||||
| 'corroborated'
|
||||
| 'contradicted'
|
||||
| 'inconclusive';
|
||||
|
||||
/** The same tolerance the search gate uses, applied in reverse */
|
||||
function yearsAgree(
|
||||
providerYear: number,
|
||||
detailsYear: number,
|
||||
mediaType: TmdbMediaType
|
||||
): boolean {
|
||||
if (Math.abs(detailsYear - providerYear) <= 1) {
|
||||
return true;
|
||||
}
|
||||
// Series: portals report the current season's year while TMDB reports
|
||||
// the premiere, so a show that started earlier still agrees.
|
||||
return mediaType === 'tv' && detailsYear < providerYear;
|
||||
}
|
||||
|
||||
export function assessProviderId(
|
||||
details: {
|
||||
title?: string;
|
||||
original_title?: string;
|
||||
release_date?: string;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
first_air_date?: string;
|
||||
},
|
||||
query: { title?: string | null; originalTitle?: string | null; year?: number | null },
|
||||
mediaType: TmdbMediaType
|
||||
): ProviderIdVerdict {
|
||||
// Same effective year the search would use, so the two agree on what
|
||||
// "the provider's year" means
|
||||
const providerYear = query.year ?? extractYear(null, query.title);
|
||||
const detailsYear = extractYear(
|
||||
mediaType === 'movie' ? details.release_date : details.first_air_date
|
||||
);
|
||||
|
||||
if (providerYear !== null && detailsYear !== null) {
|
||||
return yearsAgree(providerYear, detailsYear, mediaType)
|
||||
? 'corroborated'
|
||||
: 'contradicted';
|
||||
}
|
||||
|
||||
return detailsMatchProviderTitle(details, query)
|
||||
? 'corroborated'
|
||||
: 'inconclusive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a details payload plausibly describe the item we asked about?
|
||||
* Title-only signal — see {@link assessProviderId} for the verdict callers
|
||||
* should act on.
|
||||
*/
|
||||
export function detailsMatchProviderTitle(
|
||||
details: {
|
||||
title?: string;
|
||||
original_title?: string;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
},
|
||||
query: { title?: string | null; originalTitle?: string | null }
|
||||
): boolean {
|
||||
const variants = new Set(
|
||||
buildSearchTitleVariants(query.title, query.originalTitle)
|
||||
);
|
||||
if (variants.size === 0) {
|
||||
// Nothing to compare against — never call that a mismatch
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
details.title,
|
||||
details.original_title,
|
||||
details.name,
|
||||
details.original_name,
|
||||
].some((title) => {
|
||||
const normalized = normalizeTitle(title);
|
||||
return normalized !== '' && variants.has(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function resultTitles(result: TmdbSearchResult, mediaType: TmdbMediaType) {
|
||||
return mediaType === 'movie'
|
||||
? [result.title, result.original_title]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { assessProviderId } from './tmdb-matcher';
|
||||
|
||||
describe('assessProviderId', () => {
|
||||
it('corroborates on a matching year even when the title reads wrong', () => {
|
||||
// "IT - Chapter Two" normalizes to "chapter two" because the
|
||||
// ALL-CAPS "IT" looks like a language tag
|
||||
expect(
|
||||
assessProviderId(
|
||||
{
|
||||
title: 'It Chapter Two',
|
||||
release_date: '2019-09-04',
|
||||
},
|
||||
{ title: 'IT - Chapter Two', year: 2019 },
|
||||
'movie'
|
||||
)
|
||||
).toBe('corroborated');
|
||||
});
|
||||
|
||||
it('is inconclusive when the title differs and no year can arbitrate', () => {
|
||||
// Never 'contradicted': normalization artifacts and localized
|
||||
// titles both land here, and the id is usually fine
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ title: 'It Chapter Two' },
|
||||
{ title: 'IT - Chapter Two' },
|
||||
'movie'
|
||||
)
|
||||
).toBe('inconclusive');
|
||||
});
|
||||
|
||||
it('contradicts a same-title id from another year', () => {
|
||||
// Both normalize to "blade runner" — only the year sees the swap
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ title: 'Blade Runner', release_date: '1982-06-25' },
|
||||
{ title: 'Blade Runner 2049', year: 2017 },
|
||||
'movie'
|
||||
)
|
||||
).toBe('contradicted');
|
||||
});
|
||||
|
||||
it('reads the year out of the provider title when there is no field', () => {
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ title: 'The Lion King', release_date: '1994-06-24' },
|
||||
{ title: 'The Lion King 2019' },
|
||||
'movie'
|
||||
)
|
||||
).toBe('contradicted');
|
||||
});
|
||||
|
||||
it('allows a one-year drift between provider and TMDB dates', () => {
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ title: 'Some Film', release_date: '2018-12-28' },
|
||||
{ title: 'Totally Other Name', year: 2019 },
|
||||
'movie'
|
||||
)
|
||||
).toBe('corroborated');
|
||||
});
|
||||
|
||||
it('accepts a series that premiered before the reported season year', () => {
|
||||
// Portals report the current season's year, TMDB the premiere
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ name: 'The Boys', first_air_date: '2019-07-25' },
|
||||
{ title: 'The Boys', year: 2026 },
|
||||
'tv'
|
||||
)
|
||||
).toBe('corroborated');
|
||||
});
|
||||
|
||||
it('contradicts a series that started after the reported year', () => {
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ name: 'Something Else', first_air_date: '2024-01-01' },
|
||||
{ title: 'The Boys', year: 2019 },
|
||||
'tv'
|
||||
)
|
||||
).toBe('contradicted');
|
||||
});
|
||||
|
||||
it('corroborates on the title when neither side has a year', () => {
|
||||
expect(
|
||||
assessProviderId(
|
||||
{ title: 'The Matrix' },
|
||||
{ title: 'The Matrix' },
|
||||
'movie'
|
||||
)
|
||||
).toBe('corroborated');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user