Files
iptvnator/docs/architecture/tmdb-roadmap.md
T
2dfdd65f53 refactor(details): give the detail-page files real max-lines headroom (#1813)
* refactor(xtream): split the series details page into focused services

serial-details.component.ts sat at the 400-line max-lines limit and its
playback service and spec were close behind. Move cohesive concerns out
without changing behavior:

- route params, the provider-only flag and the (re)load of the addressed
  series go to SerialDetailsRouteService; the component still registers
  the effect, so effect order is unchanged
- season descriptions, posters and the TMDB season enrichment go to
  SerialDetailsSeasonsService
- actor, Similar and Discover navigation go to injectXtreamDetailNavigation,
  shared with the movie page
- the watched toggles and the episode playback payload leave
  SerialDetailsPlaybackService for SerialDetailsWatchToggles and
  buildSerialEpisodePlayback
- the spec's TestBed moves to a harness and the watched-toggle cases to
  serial-details.season-watch.spec.ts

Counted lines: component 400 -> 318, playback service 388 -> 342,
component spec 1196 -> 674.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(xtream): move VOD route selection and position state out of the page

vod-details-route.component.ts grew from 618 to 741 counted lines and
vod-details-playback.service.ts reached 395. Extract without changing
behavior:

- the route-derived read model (selected movie, category, catalog item,
  fallback view, multi-source identity, session and content keys) goes to
  VodDetailsSelectionService; the component keeps the same member names
- trailer state and the Similar-rail click move into the hero presenter,
  the cancel-download prompt and the progress-ring geometry into the
  downloads service, navigation into injectXtreamDetailNavigation
- stored positions (last seen, route row, guarded load) become
  VodDetailsPositionState and the external-launch bookkeeping becomes
  VodExternalLaunchClaim
- drop the unused MatTooltip import (NG8113) and eight unused imports

Counted lines: route component 741 -> 492, playback service 395 -> 344.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(stalker): extract series-view positions and watched toggles

stalker-series-view.component.ts grew from 1601 to 1813 counted lines and
its spec reached 1198 of 1200. Move code out verbatim:

- saved positions, their reconcile and the persist/clear writes go to the
  component-provided StalkerSeriesPositionsService; the ordering of reads
  and writes goes to StalkerSeriesPositionQueue
- episode, season and series watched toggles go to
  StalkerSeriesWatchToggleService, the batch core to
  runStalkerWatchToggleBatch
- the lazy VOD season loads go to StalkerVodSeasonEpisodeLoader

Every effect stays registered in the component constructor in its original
order; template bindings and public member names are unchanged.

The spec setup moves to a shared harness and the spillover-prefetch and
TMDB season cases to their own specs; the 184 cases of the directory are
unchanged.

Counted lines: component 1813 -> 1136, component spec 1198 -> 670.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(stalker): extract catalog-detail position and search paging

- stalker-catalog-detail.component.ts (397): the stored VOD position and
  its runtime updates become StalkerCatalogVodPosition, the Play/Resume
  start becomes startStalkerCatalogVodPlayback
- stalker-search.component.ts (776, baselined): the paging resource, the
  accumulated results and the parental-lock bookkeeping become
  StalkerSearchPagingController, with pure helpers in
  stalker-search-results.util.ts. The spec reaches the moved members
  through component.paging; its cases and assertions are unchanged.

Counted lines: catalog detail 397 -> 317, search 776 -> 482.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ui): give season-container and vod-details real line headroom

- season-container.component.ts (396): season auto-selection and the
  seasonSelected emission move to createSeasonAutoSelectState, called at
  the same place in the constructor so effect order is unchanged; the
  episode subline becomes buildEpisodeSubline
- vod-details.component.ts (393): the cross-portal Similar loader, the
  provider-only download state and the actor/Similar route helpers move to
  sibling modules

Inputs, outputs, selectors and public members are unchanged.

Counted lines: season container 396 -> 341, vod details 393 -> 337.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web-e2e): move Stalker portal helpers into fixtures

