* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals Adds an opt-in TMDB integration (Settings > Metadata) that enriches detail views with a field-level merge — the provider stays authoritative for stream data, TMDB fills editorial fields when the match is confident. Enrichment: - Movie/series details: plot, cast (avatar chips), director, genres, rating, poster/backdrop, official YouTube trailers - Confidence-gated matching: provider tmdb_id trusted; otherwise normalized-title search with year gate (±1; series accept earlier premieres), season-suffix stripping, Cyrillic search-language override, and language-prefix fallback variants - Lazy season/episode enrichment: real episode names, overviews, stills - "Similar" rail (Xtream): TMDB recommendations matched to the catalog - Actor pages per portal with full filmography, availability filter and an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES worker op over the trigram FTS index Infrastructure: - SQLite cache table tmdb_metadata (details, search verdicts, seasons, persons; per-language, TTL-guarded), in-memory fallback for the PWA - Settings: enable toggle, own-API-key override with a live "check key" button; TMDB attribution in Settings and About - Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via tools/tmdb/inject-tmdb-key.mjs when the secret is configured - normalizeTitle shared between renderer and DB worker - CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked) Fixes and refactors along the way: - fix(stalker): Advanced Search sent bare get_ordered_list requests and skipped the auth handshake when isFullStalkerPortal was missing on the active-playlist meta — full portals answered "Authorization failed." and search looked empty; now mirrors the catalog request shape and routes through makeAuthenticatedRequest with URL-based detection - fix(stalker): TMDB fields survive info re-normalization; detail views prefer the store copy patched by async enrichment over stale snapshots - refactor(xtream): split oversized vod/serial detail components into component-scoped playback services; detail routes re-initialize on route param changes (router reuses them for detail-to-detail nav) - i18n: all new keys translated across the 18 locales Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): provide route params observable to inline collection details, linearize regexes The global-collection inline detail host builds a fake ActivatedRoute for VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params. Since the detail components now read route.params via toSignal() (detail-> detail re-init), the missing observable crashed component construction and the content hero never rendered — broke dashboard-activation, favorites and recent Electron E2E on all platforms. Provide the params observable alongside the snapshot and assert it in the component spec. Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in normalizeTitle now excludes opening delimiters inside the classes, and youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u escapes (greptile note). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP Review follow-ups on PR #1123: the Xtream VOD detail badge renders rating_imdb, but the merge wrote the TMDB score only into `rating`, so a TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the provider left it empty, mirroring the Stalker merge. All trailer iframes are normalized to youtube-nocookie.com, so the extra youtube.com frame-src allowance was dead surface (greptile) — removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema Fixes the confirmed findings from the PR #1123 code review: - Stalker search: setSelectedContentType now runs BEFORE setSelectedItem, so the TMDB enrichment gate in the selection hook no longer sees the content type of the previously open tab (wrong/no enrichment after ITV -> search -> movie). - Title normalization is now two-tier (normalizeTitleKeys): the exact normalized form keeps a trailing year, the base form strips it and remembers the tag. Year stripping is anchored to the end of the title ("2001: A Space Odyssey" keeps its year) and language-prefix stripping is UPPERCASE-only ("It: Chapter Two" is no longer amputated). - All catalog matching (similar rail, actor pages, DB worker DB_MATCH_TITLES) compares exact forms first and only accepts year-stripped matches when the stripped tag is year-compatible (+-1) with the TMDB year — "Blade Runner" (1982) can no longer claim a catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped trailingYear so the renderer can apply the guard to worker matches. - mergedBackdrops tolerates a plain-string backdrop_path; enrichment merge+patch blocks are wrapped in try/catch so a malformed provider payload can no longer become an unhandled rejection. - loadGlobalMatches (both actor routes) guards against actor->actor navigation races — a slow match for the previous person no longer overwrites the current one's results. - tmdb_metadata media_type CHECK widened to ('movie','tv','person') and person rows now use the honest 'person' type (TmdbCacheMediaType). Pre-release dev DBs with the narrow CHECK are rebuilt in place — the table is a pure cache, so the migration is a self-healing drop-and-recreate keyed off sqlite_master. Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression coverage: title-normalization.util.spec.ts, two-tier cases in tmdb-similar.util.spec.ts and title-match.operations.spec.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 KiB
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:
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_metadatatable) 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) with caller-supplied TTLs |
tmdb-merge.ts |
Field-level merge into XtreamVodInfo / XtreamSerieInfo (pure functions, no mutation) |
tmdb-enrichment.service.ts |
Orchestrator: settings gate → id resolution → details fetch → cache |
Integration glue per portal:
- Xtream:
libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts;XtreamStore.fetchVodDetailsWithMetadata/fetchSerialDetailsWithMetadatafire it aftersetSelectedItem(...). - Stalker:
libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts; hooked insidewithStalkerSelection().setSelectedItemso every detail flow (catalog, search, favorites/recent) is covered. The enriched item is applied via directpatchState— neversetSelectedItem— so the hook cannot recurse. Live/radio selections are skipped. Movies and series share theStalkerVodInfoshape, 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; a "check key" button in the settings
section validates the API key against /configuration.
Match Confidence
Wrong metadata is worse than no metadata, so id resolution is conservative:
- 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-leveltmdb_id, so they always go through search. - 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).buildSearchTitleVariantsproduces 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. - A result is accepted only when its normalized
title/original_title(orname/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'sfirst_air_dateis the premiere. Without a year, the exact-title match must be unambiguous (single hit). - 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; 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. Stalker gets trailers and the
tmdb_recommendations data, but no rail yet (its catalog is
server-paginated, so there is no local list to match against).
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.
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
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. 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).
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_TITLESworker 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 (normalizeTitlenow lives in@iptvnator/shared/interfacesso 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 two row kinds discriminated by lookup_key prefix:
tmdb_metadata (
media_type 'movie' | 'tv' | 'person',
lookup_key 'id:<tmdbId>' -- details payload row
'title:<normalized>|year:<y>' -- search resolution row
'person:<personId>' -- person payload row
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.
Electron IPC path (follows the standard DB worker contract, see SQLite DB Worker):
- Worker ops:
DB_GET_TMDB_METADATA,DB_SET_TMDB_METADATA(database-worker.types.ts,database.worker.ts,operations/tmdb.operations.ts) - IPC registration:
events/database/tmdb.events.ts - Preload bridge:
dbGetTmdbMetadata/dbSetTmdbMetadataonwindow.electron(typed inElectronBridgeApi)
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).
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).