feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals (#1123)

* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals

Adds an opt-in TMDB integration (Settings > Metadata) that enriches
detail views with a field-level merge — the provider stays authoritative
for stream data, TMDB fills editorial fields when the match is confident.

Enrichment:
- Movie/series details: plot, cast (avatar chips), director, genres,
  rating, poster/backdrop, official YouTube trailers
- Confidence-gated matching: provider tmdb_id trusted; otherwise
  normalized-title search with year gate (±1; series accept earlier
  premieres), season-suffix stripping, Cyrillic search-language override,
  and language-prefix fallback variants
- Lazy season/episode enrichment: real episode names, overviews, stills
- "Similar" rail (Xtream): TMDB recommendations matched to the catalog
- Actor pages per portal with full filmography, availability filter and
  an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES
  worker op over the trigram FTS index

Infrastructure:
- SQLite cache table tmdb_metadata (details, search verdicts, seasons,
  persons; per-language, TTL-guarded), in-memory fallback for the PWA
- Settings: enable toggle, own-API-key override with a live "check key"
  button; TMDB attribution in Settings and About
- Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via
  tools/tmdb/inject-tmdb-key.mjs when the secret is configured
- normalizeTitle shared between renderer and DB worker
- CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked)

Fixes and refactors along the way:
- fix(stalker): Advanced Search sent bare get_ordered_list requests and
  skipped the auth handshake when isFullStalkerPortal was missing on the
  active-playlist meta — full portals answered "Authorization failed."
  and search looked empty; now mirrors the catalog request shape and
  routes through makeAuthenticatedRequest with URL-based detection
- fix(stalker): TMDB fields survive info re-normalization; detail views
  prefer the store copy patched by async enrichment over stale snapshots
- refactor(xtream): split oversized vod/serial detail components into
  component-scoped playback services; detail routes re-initialize on
  route param changes (router reuses them for detail-to-detail nav)
- i18n: all new keys translated across the 18 locales

Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates.

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

* fix(tmdb): provide route params observable to inline collection details, linearize regexes

The global-collection inline detail host builds a fake ActivatedRoute for
VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params.
Since the detail components now read route.params via toSignal() (detail->
detail re-init), the missing observable crashed component construction and
the content hero never rendered — broke dashboard-activation, favorites and
recent Electron E2E on all platforms. Provide the params observable
alongside the snapshot and assert it in the component spec.

Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in
normalizeTitle now excludes opening delimiters inside the classes, and
youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead
of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u
escapes (greptile note).

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

* fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP

Review follow-ups on PR #1123: the Xtream VOD detail badge renders
rating_imdb, but the merge wrote the TMDB score only into `rating`, so a
TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the
provider left it empty, mirroring the Stalker merge. All trailer iframes
are normalized to youtube-nocookie.com, so the extra youtube.com frame-src
allowance was dead surface (greptile) — removed.

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

* fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema

Fixes the confirmed findings from the PR #1123 code review:

- Stalker search: setSelectedContentType now runs BEFORE setSelectedItem,
  so the TMDB enrichment gate in the selection hook no longer sees the
  content type of the previously open tab (wrong/no enrichment after
  ITV -> search -> movie).
- Title normalization is now two-tier (normalizeTitleKeys): the exact
  normalized form keeps a trailing year, the base form strips it and
  remembers the tag. Year stripping is anchored to the end of the title
  ("2001: A Space Odyssey" keeps its year) and language-prefix stripping
  is UPPERCASE-only ("It: Chapter Two" is no longer amputated).
- All catalog matching (similar rail, actor pages, DB worker
  DB_MATCH_TITLES) compares exact forms first and only accepts
  year-stripped matches when the stripped tag is year-compatible (+-1)
  with the TMDB year — "Blade Runner" (1982) can no longer claim a
  catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped
  trailingYear so the renderer can apply the guard to worker matches.
- mergedBackdrops tolerates a plain-string backdrop_path; enrichment
  merge+patch blocks are wrapped in try/catch so a malformed provider
  payload can no longer become an unhandled rejection.
- loadGlobalMatches (both actor routes) guards against actor->actor
  navigation races — a slow match for the previous person no longer
  overwrites the current one's results.
- tmdb_metadata media_type CHECK widened to ('movie','tv','person') and
  person rows now use the honest 'person' type (TmdbCacheMediaType).
  Pre-release dev DBs with the narrow CHECK are rebuilt in place — the
  table is a pure cache, so the migration is a self-healing
  drop-and-recreate keyed off sqlite_master.

Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression
coverage: title-normalization.util.spec.ts, two-tier cases in
tmdb-similar.util.spec.ts and title-match.operations.spec.ts.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-04 17:07:46 +02:00
1 parent 342175fe76
commit dc05a2566e
113 files changed
+7855 -712

No files matched your search

@@ -73,6 +73,17 @@ export function getDatabasePath(): string {
/**
* SQL statements for creating all tables
*/
const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')),
lookup_key TEXT NOT NULL,
language TEXT NOT NULL,
tmdb_id INTEGER,
payload TEXT,
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
@@ -294,6 +305,9 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
];
/**
@@ -714,10 +728,41 @@ function runMigrationStatements(
}
}
/**
* Pre-release installs created tmdb_metadata with a CHECK that only
* allowed 'movie'/'tv'; person rows need 'person'. The table is a pure
* cache, so the cheapest "migration" is a drop-and-recreate with the
* widened constraint. Self-healing via sqlite_master — no app_state key.
*/
function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
try {
const row = sqliteDb
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tmdb_metadata'`
)
.get() as { sql?: string } | undefined;
if (!row?.sql || row.sql.includes(`'person'`)) {
return;
}
const rebuild = sqliteDb.transaction(() => {
sqliteDb.prepare(`DROP TABLE IF EXISTS tmdb_metadata`).run();
sqliteDb.prepare(TMDB_METADATA_TABLE_SQL).run();
sqliteDb.prepare(TMDB_METADATA_INDEX_SQL).run();
});
rebuild();
console.log(
'[DB] Rebuilt tmdb_metadata cache with widened media_type CHECK'
);
} catch (error) {
console.warn('[DB] tmdb_metadata CHECK widening failed:', error);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
+28
View File
@@ -349,3 +349,31 @@ export const downloads = sqliteTable(
export type Download = typeof downloads.$inferSelect;
export type NewDownload = typeof downloads.$inferInsert;
// TMDB metadata cache table.
// Two row kinds share the table, discriminated by the lookup_key prefix:
// - 'id:<tmdbId>' → full TMDB details payload (JSON)
// - 'title:<normalized>|year:<y>' → search resolution; tmdb_id NULL means
// "no confident match" (negative cache)
export const tmdbMetadata = sqliteTable(
'tmdb_metadata',
{
id: integer('id').primaryKey({ autoIncrement: true }),
mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(),
lookupKey: text('lookup_key').notNull(),
language: text('language').notNull(),
tmdbId: integer('tmdb_id'),
payload: text('payload'),
fetchedAt: text('fetched_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
lookupUnique: uniqueIndex('tmdb_metadata_lookup_unique').on(
table.mediaType,
table.lookupKey,
table.language
),
})
);
export type TmdbMetadata = typeof tmdbMetadata.$inferSelect;
export type NewTmdbMetadata = typeof tmdbMetadata.$inferInsert;