mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
docs(tmdb): record the TMDB capability roadmap (#1238)
* docs(tmdb): record the TMDB capability roadmap Backlog for the TMDB subsystem produced by a multi-agent audit that cross-checked our code against the official API reference and against how Plex/Jellyfin/Emby/Stremio/Kodi present metadata: unused API surface, zero-extra-call wins already sitting in cached payloads, effort-ranked themes, a top-8 shortlist, implementation sketches, and an explicit "deliberately not building" section with reasons. Three entries are defects in shipped code rather than features and are sequenced first: a broken provider tmdb_id suppresses enrichment entirely, series cast is latest-season-only (needs aggregate_credits), and cache retention exceeds the six-month TMDB ToS limit. Cross-linked from tmdb-metadata-enrichment.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(tmdb): correct the A1 sketch to match what shipped The sketch proposed discarding details on a title mismatch and recording a bad-id verdict on any failure. Neither survived review: TMDB returns titles in the request language, so a localized provider title fails the check legitimately, and the badProviderId row is keyed by id alone and shared across playlists. 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
8e1320cb34
commit
14a658f608
2 files changed
+279
No files matched your search
@@ -6,6 +6,8 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# 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:**
|
||||
|
||||
- **The shared release key.** `.github/workflows/build-and-make.yaml:402-406` injects one `TMDB_API_KEY` into every installed copy. Bulk backfill on that key is exactly the traffic shape TMDB throttles ("upper limits to help mitigate needlessly high bulk scraping"). Must be **hard-gated to a user-supplied personal key**, not merely "explicit consent".
|
||||
- **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 **388 lines and is NOT in `tools/eslint/max-lines-baseline.mjs`** — roughly 12 lines of headroom against the hard 400 lint cap.
|
||||
|
||||
**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
|
||||
Reference in new issue
Block a user