Files
iptvnator/libs/shared/interfaces/src/lib/content-metadata.interface.ts
T
4gray 61fca6f016 fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)
An Xtream activity row is built from its `content` row, and the catalog
endpoints that create those rows carry only a title and a poster. So the
dashboard hero and the recommendations rail rebuilt their TMDB query from the
display title alone, while the detail view had searched with the original
title, the release date and often a TMDB id. Without a year
`pickConfidentMatch` requires a globally unique exact title, which common
titles never satisfy — "Inside Out" matches several films and resolves to
nothing, every time.

Three `content` columns close that gap next to the existing `backdrop_url`:
`tmdb_id`, `release_year`, `original_title`. The detail views back-fill them
from what is on screen, the activity SELECTs project them, and
`buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same
facts through its stored entry, and rows with neither keep the title-only
fallback.

Measured against a real profile before building: of 58 distinct Xtream
movie/series activity rows, 16 (28%) produce a year-less key — the cohort
where a miss is guaranteed rather than likely.

Contracts worth preserving:

- Per-column, never overwrite. Enrichment supplies the pieces at different
  times, so a row-level guard would let the first arrival block every later
  one forever.
- `release_year` is the year the PROVIDER stated. The TMDB merge fills the
  date field when the provider left it empty, so it marks its own
  substitution with `tmdb_supplied_release_date` and the extractor skips
  those — making contamination structurally impossible rather than avoided.
- The id is stored unvetted: every consumer re-gates it through
  `assessProviderId`, which re-decides per lookup where a write-time verdict
  would be permanent.
- No media-type column — for Xtream the catalog files movies and series
  apart, so `content.type` already is the media type.

Worker requests now await `getDatabase()` before dispatching. The renderer
loads before `initDatabase()` and the worker opens the database file without
running migrations, so a query issued during startup on an upgraded install
could otherwise hit a schema whose new columns do not exist yet.

Not covered: the PWA, whose catalog cache is rebuilt from the API on every
load, so a stored id would never outlive the detail view that resolved it.
2026-08-13 18:51:26 +02:00

101 lines
4.3 KiB
TypeScript

/**
* Presentation and identity facts an Xtream detail view learns about a
* catalog item and hands back to the `content` row it came from.
*
* The catalog endpoints (`get_vod_streams`, `get_series`) carry a title and a
* poster and nothing else, so a `content` row starts life knowing only what a
* grid needs. The detail endpoints (`get_vod_info`, `get_series_info`) carry
* the release date, the original title and often a TMDB id — and TMDB
* enrichment resolves an id even when the provider ships none. Recording
* those on the row is what lets anything reading an activity item later
* (the dashboard hero and recommendation rails above all) repeat the lookup
* the detail view already did, instead of starting over from the display
* title. See `docs/architecture/tmdb-metadata-enrichment.md`.
*
* There is deliberately no media-type field: for Xtream the catalog files
* movies and series apart, so `content.type` already IS the media type.
* Stalker needs one because its embedded-VOD series are stored as movies,
* but Stalker items never reach the `content` table — they carry their own
* hints inside the stored entry (`extractStalkerItemTmdbHints`).
*/
export interface ContentMetadataPatch {
/** Wide 16:9 artwork for the dashboard hero */
backdropUrl?: string;
/**
* The id the detail view had on screen: a provider `tmdb_id` before
* enrichment runs, the id enrichment resolved afterwards. It is stored
* UNVETTED on purpose — every consumer reaches TMDB through
* `TmdbEnrichmentService`, whose `detailsForProviderId` runs
* `assessProviderId` on the payload and lets the title search take over
* when the years contradict. Vetting here as well would only record one
* verdict permanently, where the shared gate re-decides per lookup.
*/
tmdbId?: number;
/**
* Year the PROVIDER stated, never one read out of the title. A reader
* with no stored year already falls back to its own title extraction, so
* an absent column means "the provider gave no date" rather than "nobody
* looked" — and a title year like "2001: A Space Odyssey" can never be
* frozen into the row as that film's release year.
*/
releaseYear?: number;
/** The provider's `o_name`; movies only — series responses carry none */
originalTitle?: string;
}
/**
* `extractYear` only ever reads `19xx`/`20xx`, so anything outside that range
* reached us some other way and is not a release year.
*/
const MIN_RELEASE_YEAR = 1900;
const MAX_RELEASE_YEAR = 2099;
function readText(value: unknown): string | undefined {
const text = typeof value === 'string' ? value.trim() : '';
return text || undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function readReleaseYear(value: unknown): number | undefined {
const parsed = readPositiveInteger(value);
return parsed !== undefined &&
parsed >= MIN_RELEASE_YEAR &&
parsed <= MAX_RELEASE_YEAR
? parsed
: undefined;
}
/**
* Drop everything unusable from a patch, and report `null` when nothing
* usable is left — callers use that to skip the write entirely rather than
* spend an IPC round-trip on an empty update.
*
* Validating here, once, is what keeps a provider's `"0"`, `""` or `"N/A"`
* out of the column: the write path is shared by the detail views, the
* favorites/recent add paths and the DB worker, and each of them sees the
* raw provider shape.
*/
export function normalizeContentMetadataPatch(
patch: ContentMetadataPatch | null | undefined
): ContentMetadataPatch | null {
if (!patch) {
return null;
}
const normalized: ContentMetadataPatch = {};
const backdropUrl = readText(patch.backdropUrl);
if (backdropUrl) normalized.backdropUrl = backdropUrl;
const tmdbId = readPositiveInteger(patch.tmdbId);
if (tmdbId !== undefined) normalized.tmdbId = tmdbId;
const releaseYear = readReleaseYear(patch.releaseYear);
if (releaseYear !== undefined) normalized.releaseYear = releaseYear;
const originalTitle = readText(patch.originalTitle);
if (originalTitle) normalized.originalTitle = originalTitle;
return Object.keys(normalized).length > 0 ? normalized : null;
}