From 9b8174f761e8b2adb502fceec8255dca151a989c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Aug 2026 12:06:41 +0200 Subject: [PATCH] docs(portals): describe the Discover gates the code actually implements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rounds of review fixes moved the contracts out from under the prose. The year chip no longer gates on a merge-written numeric tmdb_id (that gate was wrong: the field is number | string, so a provider-sent number passed it with enrichment never having run) but on the navigation target, which requires a playlist and enabled enrichment. Discover loads are guarded by recency, not by facet key, because A→B→A leaves two in-flight requests sharing one key. Availability additionally waits for catalog readiness. Both canonical entries say so now. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 47 ++++++++++++++----- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43390df11..29988d968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1366,7 +1366,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`. Xtream rows carry the same identity on the `content` row: the detail views back-fill `tmdb_id`/`release_year`/`original_title` next to `backdrop_url` (`xtreamDetailContentMetadata` → `XtreamStore.backfillContentMetadata` → `DB_SET_CONTENT_METADATA_IF_MISSING` → `persistContentMetadataIfMissing`), the activity SELECTs project them onto `PortalActivityItem`, and `buildDashboardTmdbAttempts` reads them back. Writes are per-column and never overwrite (enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one); `release_year` is the year the PROVIDER stated, never one read out of the title (readers still apply that fallback themselves, so an absent column means "no provider date" — and "2001: A Space Odyssey" can never be frozen in as a 2001 film), which holds only because the TMDB merge marks the dates it substitutes itself with `tmdb_supplied_release_date` and the extractor skips those — the merge's other `tmdb_*` fields are conditional on having content, so they cannot serve as an "enrichment ran" signal; the id is stored unvetted because every consumer re-gates it through `assessProviderId`; and there is no media-type column, since for Xtream `content.type` already is the media type. Both sides validate through `normalizeContentMetadataPatch` (`libs/shared/interfaces`), so legacy rows, never-opened rows and provider junk all collapse to the title-only fallback — as does the PWA, whose catalog cache is rebuilt from the API on every load - Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`, whose grid is the extracted `TitleResultsComponent` shared with the Discover pages) -- Discover pages (clickable metadata chips, issue #1449): year, genre, and country chips on all four detail views (Xtream VOD/series, shared Stalker detail, Stalker series) are clickable when TMDB matched the item — the merges emit structured `tmdb_genres`/`tmdb_countries` (+ `tmdb_media_type` on Stalker), the year chip gates on a merge-written numeric `tmdb_id`, and clicks navigate via `discoverLink()` (`libs/portal/shared/util`) to the portal-scoped `discover` route (`?type&year&genre&genreLabel&country&countryLabel`). The route containers (`XtreamDiscoverRouteComponent`, `StalkerDiscoverRouteComponent`) clone the actor-page pattern: TMDB `/discover` top-5-pages by popularity via `TmdbDiscoverService` (session-only Map cache, never persisted to `tmdb_metadata`), matched against the catalog (Xtream in-memory index / all-portals `DB_MATCH_TITLES`; Stalker search-prefill), rendered by `DiscoverViewComponent`; facet changes on the same route instance are staleness-guarded by `discoverFacetKey()`. See "Discover Pages" in `docs/architecture/tmdb-metadata-enrichment.md` +- Discover pages (clickable metadata chips, issue #1449): year, genre, and country chips on all four detail views (Xtream VOD/series, shared Stalker detail, Stalker series) are clickable when TMDB matched the item — the merges emit structured `tmdb_genres`/`tmdb_countries` (+ `tmdb_media_type` on Stalker), the year chip renders from provider data so it is gated on the navigation TARGET instead of the item's identity — `createDiscoverFacetNavigation()` offers a facet only when a playlist resolves AND `TmdbEnrichmentService.isEnabled()`, since Discover reads its results from TMDB (gating on `typeof tmdb_id === 'number'` is WRONG: the field is `number | string` and a provider-sent number satisfied it with enrichment never having run) — and clicks navigate via `discoverLink()` (`libs/portal/shared/util`) to the portal-scoped `discover` route (`?type&year&genre&genreLabel&country&countryLabel`). The route containers (`XtreamDiscoverRouteComponent`, `StalkerDiscoverRouteComponent`) clone the actor-page pattern: TMDB `/discover` top-5-pages by popularity via `TmdbDiscoverService` (session-only Map cache, never persisted to `tmdb_metadata`), matched against the catalog (Xtream in-memory index / all-portals `DB_MATCH_TITLES`; Stalker search-prefill), rendered by `DiscoverViewComponent`. Facets change via query params on the same route instance, so the discover load is guarded by recency (`createLatestRequestGuard()`) — A→B→A leaves two in-flight requests sharing one `discoverFacetKey()` — while the catalog match uses the guard for its spinner and the facet key for its results; availability also waits for catalog readiness (in-flight flags, not `isContentInitialized`, so a failed import still settles). See "Discover Pages" in `docs/architecture/tmdb-metadata-enrichment.md` - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` - All `DB_MATCH_TITLES` consumers (Trending rail, "Because you watched" recommendations rail, cross-portal Similar rail, actor "All portals" scope) resolve the worker's flat result list through the shared `groupTitleMatchesByKey()` + `pickTitleMatch()` in `libs/services/src/lib/catalog-title-match.service.ts`. The grouping keeps EVERY row per `type:exactNormalizedTitle` on purpose — the year that separates same-titled rows belongs to the lookup, which the grouping cannot see, so collapsing first made a catalog holding both "Dune 1984" and "Dune 2021" drop whichever copy the user actually owns. `pickTitleMatch` then ranks year-compatible rows by evidence (exact year → untagged → any compatible) across all title aliases at once; only the recommendations rail passes an alias (TMDB `original_title`, via `candidateLookup()`). Multi-source VOD discovery deliberately stays off these helpers: there every copy is a distinct selectable source, not one best answer - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index f653fae8a..9c269763f 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -367,12 +367,21 @@ payloads already carry `genres`/`production_countries` rows produce facets without a refetch. Like person chips, facet chips are clickable ONLY with TMDB backing: genre/country chips render per-entry from the structured arrays (falling back to today's static joined-string -chip without them), and the year chip is clickable only when -`tmdb_id` is a merge-written number — provider payloads ship `tmdb_id` -as untrusted strings, so `typeof === 'number'` is the gate. +chip without them), while the year chip renders from provider data and +so needs a gate of its own. That gate is the navigation TARGET, not the +item's identity: `createDiscoverFacetNavigation()` offers a facet only +when its click can land somewhere, and the four hosts return `null` from +their target unless a playlist resolves AND +`TmdbEnrichmentService.isEnabled()` — Discover reads its results from +TMDB, so a chip must never promise a page enrichment cannot fill. An +earlier version gated on `typeof tmdb_id === 'number'` instead; that is +wrong, because `XtreamVodInfo.tmdb_id` is `number | string` and a +provider sending a JSON number satisfied it with enrichment never having +run. Discover-by-year does not use the item's TMDB id at all. -**Navigation.** Chip clicks navigate (via each render site's own -`openDiscover*` methods, mirroring `openActor`) to the portal-scoped +**Navigation.** Chip clicks navigate (via +`createDiscoverFacetNavigation()`, shared by all four render sites) to +the portal-scoped `discover` route: `/workspace/{xtreams|stalker}/:id/discover?type= movie|tv&year=&genre=&genreLabel=&country=&countryLabel=`. The query-param assembly is centralized in `discoverLink()` @@ -397,14 +406,26 @@ with enrichment disabled. `StalkerDiscoverRouteComponent` clone the actor route containers: same scope toggle, same portal/global matching (Xtream in-memory index by the facet's media type; Stalker search-prefill only, global scope matching -Xtream playlists), same navigation on click. Because facets change via -query params on the SAME route instance, async results are -staleness-guarded by `discoverFacetKey()` rather than by instance — -but the in-flight INDICATOR is owned by `createLatestRequestGuard()` -(`libs/portal/shared/util`), because a request whose subject changed -must not clear a spinner a replacement request now owns, and a request -with no replacement (the user left the scope) must still clear it. The -actor pages use the same guard for the same reason. Both +Xtream playlists), same navigation on click. + +Facets change via query params on the SAME route instance, so staleness +cannot be decided by instance — and, for the discover load, not by facet +either: A→B→A leaves two in-flight requests whose `discoverFacetKey()` +is identical, so an older one failing after the newer succeeded would +replace valid results with an empty page. Recency decides instead, via +`createLatestRequestGuard()` (`libs/portal/shared/util`). The catalog +match keeps both: the guard owns the in-flight INDICATOR (a request whose +subject changed must not clear a spinner its replacement now owns, and a +request with no replacement must still clear it) while the facet key +decides whether the RESULT is still wanted. The actor pages use the same +guard for the same reason. + +Availability also waits for the catalog, not just for TMDB: the content +gate renders the route while a cold import runs, and TMDB usually answers +first, so publishing then would state that owned titles are missing. +Readiness is keyed on the in-flight flags rather than +`isContentInitialized`, so a failed import settles the page instead of +spinning forever. Both render the shared `DiscoverViewComponent`, whose grid is the `TitleResultsComponent` extracted from `ActorViewComponent` (`libs/ui/shared-portals/src/lib/title-results/`) — one grid, filter