stalker.e2e.ts was at 1196 of 1200 counted lines. Move the mock endpoints,
scenario MACs and page helpers to stalker-portal.fixture.ts and the
embedded-series steps three tests repeated to
stalker-embedded-series.fixture.ts. Every test stays in stalker.e2e.ts in
the same order, with the same serial mode and OWNED_MACS reset.

Counted lines: 1196 -> 981.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: follow the detail-page extractions

Name the Stalker watched-toggle and position services that now own the
batch and reconcile code, point AUTH_REJECTED_MAC and the scenario MACs at
stalker-portal.fixture.ts, and drop two stale statements about components
sitting at the max-lines cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(xtream): note why the seasons service is injected last

Its TMDB enrichment effect keeps the position it had as the first effect
of the component constructor only while it is created after the other
services' effects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 16:52:53 +02:00

278 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TMDB Capability Roadmap
Follow-up backlog for the TMDB metadata subsystem: what the API still
offers that we do not use, what it would cost, and what we deliberately
will not build. Companion to
[TMDB Metadata Enrichment](./tmdb-metadata-enrichment.md), which
documents what already ships.
Produced by a multi-agent audit (2026-07-24) that cross-checked our code
against the official TMDB API reference and against how Plex, Jellyfin,
Emby, Stremio and Kodi present metadata. Line numbers reference the state
of the repo at that date and will drift — treat them as pointers, not
addresses.
> Three items in here are **defects in shipped code**, not features:
> A1 (broken provider `tmdb_id` suppresses enrichment), A2 (series cast is
> latest-season-only) and F1 (cache retention exceeds the TMDB ToS limit).
> They come first.
## 0. The one decision that gates everything
`buildDetailsLookupKey` (`libs/services/src/lib/tmdb/tmdb-matcher.ts:79-83`) is `id:{tmdbId}|v2`. Six separate ideas below each "need a v3 bump", and a bump invalidates **every cached details row for every user**. Make this decision **once**:
**One consolidated append PR** — add `images,release_dates,content_ratings,alternative_titles,aggregate_credits` to the two detail calls (`tmdb-api.service.ts:62,74`), trim `images` down to the chosen logo + top posters _before_ `TmdbCacheService.set`, and bump the key to `v3` — scoped by `mediaType` so movie rows aren't invalidated by the TV-only `aggregate_credits` addition. Everything downstream then ships as pure presentation PRs with no further refetch events.
Ship the **cache retention sweep first** (§4, #8) so the bump's orphaned rows have a reaper.
---
## 1. Zero-extra-call wins — data already on disk today
These read fields already sitting in `tmdb_metadata.payload` (stored verbatim per `tmdb.types.ts:1-5`). **No new HTTP, no cache-key bump, no schema change.** This is the cheapest tier in the entire roadmap.
| Feature | Field already cached | Cost |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| **Extras shelf** (Featurette / BTS / Bloopers / Clips) | `videos.results[]` — `pickTrailerKey` (`tmdb-merge.ts:110-119`) keeps 1 of ~15 | L/M |
| **Series structure** (status chip, season posters, episode counts) | `/tv/{id}` base: `seasons[]`, `status`, `in_production`, `next_episode_to_air`, `number_of_seasons` — **none typed** in `TmdbTvDetails` (`tmdb.types.ts:99-107`) | L/M |
| **Franchise strip** | `belongs_to_collection` on every enriched movie — untyped, discarded | (needs 1 call for `parts[]`) |
| **Tagline** on detail hero | `tagline` on both detail responses | S |
| **Full cast beyond 10 / whole crew** | `credits.cast[]` (30-100 entries, `MAX_CAST_NAMES=10`), `credits.crew[]` (writers, composers, DoP — filtered to `job==='Director'`) | M |
| **Recommendations 13-20 + their overviews/backdrops/ratings** | `recommendations.results[]` (20 cached, 12 kept, 4 of 15 fields kept) | S |
| **`deathday` on actor pages** | Already parsed into `ActorProfile` (`tmdb-person.ts:51`), never rendered | **XS — one template line** |
| **Episode runtimes, "Season Finale" badges** | Season payload `runtime`, `episode_type`, `vote_count` (typed, unused) | S |
| **"Because you watched X"** | `recommendations` on any opened title | M |
⚠️ **Caveat on all of them:** the `merge*WithTmdb` functions write a _fixed projection_. Reading a new field means extending the merge (three interfaces + `NormalizedVodMeta` + both normalizers in `vod-details-adapters.ts`) **or** a lazy cache read via `TmdbCacheService.get(mediaType, buildDetailsLookupKey(id), lang)` — the pattern `TmdbSeasonService.originalShowLanguage` already uses (`tmdb-season.service.ts:77-92`).
⚠️ **Second caveat, non-English locales:** TMDB language-filters `videos`, so the extras shelf is frequently empty for ru/ar/ja users. Reuse the existing original-language fallback payload (`tmdb-language-fallback.ts`) rather than adding `include_video_language` — that param would force the shelf into the v3 bump.
---
## 2. Themes, ordered by value-to-effort within each
### Theme A — Match accuracy (HIGH LEVERAGE: fixes the existing integration)
These add **no surface area**. They make what already ships correct. High leverage: every one of them multiplies the value of every other TMDB feature, because nothing renders when the match fails.
| # | Idea | Effort | Note |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A1 | **Distrust broken provider `tmdb_id`** — a garbage-but-integer id short-circuits title search; `getDetails` 404s, outer catch swallows, **zero enrichment on an item title search would have handled**. Worse: a _stale-but-valid_ id returns a confidently-wrong payload with **no title sanity check at all**. | **S/M** | Confirmed bug. Xtream movies only (`xtream-tmdb-enrichment.ts:56` is the sole path passing `tmdbId`), but that's the highest-traffic detail view. |
| A2 | **`aggregate_credits` for TV** — `/tv/{id}/credits` is documented as "the latest season credits". **Our series cast chips are quietly wrong for long-running shows.** Closer to a bug fix than a feature. | **M** | Append `credits,aggregate_credits` together and union — TMDB's own docs say aggregate "does not return the newest season". |
| A3 | **`alternative_titles` synonym index** — write one cache row per normalized alternate title pointing at the resolved `tmdb_id`, using the existing search-lookup-key shape so lookups stay point-shaped. | M | Chicken-and-egg: only helps titles we already matched. Real payoff arrives after a warm pass. |
| A4 | **`/search/multi` for ambiguous media type only** (Stalker `is_series`, Ministra VOD) | M | ⚠️ Do **not** frame as "halves search calls" — we search one type, not two. It's a _typing_ fix. And multi's single mixed 20-result page can _lower_ recall as a primary path. |
| A5 | **Network resilience** — typed `TmdbApiError`, in-flight coalescing, 429/5xx backoff, a visible bad-key signal | M | 401 currently indistinguishable from "TMDB doesn't have this title". |
**Dropped from this theme:** `/find/{external_id}`. Verified — **no provider interface in this repo carries an IMDb or TVDB id**, only `rating_imdb` (a score). M3U `tvg-id` is an XMLTV _channel_ id, and M3U has no TMDB enrichment at all. It has no inbound caller.
**Before shipping A3/A4: add match/miss instrumentation.** There is no measurement today, so "fixes the 33% miss" is currently unfalsifiable.
### Theme B — Artwork & cinematic polish
| # | Idea | Effort | Note |
| --- | -------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B1 | **Title logos (clearlogo) on detail + dashboard heroes** | M/H | Plex, Emby, Kodi and Jellyfin all independently converged on the same `clearlogo` asset. Biggest perceived-quality delta available. **Zero new i18n keys.** |
| B2 | **Localized poster / textless backdrop selection** | M | Same `images` append. Pure merge-output change, both environments. |
| B3 | **Extras shelf** | L/M | See §1. |
| B4 | **Editorial meta rail** (tagline + certification plate) | M | Tagline is free today; certification joins the v3 append. |
| B5 | Poster-derived ambient palette | M/H | `image.tmdb.org` returns `access-control-allow-origin: *`, so the canvas is untainted. But it must **not** animate over the Embedded MPV transparency hole, and needs an owning service with teardown-on-navigation. Defer. |
⚠️ B1 gotcha: `include_image_language` takes **ISO 639-1** codes, but `TmdbRuntimeService.language()` returns region-suffixed (`ru-RU`). A `.split('-')[0]` helper doesn't exist yet. And the param is **mandatory** — without it, appended images inherit `language=ru-RU` and return near-empty logos for all 17 non-English locales.
⚠️ B1 honesty: logo coverage is thin for the regional/older fare that dominates IPTV catalogs. A fixed-height title box changes layout for 100% of titles to serve maybe half. That's a deliberate design call, not a free win.
### Theme C — Series / anime handling
| # | Idea | Effort |
| --- | ---------------------------------------------------------------------------- | -------- |
| C1 | **Series structure**: status chip, season posters, per-season episode counts | L/M |
| C2 | **Next-episode line**, gated on `TmdbCacheService.isFresh(entry, ~2 days)` | S |
| C3 | Season completeness ("portal has 6 of 8 seasons") | S, gated |
⚠️ C1: TMDB returns `status` as an **English string** (`"Ended"`, `"Returning Series"`) even under `language=ru-RU`. Map to i18n keys — never render raw into 18 locales.
⚠️ C2: the details TTL is 30 days, so `next_episode_to_air` will routinely be a date **weeks in the past**. Suppress when stale; never refetch.
⚠️ C3: compare season **numbers** present against `seasons[]` filtered to `season_number > 0`, not counts. A provider dumping every episode under season 1 would otherwise produce a false "missing 7 seasons" claim. Suppress entirely on any misalignment, and phrase strictly as an observation about the portal.
**Anime absolute-vs-aired ordering (`episode_groups`): deprioritized.** IPTV providers name episodes arbitrarily; there's no stable key to map onto a TMDB group order. High risk of playing the wrong episode. If ever built: display-only toggle, never mutating stored identity or playback positions (the Sonarr #6547 lesson — upstream numbering changes physically reshuffled users' libraries).
### Theme D — Browse & discovery
| # | Idea | Effort |
| --- | ---------------------------------------------------- | --------------- |
| D1 | **"Because you watched X" rail** | M |
| D2 | **Franchise strip** (owned/unowned) | M |
| D3 | Genre / decade / rating facets over your own catalog | **H — blocked** |
| D4 | Mood shelves from keywords (pill row only) | M |
⚠️ **The recurring architectural trap:** every catalog-first rail surfaces content the user cannot play. The Trending rail already solves this with batched `DB_MATCH_TITLES`. **Hide the rail on zero matches** — an empty shelf is worse than no shelf.
⚠️ D3 is hard-blocked. `content` (`schema.ts:92-127`) has no `year`, `genre`, `runtime` or `tmdb_id`, and there is **no joinable key to `tmdb_metadata`**. Filtering isn't even SQL — `filteredAndSortedContent()` (`with-selection.feature.ts:339-444`) is in-memory over the loaded array. This needs the metadata index below.
⚠️ D4's dashboard-shelf half is not currently possible: `TmdbCacheService` is **point-lookup only**, there is no scan op over `tmdb_metadata`, and cache rows carry no back-reference to a content row. Ship the pill row alone. Also: TMDB keywords are an **English-only controlled vocabulary** — they'll render as English lowercase strings next to fully localized genres in 18 locales.
### Theme E — Library index (the prerequisite everything blocked is blocked on)
Canonical titles in grids, rating/year badges on cards, genre/decade browse, family mode, cross-surface artwork overrides — **all five reduce to the same missing primitive.** They cannot ship without it and should not be scheduled separately.
**The right shape** (correcting the obvious approach): a **side table `content_tmdb` keyed on `(playlist_id, content_type, xtream_id)`** — _not_ columns on `content`. `clearXtreamImportCache` (`content.operations.ts:1063-1100`) deletes content rows, and `content.id` is an autoincrement surrogate that isn't stable across re-import; denormalized columns would be wiped on every catalog refresh.
**Blockers, all hard:**
- **User-supplied key and throttling.** Distributed builds ship without a shared key; the workflow retains optional injection support (see [Settings and API Key](./tmdb-metadata-enrichment.md#settings-and-api-key)). Bulk backfill must require a user-supplied key even in a build with an injected default. A personal key does not exempt bulk traffic from [TMDB rate limits](https://developer.themoviedb.org/docs/rate-limiting); explicit consent alone is insufficient.
- **No 429/backoff machinery exists** (Theme A5 is a prerequisite).
- **Per-item IPC.** 40k warm writes = 40k+ worker round-trips. Batched cache ops are a prerequisite, not an optimization.
- **6-month retention** must ship _with_ it, not after — bulk warming is precisely a mechanism for maximizing held TMDB data.
- **Coverage:** Xtream + Electron only. Stalker, M3U and every PWA user get zero. Cannot be sold as "my library".
Realistic: **H, multi-PR.** Worth doing eventually; not worth doing next.
### Theme F — Correctness & compliance hygiene
| # | Idea | Effort |
| --- | ------------------------------------------ | ------ |
| F1 | **6-month cache TTL sweep + purge button** | L+ |
| F2 | **"Wrong movie?" manual match override** | M |
F1 is a **live compliance gap in shipped code**, not a feature. `tmdb.operations.ts` has only `get`/`set`; `setTmdbMetadata` uses `onConflictDoUpdate`, so untouched rows persist forever. The ToS caps retention at 6 months. `fetched_at` already exists (`schema.ts:376`).
### Theme G — Cross-device / accounts
**Deliberately empty.** See §5.
---
## 3. Top-8 ranked shortlist
| # | What it is | Endpoint | Why it matters for IPTV specifically | Effort | Type |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------- |
| **1** | **Distrust broken provider `tmdb_id`** — try details, fall back to title search on 404 _or_ title mismatch; negative-cache the bad id | none (uses existing `/movie\|tv/{id}` + `/search/*`) | IPTV providers ship _garbage_ `tmdb_id` values. Every bad id is a detail page with **no plot, no cast, no poster** — for an item title search would have matched. Zero i18n. | **S/M** | **Quick win** ⚡ **high leverage** |
| **2** | **6-month TTL sweep + purge button** | none | Live ToS violation in shipped code. Also the escape hatch for the v3 bump's orphaned rows. | **L+** | Quick win (risk removal) |
| **3** | **Extras shelf** (Featurette/BTS/Bloopers) | `videos` — **already appended** | Plex _pays a licensing fee_ for extras; Emby/Infuse need local files. IPTVnator can't have either — YouTube-hosted extras are the only possible implementation, and the nocookie embed + Electron Referer shim already ship. | **L/M** | **Quick win** ⚡ |
| **4** | **`aggregate_credits` for TV** | `append_to_response=credits,aggregate_credits` on `/tv/{id}` | `/tv/{id}/credits` returns **only the latest season**. Our series cast chips are wrong today for every long-running show. Bug fix wearing a feature costume. | **M** | **Quick win** ⚡ **high leverage** |
| **5** | **Series structure** (status chip, season posters, episode counts) | `/tv/{id}` base fields — **already cached** | IPTV series come as an undifferentiated pile of season folders with no art and no signal. Season posters + "Ended / Returning" + episode counts turn that into something navigable. Zero calls, zero bump. | **L/M** | **Quick win** ⚡ |
| **6** | **Title logos on heroes** | `append_to_response=images` + `include_image_language` | The single largest perceived-quality upgrade available, and the pattern all five incumbents converged on independently. Zero new i18n keys across 18 locales. | **M/H** | **Strategic bet** |
| **7** | **"Because you watched X" rail** | `recommendations` — **already appended & cached** | >80% of Netflix viewing comes from rails, not search. IPTVnator's dashboard is _all history rails_ — a user with three playlists and no watch history gets no discovery at all. This is the first rail that answers "what should I watch". | **M** | **Strategic bet** |
| **8** | **"Wrong movie?" manual match override** | `/search/multi` (one call per dialog open, user-typed) | The honest complement to #1: automated matching will still fail on polluted catalogs, and today there is **no recovery path** — the user sees the wrong film's plot forever. Precedent exists: `epg-mapping-dialog` binds a dirty provider entity to a canonical one. | **M** | **Strategic bet** |
**Just outside:** franchise strip (M, but `belongs_to_collection` is null for most catalog titles — rare payoff); network resilience A5 (M, prerequisite for anything bulk); `alternative_titles` synonym index (M, but low standalone gain until a warm pass exists).
---
## 4. Deliberately NOT building
| Idea | Why not |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Family mode / certification gate + PIN** | Enrichment is lazy and per-open, so a gate **fails open on every unmatched title** (passes everything) or **fails closed** (empty app on day one). Structurally impossible for PWA (no catalog DB), Stalker (server-paginated) and M3U (no enrichment at all) — and TMDB covers **no live channels**, which is where IPTV family risk actually concentrates. An age gate that silently passes unmatched titles is worse than none, because parents will trust it. **Ship instead:** adult-category keyword blocklist + Stalker's existing `censored` flag + PIN, with TMDB certification as a per-title **badge only**. Never describe the badge as a safety feature, in UI copy or release notes. |
| **`/find/{external_id}`** | Verified: no provider interface in this repo exposes an IMDb or TVDB **id** — only `rating_imdb` (a score). M3U `tvg-id` is an XMLTV _channel_ id, and M3U has no TMDB path. The only `imdb_id` obtainable comes from TMDB itself, which is circular. No inbound caller exists. |
| **`watch/providers`** | Tells the user a title is on Netflix. In a player whose premise is the user's _own_ streams, this reads as an ad for a competitor and drags in a mandatory JustWatch attribution obligation on top of ours. Direct conflict with the product stance. |
| **TMDB / Trakt account sync (v3 session or v4 OAuth)** | Requires every user to hold a TMDB account and complete a browser OAuth flow, to sync data we already store _better_ — our favorites are playlist-scoped and playlist-aware; TMDB's are not. High friction, new credential surface, low payoff. Guest sessions expire in 60 min with no reattachment — a dead end. |
| **Quality/rating badges on grid cards** | No join key from `content` to `tmdb_metadata`, no batched cache-read IPC op, and near-zero coverage without a bulk warm pass. Blocked behind Theme E. _Salvage now:_ the subtitle-consistency fix + a **provider**-rating badge on `app-content-card`. |
| **Canonical titles in grids** | Same missing join key — plus a sharper unnamed regression: search runs trigram FTS over `content_title_fts`, which holds **provider** titles. Show "The Matrix", the user types "The Matrix", the indexed `"EN - Matrix, The 1080p 2019"` doesn't match. Cleaning display titles without indexing canonical titles **actively makes search worse**. _Ship the cheap subset:_ canonical title on the **detail hero only**, provider string demoted into the About block. |
| **Empty-state artwork mosaic** | Wrong reuse target (`portal-empty-state` is live-TV-only), and it cannot render in the first-run case it's designed for — TMDB is default-off and the library is empty. _Salvage:_ the `/search/multi` teaching message on a genuine zero-result search ("_Dune: Part Two_ exists, but it isn't in this playlist") — points at the user's own playlist, never outward. |
| **User-facing smart-collection rule builder** | Kometa's 3.4k stars represent real but self-selecting power-user demand. Large UI surface, and IPTV catalogs churn — saved rules rot silently between refreshes. Ship a handful of curated dynamic rails instead: ~90% of the value, ~10% of the surface. |
| **Audio/subtitle language badges from TMDB** | TMDB has **no subtitle data at all**; `spoken_languages` describes the _work_, not the _stream_. A wrong badge is worse than no badge. Correct sources are Xtream `get_vod_info` codec fields and HLS `#EXT-X-MEDIA` at playback time. Explicitly out of scope for TMDB work. |
| **Anime absolute-vs-aired episode ordering** | Provider episode labels are arbitrary — no stable key to map onto a TMDB group order. High risk of playing the wrong episode. `episode_groups` `type` integers aren't even in the official spec. |
| **Poster-derived ambient palette (for now)** | Technically viable (CORS verified), but it's a global mutable style channel needing an owning service with navigation teardown, dual-theme WCAG clamping, and — critically — it must not animate over the Embedded MPV transparency hole, the exact class of change that broke native-view before. Not worth it yet. |
---
## 5. Implementation sketches — top 3
### #1 — Distrust broken provider `tmdb_id`
**The bug**, in `libs/services/src/lib/tmdb/tmdb-enrichment.service.ts` `enrich()`:
```ts
const tmdbId = parseProviderTmdbId(query.tmdbId) ?? (await this.resolveIdBySearch(...));
if (tmdbId === null) return null;
return await this.getDetails(mediaType, tmdbId);
```
A garbage-but-integer provider id short-circuits the search entirely. `getDetails` → `fetchDetails` → `api.request()` throws on the 404, the outer catch logs and returns `null`. **Zero enrichment, permanently, for an item title search would have resolved.** The stale-but-_valid_ id case is worse — it never throws, and there is no title sanity check, so it silently renders another film's plot and cast.
**Files:**
- New `libs/services/src/lib/tmdb/tmdb-id-resolver.ts` — `resolveTmdbId(mediaType, query, {api, cache, runtime}) → number | null`. Extraction is needed regardless: `tmdb-enrichment.service.ts` is **290 lines, at the ceiling**, and idea #8 needs the same extraction.
- `tmdb-enrichment.service.ts` — inject the resolver; `enrich()` becomes try-details-then-fall-back-to-search.
- `tmdb-matcher.ts` (166 lines, room) — reuse `normalizeTitle` + `buildSearchTitleVariants` for the sanity check; add a `badProviderId:{id}` negative-cache key builder.
**Logic:** if `parseProviderTmdbId` yields an id, attempt `getDetails`. On throw **or** on a resolved title whose `normalizeTitle()` matches none of `buildSearchTitleVariants(query.title, query.originalTitle)` → fall through to `resolveIdBySearch`.
> **Correction, as shipped (#1239).** Two refinements to the sketch above.
> A title mismatch must **not** discard the payload: TMDB returns titles in
> the *request* language, so a localized provider title legitimately fails
> the check. It only lets the search compete, and the provider payload is
> kept when the search finds nothing confident. And the `badProviderId:`
> row is written **only for a confirmed 404** — never for transient
> failures (401/429/5xx/offline) and never for mismatches, since that row
> is keyed by id alone and shared across playlists, so a per-item verdict
> there would disable the id for every other item that uses it correctly. **The negative cache is not optional** — a failed details fetch currently caches nothing, so every re-open retries the 404 forever; without it you trade one wasted request for two, permanently.
**Regression spec:** (a) a provider `tmdb_id` that 404s still produces enrichment via title search; (b) a provider `tmdb_id` resolving to a mismatched title is rejected; (c) the happy path issues exactly one details request and **no** search.
Zero i18n keys. Zero UI change.
---
### #2 — Cache retention sweep + purge button
**Files (the five DB-worker sync points, plus two fixtures):**
- `apps/electron-backend/src/app/database/operations/tmdb.operations.ts` — add `purgeTmdbMetadataOlderThan(db, cutoff)` and `getTmdbMetadataStats(db)` beside the existing `get`/`set`.
- `apps/electron-backend/src/app/workers/database-worker.types.ts:52-54`
- `apps/electron-backend/src/app/workers/database.worker.ts:596-620`
- `apps/electron-backend/src/app/events/database/tmdb.events.ts`
- `apps/electron-backend/src/app/api/main.preload.ts:784-792`
- `libs/shared/interfaces/src/lib/electron-api.interface.ts:816-823`
- Fixtures: `main.preload.spec-data.ts:364`, `worker-ipc-contract.spec-data.ts`
- UI: `apps/web/src/app/settings/settings-tmdb-section.component.{ts,html}` (87/113 lines, headroom)
**Two defects to design around:**
1. `setTmdbMetadata` writes `fetched_at` as an **ISO-8601 string**, but the column DEFAULT is `datetime('now')` (space-separated, no `Z`). A naive `WHERE fetched_at < ?` string compare is **wrong** — `'T'` sorts after `' '`. Use `julianday(fetched_at)`.
2. Do **not** reuse the `appState` one-off migration-guard pattern. A retention sweep is _recurring_; the key must store a **last-swept timestamp**, not a boolean, or it runs exactly once in the app's lifetime.
**Sweep:** fire from the DB worker **after first paint** (never on the startup critical path), ≥24h cadence, `DELETE WHERE julianday('now') - julianday(fetched_at) > 180`.
**Panel:** "Metadata cache: N entries, X MB" + Clear button. Compute size **only on section expand** — `SUM(...)` is a full scan. Use `LENGTH(CAST(payload AS BLOB))`; SQLite `LENGTH()` on TEXT counts _characters_, not bytes.
**Regression spec:** rows at 179 and 181 days survive / are deleted; both `fetched_at` formats handled.
**Doc note** in `docs/architecture/tmdb-metadata-enrichment.md`: the Clear button is also the escape hatch for the v3 key bump — a bump _orphans_ rows rather than deleting them, so without this they sit until the 180-day sweep reaches them.
**Out-of-scope note worth recording:** `downloads.poster_url` (`schema.ts:334`) can hold an `image.tmdb.org` URL when a download starts from an enriched detail page. The purge doesn't cover it.
---
### #3 — Extras shelf
**Prerequisite split.** `libs/services/src/lib/tmdb/tmdb-merge.ts` is **302 lines** — over the 300 target. Extract into `libs/services/src/lib/tmdb/tmdb-videos.ts`:
- `pickTrailerKey` — **unchanged**, so nothing downstream breaks
- `groupVideosByType(details, {limit: 6})` → `{key, type, name, language}[]`, filtered to `site === 'YouTube'`, deduped by key, **official-preferred rather than official-only** (plenty of genuine studio featurettes carry `official: false`; a hard filter empties the shelf on most catalog titles), excluding the key already used as the trailer
**Data path** (the merge writes a _fixed projection_ — this is not "read the payload in the component"):
- `tmdb_videos?: TmdbVideoRef[]` on `XtreamVodInfo`, `XtreamSerieInfo`, `StalkerVodInfo`
- populated in all three of `mergeVodInfoWithTmdb` (`:168`), `mergeSerieInfoWithTmdb` (`:215`), `mergeStalkerInfoWithTmdb` (`:257`)
- through `NormalizedVodMeta` (`libs/shared/interfaces/src/lib/vod-details-item.interface.ts`) + **both** normalizers in `vod-details-adapters.ts` — the single convergence point where Xtream and Stalker meet
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **NOT in `tools/eslint/max-lines-baseline.mjs`** and stays under the hard 400 lint cap only because its state lives in sibling helpers (about 340 counted lines today).
**Render** into the `detail-extras` projection slot at **four** sites (`vod-details.component.html:225`, `serial-details.component.html:201`, `vod-details-route.component.html:247`, `stalker-series-view.component.html:202`) — note Xtream `serial-details` has no trailer block today, so it either gains one or the shelf lands inconsistently. Reuse the existing nocookie iframe + `| safe` pipe so the Electron Referer shim keeps working; clicking swaps the embed `src` rather than opening a new player. Cards use `https://img.youtube.com/vi/{key}/hqdefault.jpg` (CSP verified: `img-src` covers it, `frame-src https://www.youtube-nocookie.com` covers the embed). `@if (extras().length > 1)` … `@else` the existing single-trailer markup **verbatim**.
**Labels** come from translation keys on the TMDB type enum, **never** from `video.name` (5-6 keys × 18 locales via the `i18n-fill` skill).
**Coverage honesty:** ship the en-correct version first. For non-English locales the shelf is empty on most titles until either the original-language fallback payload is reused or `include_video_language` lands — and the latter forces the v3 bump, so it belongs in the consolidated append PR, not here.
---
## 6. Suggested sequence
1. **A1** (broken `tmdb_id`) + **F1** (retention sweep) — correctness and compliance, no new surface, no cache bump
2. **Extras shelf** + **series structure** — pure zero-call presentation wins off already-cached payloads
3. **Consolidated v3 append PR** — `images` + `release_dates`/`content_ratings` + `alternative_titles` + `aggregate_credits`, with payload trimming, mediaType-scoped key
4. **Title logos**, **certification plate**, **aggregate-credits cast fix** — all ride the bump landed in step 3
5. **"Because you watched X"** + **"Wrong movie?" override** — the two strategic bets
6. **Theme E** (library index) — only after A5 (backoff), batched cache ops, and a personal-key gate exist