Files
iptvnator/docs/architecture/tmdb-metadata-enrichment.md
T
4gray 83f6a270a5 fix(dashboard): give the hero the identity the detail view matched with (#1362)
The dashboard hero showed no backdrop for items whose detail page had one.
Two independent causes, both about identity rather than the matching gate.

Stalker items never reach the `content` table, so the xtream back-fill of
`content.backdrop_url` has no equivalent for them — but the enriched backdrop
is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The
activity mappers now surface it as `backdrop_url`, where the hero already
looks first.

The hero's TMDB lookup ran on the display title alone, while the detail view
searched with the original title and the release year. Without a year
`pickConfidentMatch` requires a single exact title match, which common titles
never satisfy, and the miss lands in the negative cache under a lookup key the
detail view's hit can never be found at. The query is now built from the same
fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id`
short-circuits the search entirely.

A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every
row falls back to, and an id is valid only for its own media type. A 'tv'
verdict, reached only on positive series evidence, gets no retry back to
'movie'.

Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the
dashboard actually uses.
2026-08-02 20:31:51 +02:00

465 lines
27 KiB
Markdown

# TMDB Metadata Enrichment
This document describes the opt-in TMDB (The Movie Database) metadata
enrichment subsystem introduced in phase 1: settings opt-in, the TMDB
service layer, the SQLite cache, and the Xtream detail-view integration.
Related:
- [TMDB Capability Roadmap](./tmdb-roadmap.md) — follow-up backlog: unused
API surface, known defects, and what we deliberately will not build
- [SQLite DB Worker](./sqlite-db-worker.md)
- [Nx Workspace Boundaries](./nx-workspace-boundaries.md)
## Summary
- Xtream VOD and series detail views can be enriched with TMDB data (plot,
cast, director, genres, rating, artwork) via a **field-level merge** — the
provider stays authoritative for stream-related data and for any field
TMDB cannot fill.
- Enrichment is **opt-in** via `Settings > Metadata (TMDB)` because it sends
movie/series titles to a third-party API. Default: disabled.
- The detail view renders provider data **immediately**; enrichment runs
asynchronously and patches the selected item once TMDB responds. A
staleness guard drops responses that arrive after the user navigated away.
- All TMDB lookups are cached: SQLite (`tmdb_metadata` table) in Electron,
session-scoped in-memory map in the PWA.
- Attribution (TMDB logo + "This product uses the TMDB API but is not
endorsed or certified by TMDB.") is shown in the settings TMDB section and
in Settings > About, as required by TMDB's terms.
## Module Layout
The service layer lives in `libs/services/src/lib/tmdb/` (scope:shared) so
both portal libs and — in later phases — the M3U player can consume it
without creating dependency cycles (`portal/shared/data-access` already
depends on `portal/xtream/data-access`, so it cannot host code the Xtream
store imports):
| File | Responsibility |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping |
| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) |
| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) |
| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) |
| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs |
| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) |
| `tmdb-credits.ts` | People out of credit payloads: display cast, person chips, and the two-shape union a series cast needs |
| `tmdb-cache-payload.ts` | Trims a details payload before caching (aggregate roles/crew) without changing what a merge over it produces |
| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution |
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
Integration glue per portal:
- **Xtream**: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts`;
`XtreamStore.fetchVodDetailsWithMetadata` / `fetchSerialDetailsWithMetadata`
fire it after `setSelectedItem(...)`.
- **Stalker**: `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts`;
hooked inside `withStalkerSelection().setSelectedItem` so every detail flow
(catalog, search, favorites/recent) is covered. The enriched item is
applied via direct `patchState` — never `setSelectedItem` — so the hook
cannot recurse. Live/radio selections are skipped. Movies and series share
the `StalkerVodInfo` shape, so one merge function
(`mergeStalkerInfoWithTmdb`) covers both; Stalker has no TMDB id, so
resolution always goes through the title search. TMDB also supplies a
backdrop (`tmdb_backdrop`) — Stalker portals never provide one.
Components read the selection through signals and re-render when the merged
item lands. Enriched cast (`tmdb_cast` with profile photos) renders as
avatar chips in the detail views, and so do directors/creators
(`tmdb_directors`: movie directors from `credits.crew` with
`job === 'Director'`, series creators from `created_by`) — both chip kinds
carry `tmdbPersonId` and open the same person page. Series detail views
also show a production-status chip (`tmdb_status`): TMDB returns `status`
as an ENGLISH string regardless of the request language, so it is
normalized to a token (`normalizeSeriesStatus`) and rendered through
translated labels (`seriesStatusLabelKey`) — the raw value never reaches
the UI. Unknown values are dropped rather than displayed. A "check key"
button in the settings section validates the API key against
`/configuration`, and a cache panel beside it shows the stored row count
plus payload size and can drop the lot (`DB_GET_TMDB_CACHE_STATS` /
`DB_CLEAR_TMDB_METADATA`, in-memory map in the PWA). Sizing is a full
scan, so it only runs once the TMDB section is the active one. Clearing
costs nothing but the next few requests — enrichment refetches on demand
— and it is also the escape hatch when a lookup-key version bump orphans
rows, since a bump makes them unreachable rather than deleting them.
## Match Confidence
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),
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,
punctuation, trailing release years, and trailing season markers
(`The Boys s05`, `Season 2`, `сезон 3`, `Staffel 2`, `Temporada 2`).
`buildSearchTitleVariants` produces ordered candidates — original
title, display title, then fallbacks with a leading language token
stripped (`DE Batman`, `English The Godfather`; ALL-CAPS short codes
only, so articles like "The"/"De Lift" survive) — tried sequentially
until a confident match.
3. A result is accepted only when its normalized `title`/`original_title`
(or `name`/`original_name`) is **exactly equal** to the normalized query
AND the release year matches within ±1 (year comes from the provider's
release date, falling back to a year tag in the raw title). For series
the year gate additionally accepts shows that premiered **before** the
provider year — portals report the current season's year while TMDB's
`first_air_date` is the premiere. Without a year, the exact-title match
must be unambiguous (single hit).
4. No confident match → the provider data stays untouched, and the negative
verdict is cached (shorter TTL) so browsing back doesn't re-search.
The year filter is applied client-side rather than via TMDB's strict
`year`/`first_air_date_year` search params, which would drop correct results
when the provider's year is off by one.
**Non-Latin titles**: TMDB matches translated titles but returns `title` in
the _request_ language, so a Cyrillic query issued with `en-US` would come
back with an English title and fail the exact-match gate.
`tmdbSearchLanguageForTitle` detects Cyrillic queries and issues the search
with `ru-RU` (unless the app language is already Cyrillic-based); details
are still fetched in the app language afterwards.
## Details Fetch and Localization
Details are fetched with
`/movie/{id}?append_to_response=credits,videos,recommendations` (`/tv/{id}`
for series). Credits provide cast/director — for series the request also
appends `aggregate_credits`, because TMDB documents a TV id's `credits` as
the **latest season's** credits. What `aggregate_credits` covers is
described in one self-contradicting sentence — "it does not return the
newest season. Instead, it is a view of all the entire cast & crew for all
episodes belonging to a TV show" — so it is either the whole run minus the
newest season or the whole run. `unifiedTvCast` is written not to care:
it unions the two by set difference (whole-run billing order first, then
anyone `credits` has that the aggregate lacks), which under the second
reading appends nothing. Either way, long-running shows neither lose
departed regulars nor miss new ones. Payloads cached
before this landed have no `aggregate_credits` and fall back to the old
behaviour until they are refetched; videos supply the best YouTube
trailer (official trailer > trailer > teaser, merged into
`youtube_trailer` / `tmdb_trailer`); recommendations power the "Similar"
rail. In Xtream detail views the rail shows only recommendations that
match the provider catalog by normalized title
(`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`). Matching is
two-tier (`normalizeTitleKeys`): exact normalized titles compare first
(a trailing year in a TMDB title is part of the title — "Blade Runner
2049"); the provider's year-stripped form only counts when its stripped
year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982)
never claims a catalog "Blade Runner 2049". The rail navigates
to the matched item — the detail components re-initialize on route param
changes (reactive `routeParams` signal) because the router reuses the
component for detail→detail navigation.
`CrossPortalSimilarService` (`libs/services`) extends the rail across
portals: recommendations are matched against ALL imported Xtream
playlists with one batched `DB_MATCH_TITLES` request (Electron only,
same two-tier + year rule). Stalker detail views — where the local
catalog is server-paginated and unmatchable — get their "Similar" rail
purely from these cross-portal matches (shared `VodDetailsComponent` for
movies, `stalker-series-view` for series); Xtream detail views append
them after the local-catalog matches, deduplicated by normalized title,
with the source playlist name on each card.
The `language` param derives from the app language setting
(`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per
language, so switching the app language re-fetches localized metadata.
TMDB language-filters both text AND videos — a Russian-only title returns
an empty overview and no trailer for `en-US` (its trailer is tagged
`iso_639_1=ru`). When the app-language payload is missing either, the
enrichment refetches once in the content's `original_language` and fills
only the missing fields (`tmdb-language-fallback.ts`): the details
overview and/or trailer (each independently, so a present app-language
overview is kept while the trailer is filled), and — via the same rule in
`TmdbSeasonService` when a season payload carries no usable text — the
season overview and per-episode names/overviews. Genres, credits and
artwork stay in the app language; both language rows land in the cache.
Trailers embed via `https://www.youtube-nocookie.com/embed/…`. YouTube
requires a Referer on the embed request ("Error 153 — Video player
configuration error" without one); the packaged Electron app loads from
`file://`, which never sends one, so the Electron main process injects the
project site as Referer for YouTube embed hosts
(`request-header-overrides.service.ts`, registered at startup via
`registerStaticHeaderShims`). Dev builds (localhost origin) are unaffected.
## Season/Episode Enrichment
Show-level merges store the matched id as `tmdb_id` on the enriched info
(`XtreamSerieInfo` / `StalkerVodInfo`). When the user opens a season, the
detail views lazily fetch `/tv/{tmdbId}/season/{n}` via
`TmdbEnrichmentService.getSeasonEpisodes` (cached per language under
`id:{tmdbId}|season:{n}`) and overlay it with `mergeEpisodesWithTmdb`:
- generic provider titles ("Episode 4", "Серия 4", "S01E04", bare numbers)
are replaced with real episode names; meaningful provider titles are kept
- overviews and stills are TMDB-preferred; air date and rating only fill
empty provider fields; durations stay provider-owned
- episodes without a TMDB counterpart (by episode number) pass through
untouched
The season number `{n}` is the provider's episode season number, with one
correction (`resolveEnrichmentSeasonNumber` in
`libs/shared/interfaces/src/lib/season-marker.util.ts`): providers often
slice a show into per-season catalog items ("The Mandalorian (2 season)",
"Пацаны 2 сезон", "The Boys S05") and renumber the single contained season
to 1. When the item contains exactly ONE season and the raw title carries
an explicit season marker (`extractSeasonFromTitle`: `s02`, `season 2`,
`2 season`, `2nd season`, `сезон 2`, `2-й сезон`, `staffel`/`temporada`/
`saison` forms, bracketed or not) that differs from the provider's number,
the marker wins and that TMDB season is fetched. Multi-season items always
keep provider numbering. `normalizeTitleKeys` strips the same markers
(including number-first forms like "2 сезон") from search titles, so the
show-level match is unaffected by them.
Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired
from the serial detail's `(seasonSelected)`; Stalker — the series view
keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its
`mappedSeasons` computed. Each Stalker entry records the RESOLVED season
it was fetched for: per-season slices of one show share
(tmdbId, provider key "1") but resolve to different seasons, and a fetch
made with stale detail-to-detail navigation context is overwritten once
the real context re-resolves. The fetch effect gates on coherence rather
than timing: it waits while the season resource reloads and requires the
selected key to exist in the map with episodes. The retained season
selection deliberately survives navigation — the season container
deduplicates `seasonSelected` emissions, so items sharing one season-key
set would otherwise never re-trigger enrichment. Without a show-level match or with enrichment
disabled everything is a no-op — the `SeasonContainer` UI already renders
every episode field conditionally.
## Actor Pages
Cast chips carry the TMDB person id (`tmdbPersonId` on
`TmdbEnrichedCastMember`) and navigate to `actor/:personId` inside the
current portal. The page loads `/person/{id}?append_to_response=
combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached
under `person:{id}` with media_type `person`) and renders the shared
`ActorViewComponent` (`libs/ui/shared-portals`). The filmography merges
acting credits (`combined_credits.cast`) with directing/creating credits
(`combined_credits.crew`, jobs `Director`/`Creator`) into one list —
acting wins the per-title dedup, directing-only titles show the job in
the character slot — so the page serves actors and directors alike.
Filmography has two scopes:
- **This portal** (default): the Xtream route component matches every
credit against the loaded catalog via `buildCatalogTitleIndex`
(movies → vodStreams, tv → serialStreams) — matched titles get an
"In your library" badge and navigate straight to their detail view; the
rest open the portal search prefilled with the title (`?q=`). Stalker
has no local catalog, so every title goes through the portal search.
- **All portals** (Electron only, toggle hidden in the PWA): one batched
`DB_MATCH_TITLES` worker request runs a trigram-FTS lookup per title
over ALL imported Xtream playlists
(`operations/title-match.operations.ts`), confirming candidates with
the same two-tier normalized-title matching the renderer uses
(`normalizeTitle` now lives in `@iptvnator/shared/interfaces` so the
worker and the renderer share it). Matches carry the playlist name
(shown in the badge) and navigate into that playlist's detail view.
This also works from Stalker actor pages — the one place the Stalker
catalog limitation is lifted.
## Cache
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
fetched_at TEXT,
UNIQUE(media_type, lookup_key, language)
)
```
TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and
positive matches 30 days, negative matches 7 days.
Search and details keys carry a `|v2` version suffix (`buildDetailsLookupKey`
in `tmdb-matcher.ts`): for search rows so normalization changes cannot reuse
stale positive or negative resolutions, for details rows because payloads now
include videos via `append_to_response` and pre-videos cache rows had to be
invalidated. Database startup deletes the obsolete
unversioned search rows once and records
`migration:tmdb-search-lookup-v2-cache-cleanup:v1` in `app_state`; details and
person cache rows are unaffected.
Electron IPC path (follows the standard DB worker contract, see
[SQLite DB Worker](./sqlite-db-worker.md)):
- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA`, plus the two
maintenance ops behind the settings cache panel,
`DB_GET_TMDB_CACHE_STATS` and `DB_CLEAR_TMDB_METADATA`
(`database-worker.types.ts`, `database.worker.ts`,
`operations/tmdb.operations.ts`)
- IPC registration: `events/database/tmdb.events.ts`
- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` /
`dbGetTmdbCacheStats` / `dbClearTmdbMetadata` on `window.electron` (typed
in `ElectronBridgeApi`)
`TmdbCacheService` treats the maintenance pair as optional on the bridge: an
Electron shell that predates them reports `null` (unsupported) rather than
falling back to the renderer map, which is always empty in Electron and
would claim the SQLite cache is empty. A `clear()` first awaits the writes
already in flight so they land and are deleted with everything else, and
holds the clear promise for its duration so a write starting meanwhile
queues behind the delete instead of racing it. Rows written after the user
cleared are deliberately kept.
The panel's full path — settings button, preload bridge, DB worker, real
SQLite — is covered by `@settings @electron @persistence sizes and clears
the TMDB metadata cache` in `apps/electron-backend-e2e/src/settings.e2e.ts`.
It seeds a row through `dbSetTmdbMetadata` rather than through enrichment,
which needs an API key that builds outside the release pipeline do not
carry.
The PWA uses a session-scoped in-memory map (acceptable for phase 1; TMDB
supports CORS so the PWA calls the API directly).
## Settings and API Key
`Settings.tmdb?: { enabled: boolean; apiKey?: string }`
(`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has
a "Metadata (TMDB)" section: enable toggle, optional API key override with a
"check key" button (validates against `/configuration`), and a cache panel
showing the stored row count plus payload size with a button that drops the
lot. Sizing is a full table scan, so it runs only once that section is the
active one, and a failed read or clear says so instead of showing an empty
cache.
The embedded default key lives in `DEFAULT_TMDB_API_KEY`
(`libs/services/src/lib/tmdb/tmdb-config.ts`) and is an **empty placeholder
in the repository by design**: the real key is stored in the `TMDB_API_KEY`
GitHub Actions secret and injected at CI build time by
`tools/tmdb/inject-tmdb-key.mjs` (step "Inject TMDB API key" in
`build-and-make.yaml`, before the frontend build). Rationale: TMDB keys are
free and extractable from any client binary regardless, but keeping the key
out of the public repo prevents trivial scraping and fork propagation. Never
commit a real key; never reuse keys found in other repositories.
With no key available (empty default and no user override in settings),
enrichment stays inactive even when the toggle is on — fork PRs and local
dev builds fall into this mode automatically.
## Failure Behavior
Enrichment is strictly best-effort: any API/cache/parse failure logs a
warning and returns `null`, leaving the provider data untouched. Enrichment
never blocks or delays rendering of the detail view.
## Out of Scope (later phases)
Similar/recommendations rails, actor cross-catalog search, trending
dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
(IndexedDB).
## Dashboard Integration
- **Trending rail** ("Trending this week", `dashboardRails.tmdbTrending`
toggle): `DashboardTrendingService`
(`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending
lists (cached one day per language) and runs ONE batched
`CatalogTitleMatchService.matchTitles` request against the imported
Xtream playlists — matched cards navigate straight to their detail view
and show the playlist name; unmatched cards open the global search
prefilled. Requires both the TMDB opt-in and the Electron DB worker
(the rail is hidden in the PWA). The load fires only after the
dashboard's own recent/favorites data is in, so it never competes for
the worker at startup, and runs once per app session.
- **Hero extras**: `DashboardHeroTmdbService`
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
backdrop (when the activity row has none), a rating badge and up to two
genre chips — resolved through the enrichment facade, so items already
opened in a detail view come from the SQLite cache without network.
Results are memoized per lookup identity for the session. The hero renders
immediately from provider data; extras appear when resolved. Series
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.
The query is built to **match what the detail view searched with**, not
just what the card displays. A title alone is weaker identity than the
detail page had: without a year `pickConfidentMatch` requires a single
exact title match, which common titles never satisfy, and the miss lands
in the negative cache under a lookup key the detail view's hit can never
be found at. Stalker rows carry those facts —
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
every row falls back to when nothing says otherwise, and an embedded-VOD
series is a `'movie'` activity row but a show on TMDB. That retry **drops
the id**, which is only ever valid for the media type it was resolved
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
gets no retry — it is reached only on positive evidence (series category,
`is_series`, or a non-empty episode array), and the gate cannot tell an
adaptation sharing its show's name and year from the show itself.
Note the id in a stored Stalker entry is not a provider claim. Stalker
portals never send one; its only source is a match this app already made
and gated.
### Stalker backdrops on activity rows
Xtream detail views back-fill `content.backdrop_url`, which the dashboard
reads directly. Stalker items never reach the `content` table, so their
backdrop travels inside the stored playlist entry instead
(`info.tmdb_backdrop`, written by the same merge that enriched the detail
view) and the activity mappers surface it as `backdrop_url` —
`mapStalkerPlaylistRecentItems` in `playlist-recently-viewed.utils.ts` and
`buildStalkerFavoriteItems` in `dashboard-mappers.ts`. Entries saved before
enrichment (or with TMDB off) simply have none, and the dashboard falls back
to a blurred poster.
### `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.