diff --git a/CLAUDE.md b/CLAUDE.md index f8aef848f..49b0752c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -650,7 +650,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use **TMDB Metadata Enrichment** (opt-in): - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works -- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation +- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - 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` diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 5692a6a07..743220410 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -39,7 +39,7 @@ import type { ResolvedPortalPlayback, Settings, TmdbCacheEntry, - TmdbMediaType, + TmdbCacheMediaType, XtreamCategory, } from '@iptvnator/shared/interfaces'; import { @@ -759,7 +759,7 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_SET_APP_STATE', key, value), // TMDB metadata cache dbGetTmdbMetadata: ( - mediaType: TmdbMediaType, + mediaType: TmdbCacheMediaType, lookupKey: string, language: string ) => diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts index 4dbf1340f..dc7988664 100644 --- a/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts @@ -32,10 +32,44 @@ describe('title-match.operations', () => { categoryId: 7, xtreamId: 42, type: 'movie', + trailingYear: null, }, ]); }); + it('reports the stripped year tag for base-tier matches', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'The Matrix 1999' }], + ]); + + const matches = await matchTitles(db, ['The Matrix']); + expect(matches).toHaveLength(1); + expect(matches[0].trailingYear).toBe(1999); + }); + + it('keeps title-years intact on the exact tier', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'Blade Runner 2049' }], + ]); + + // Exact-tier: the query itself carries the year as part of the title + const exact = await matchTitles(db, ['Blade Runner 2049']); + expect(exact).toHaveLength(1); + expect(exact[0].trailingYear).toBeNull(); + }); + + it('flags "Blade Runner" matching "Blade Runner 2049" with the year tag', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'Blade Runner 2049' }], + ]); + + // Base-tier hit: the renderer must reject it via the 2049/1982 + // year incompatibility (trailingYear is surfaced for exactly that) + const matches = await matchTitles(db, ['Blade Runner']); + expect(matches).toHaveLength(1); + expect(matches[0].trailingYear).toBe(2049); + }); + it('drops FTS candidates whose normalized title differs', async () => { const { db } = createDbMock([ [{ ...matrixRow, title: 'The Matrix Reloaded' }], diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.ts index 7c3937692..f3f5e4c75 100644 --- a/apps/electron-backend/src/app/database/operations/title-match.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.ts @@ -1,16 +1,18 @@ import { sql } from 'drizzle-orm'; import { CatalogTitleMatch, - normalizeTitle, + normalizeTitleKeys, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; /** * Batched cross-playlist title matching for the actor page's "All portals" * scope. For every requested title one trigram-FTS lookup runs against - * `content_title_fts`; candidates count as matches only when their - * normalized title equals the normalized query — the same confidence rule - * the renderer uses for portal-scoped matching. + * `content_title_fts`. Confirmation is two-tier, mirroring the renderer's + * portal-scoped matching: the query (a canonical TMDB title) must equal + * either the candidate's exact normalized title, or its year-stripped form + * — in which case the stripped year tag is returned so the renderer can + * reject year-incompatible matches. */ const MAX_TITLES_PER_REQUEST = 200; @@ -49,7 +51,8 @@ export async function matchTitles( const matches: CatalogTitleMatch[] = []; for (const queryTitle of uniqueTitles) { - const wanted = normalizeTitle(queryTitle); + // TMDB titles are canonical — a trailing year is part of the title + const wanted = normalizeTitleKeys(queryTitle).exact; const matchQuery = buildFtsMatchQuery(wanted); if (!wanted || !matchQuery) { continue; @@ -81,7 +84,13 @@ export async function matchTitles( } for (const row of rows) { - if (normalizeTitle(row.title) !== wanted) { + const rowKeys = normalizeTitleKeys(row.title); + const exactMatch = rowKeys.exact === wanted; + const baseMatch = + !exactMatch && + rowKeys.base !== rowKeys.exact && + rowKeys.base === wanted; + if (!exactMatch && !baseMatch) { continue; } matches.push({ @@ -91,6 +100,7 @@ export async function matchTitles( categoryId: row.category_xtream_id, xtreamId: row.xtream_id, type: row.type, + trailingYear: exactMatch ? null : rowKeys.trailingYear, }); } } diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts index ab3e7ffee..75fc67865 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts @@ -1,11 +1,11 @@ import { and, eq } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { TmdbCacheEntry, TmdbMediaType } from '@iptvnator/shared/interfaces'; +import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; export async function getTmdbMetadata( db: AppDatabase, - mediaType: TmdbMediaType, + mediaType: TmdbCacheMediaType, lookupKey: string, language: string ): Promise { diff --git a/apps/electron-backend/src/app/events/database/tmdb.events.ts b/apps/electron-backend/src/app/events/database/tmdb.events.ts index 7beda920b..19b6b84da 100644 --- a/apps/electron-backend/src/app/events/database/tmdb.events.ts +++ b/apps/electron-backend/src/app/events/database/tmdb.events.ts @@ -3,12 +3,12 @@ * Persists TMDB API responses so detail-view enrichment stays offline-fast */ -import type { TmdbCacheEntry, TmdbMediaType } from '@iptvnator/shared/interfaces'; +import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; import { handleWorkerRequest } from './worker-events.utils'; handleWorkerRequest( 'DB_GET_TMDB_METADATA', - (mediaType: TmdbMediaType, lookupKey: string, language: string) => ({ + (mediaType: TmdbCacheMediaType, lookupKey: string, language: string) => ({ mediaType, lookupKey, language, diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 621e5da79..959086578 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -112,7 +112,12 @@ trailer (official trailer > trailer > teaser, merged into `youtube_trailer` / `tmdb_trailer`); recommendations power the "Similar" rail. In Xtream detail views the rail shows only recommendations that match the provider catalog by normalized title -(`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`) and navigates +(`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`). Matching is +two-tier (`normalizeTitleKeys`): exact normalized titles compare first +(a trailing year in a TMDB title is part of the title — "Blade Runner +2049"); the provider's year-stripped form only counts when its stripped +year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982) +never claims a catalog "Blade Runner 2049". The rail navigates to the matched item — the detail components re-initialize on route param changes (reactive `routeParams` signal) because the router reuses the component for detail→detail navigation. Stalker gets trailers and the @@ -150,8 +155,7 @@ Cast chips carry the TMDB person id (`tmdbPersonId` on `TmdbEnrichedCastMember`) and navigate to `actor/:personId` inside the current portal. The page loads `/person/{id}?append_to_response= combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached -under `person:{id}`; the table's media_type CHECK only allows movie/tv, so -person rows reuse `movie` as a namespace) and renders the shared +under `person:{id}` with media_type `person`) and renders the shared `ActorViewComponent` (`libs/ui/shared-portals`). Filmography has two scopes: @@ -166,7 +170,7 @@ Filmography has two scopes: `DB_MATCH_TITLES` worker request runs a trigram-FTS lookup per title over ALL imported Xtream playlists (`operations/title-match.operations.ts`), confirming candidates with - the same normalized-title equality the renderer uses + the same two-tier normalized-title matching the renderer uses (`normalizeTitle` now lives in `@iptvnator/shared/interfaces` so the worker and the renderer share it). Matches carry the playlist name (shown in the badge) and navigate into that playlist's detail view. @@ -179,9 +183,10 @@ Single table with two row kinds discriminated by `lookup_key` prefix: ``` tmdb_metadata ( - media_type 'movie' | 'tv', + media_type 'movie' | 'tv' | 'person', lookup_key 'id:' -- details payload row 'title:|year:' -- search resolution row + 'person:' -- person payload row language TEXT, -- TMDB language code tmdb_id INTEGER, -- NULL on a search row = negative cache payload TEXT, -- raw JSON details, NULL for search rows diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts index c965c21d2..fb6c5c4fe 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts @@ -84,8 +84,13 @@ export async function enrichStalkerSelectionWithTmdb( return; } - applyEnrichedItem({ - ...current, - info: mergeStalkerInfoWithTmdb(currentInfo, details, mediaType), - }); + try { + applyEnrichedItem({ + ...current, + info: mergeStalkerInfoWithTmdb(currentInfo, details, mediaType), + }); + } catch (error) { + // Never degrade provider data over a malformed payload shape + console.warn('[TMDB] Stalker merge failed:', error); + } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index 979364bac..0ccf45eeb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -20,7 +20,8 @@ import { } from '@iptvnator/services'; import { CatalogTitleMatch, - normalizeTitle, + normalizeTitleKeys, + titleYearsCompatible, } from '@iptvnator/shared/interfaces'; import { ActorViewComponent, @@ -155,19 +156,29 @@ export class StalkerActorRouteComponent { credit: ActorFilmographyCredit ): CatalogTitleMatch | null { const type = credit.mediaType === 'movie' ? 'movie' : 'series'; - return ( - this.globalIndex().get(`${type}:${normalizeTitle(credit.title)}`) ?? - null - ); + const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; + const match = this.globalIndex().get(key) ?? null; + return match && + titleYearsCompatible(credit.year, match.trailingYear) + ? match + : null; } private async loadGlobalMatches(): Promise { + // Guard against actor→actor navigation: a slow match for the + // previous person must not overwrite the current one's results + const requestedPersonId = this.personId(); const titles = this.filmography().map((credit) => credit.title); this.isMatchingGlobal.set(true); try { - this.globalMatches.set(await this.titleMatch.matchTitles(titles)); + const matches = await this.titleMatch.matchTitles(titles); + if (this.personId() === requestedPersonId) { + this.globalMatches.set(matches); + } } finally { - this.isMatchingGlobal.set(false); + if (this.personId() === requestedPersonId) { + this.isMatchingGlobal.set(false); + } } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 5b0d6c4cf..5cbdf6b74 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -300,21 +300,28 @@ export class StalkerSearchComponent { selectItem(item: StalkerVodSource) { this.closeInlinePlayer(); + const filterType = this.selectedFilterType(); const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = - this.selectedFilterType() === 'vod' && + filterType === 'vod' && !hasEmbeddedSeries && isStalkerSeriesFlag(item.is_series); + // The setSelectedItem hook gates TMDB enrichment on the CURRENT + // content type — it must be up to date before the item is set, + // otherwise the type of the previously open tab leaks in. + if (filterType === 'vod' || filterType === 'series') { + this.stalkerStore.setSelectedContentType(filterType); + } + this.itemDetails.set( buildStalkerSelectedVodItem(item, needsSeriesFetch) ); this.stalkerStore.setSelectedItem(this.itemDetails()); - switch (this.selectedFilterType()) { + switch (filterType) { case 'vod': - this.stalkerStore.setSelectedContentType('vod'); if (!hasEmbeddedSeries && !needsSeriesFetch) { const detailViewState = createStalkerDetailViewState( this.itemDetails()!, @@ -334,9 +341,6 @@ export class StalkerSearchComponent { this.selectedVodPosition.set(null); } break; - case 'series': - this.stalkerStore.setSelectedContentType('series'); - break; default: break; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 5852f682d..79bc4de43 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -70,10 +70,15 @@ export async function enrichVodSelectionWithTmdb( return; } - store.setSelectedItem({ - ...current, - info: mergeVodInfoWithTmdb(currentInfo, details), - } as unknown as TItem); + try { + store.setSelectedItem({ + ...current, + info: mergeVodInfoWithTmdb(currentInfo, details), + } as unknown as TItem); + } catch (error) { + // Never degrade provider data over a malformed payload shape + console.warn('[TMDB] VOD merge failed:', error); + } } export async function enrichSerialSelectionWithTmdb< @@ -114,10 +119,14 @@ export async function enrichSerialSelectionWithTmdb< return; } - store.setSelectedItem({ - ...current, - info: mergeSerieInfoWithTmdb(currentInfo, details), - } as unknown as TItem); + try { + store.setSelectedItem({ + ...current, + info: mergeSerieInfoWithTmdb(currentInfo, details), + } as unknown as TItem); + } catch (error) { + console.warn('[TMDB] series merge failed:', error); + } } /** @@ -178,11 +187,18 @@ export async function enrichSerialSeasonWithTmdb< return; } - store.setSelectedItem({ - ...current, - episodes: { - ...current.episodes, - [seasonKey]: mergeEpisodesWithTmdb(currentEpisodes, tmdbEpisodes), - }, - } as unknown as TItem); + try { + store.setSelectedItem({ + ...current, + episodes: { + ...current.episodes, + [seasonKey]: mergeEpisodesWithTmdb( + currentEpisodes, + tmdbEpisodes + ), + }, + } as unknown as TItem); + } catch (error) { + console.warn('[TMDB] season merge failed:', error); + } } diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 6783ec0ac..84c6fd211 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -21,14 +21,18 @@ import { } from '@iptvnator/services'; import { CatalogTitleMatch, - normalizeTitle, + normalizeTitleKeys, + titleYearsCompatible, } from '@iptvnator/shared/interfaces'; import { ActorViewComponent, ActorViewItem, ActorViewScope, } from '@iptvnator/ui/shared-portals'; -import { buildCatalogTitleIndex } from '../tmdb-similar.util'; +import { + buildCatalogTitleIndex, + lookupCatalogTitle, +} from '../tmdb-similar.util'; /** * Actor page inside an Xtream portal: TMDB person + full filmography. @@ -173,26 +177,36 @@ export class XtreamActorRouteComponent { private portalMatchFor(credit: ActorFilmographyCredit) { const index = credit.mediaType === 'movie' ? this.vodIndex() : this.serialIndex(); - return index.get(normalizeTitle(credit.title)) ?? null; + return lookupCatalogTitle(index, credit.title, credit.year); } private globalMatchFor( credit: ActorFilmographyCredit ): CatalogTitleMatch | null { const type = credit.mediaType === 'movie' ? 'movie' : 'series'; - return ( - this.globalIndex().get(`${type}:${normalizeTitle(credit.title)}`) ?? - null - ); + const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; + const match = this.globalIndex().get(key) ?? null; + return match && + titleYearsCompatible(credit.year, match.trailingYear) + ? match + : null; } private async loadGlobalMatches(): Promise { + // Guard against actor→actor navigation: a slow match for the + // previous person must not overwrite the current one's results + const requestedPersonId = this.personId(); const titles = this.filmography().map((credit) => credit.title); this.isMatchingGlobal.set(true); try { - this.globalMatches.set(await this.titleMatch.matchTitles(titles)); + const matches = await this.titleMatch.matchTitles(titles); + if (this.personId() === requestedPersonId) { + this.globalMatches.set(matches); + } } finally { - this.isMatchingGlobal.set(false); + if (this.personId() === requestedPersonId) { + this.isMatchingGlobal.set(false); + } } } diff --git a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts index 821ce5161..8f1badc7c 100644 --- a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts +++ b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts @@ -1,5 +1,9 @@ import { TmdbRecommendation } from '@iptvnator/shared/interfaces'; -import { matchRecommendationsToCatalog } from './tmdb-similar.util'; +import { + buildCatalogTitleIndex, + lookupCatalogTitle, + matchRecommendationsToCatalog, +} from './tmdb-similar.util'; describe('matchRecommendationsToCatalog', () => { const rec = ( @@ -87,3 +91,66 @@ describe('matchRecommendationsToCatalog', () => { ).toEqual([]); }); }); + +describe('two-tier year handling', () => { + const rec = ( + tmdbId: number, + title: string, + year: number | null = null + ): TmdbRecommendation => ({ tmdbId, title, year, posterUrl: null }); + + const catalog = [ + { stream_id: 1, name: 'Blade Runner 2049', category_id: '9' }, + { stream_id: 2, name: 'The Matrix 1999', category_id: '9' }, + ]; + + it('matches a title-year recommendation on the exact tier', () => { + const matched = matchRecommendationsToCatalog( + [rec(335984, 'Blade Runner 2049', 2017)], + catalog + ); + expect(matched.map((m) => m.id)).toEqual([1]); + }); + + it('rejects a year-incompatible base-tier match', () => { + // "Blade Runner" (1982) must NOT claim the catalog's "Blade Runner 2049" + const matched = matchRecommendationsToCatalog( + [rec(78, 'Blade Runner', 1982)], + catalog + ); + expect(matched).toEqual([]); + }); + + it('accepts a year-compatible base-tier match', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix', 1999)], + catalog + ); + expect(matched.map((m) => m.id)).toEqual([2]); + }); +}); + +describe('buildCatalogTitleIndex / lookupCatalogTitle', () => { + const index = buildCatalogTitleIndex([ + { stream_id: 1, name: 'Blade Runner 2049', category_id: '9' }, + { stream_id: 2, name: 'The Matrix 1999', category_id: '9' }, + { stream_id: 3, name: 'Heat', category_id: '9' }, + ]); + + it('resolves exact titles including title-years', () => { + expect(lookupCatalogTitle(index, 'Blade Runner 2049', 2017)?.id).toBe(1); + expect(lookupCatalogTitle(index, 'Heat', 1995)?.id).toBe(3); + }); + + it('resolves year-tagged provider titles when years agree', () => { + expect(lookupCatalogTitle(index, 'The Matrix', 1999)?.id).toBe(2); + }); + + it('rejects contradicting years on the stripped tier', () => { + expect(lookupCatalogTitle(index, 'Blade Runner', 1982)).toBeNull(); + }); + + it('is lenient when the credit year is unknown', () => { + expect(lookupCatalogTitle(index, 'The Matrix', null)?.id).toBe(2); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts index 89d055bc8..c18f59e6b 100644 --- a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts +++ b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts @@ -1,11 +1,18 @@ -import { normalizeTitle } from '@iptvnator/services'; -import { TmdbRecommendation } from '@iptvnator/shared/interfaces'; +import { + TmdbRecommendation, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; /** * Matches TMDB recommendations against the provider catalog so the - * "Similar" rail only shows titles the user can actually play. Matching is - * by normalized title (same normalization as the enrichment matcher); - * recommendations without a catalog hit are dropped. + * "Similar" rail only shows titles the user can actually play. + * + * Matching is two-tier: TMDB titles are canonical (a trailing year is part + * of the title — "Blade Runner 2049"), provider titles are dirty (a + * trailing year is usually a release tag — "The Matrix 1999"). The exact + * normalized forms are compared first; the provider's year-stripped form + * only counts when the stripped year does not contradict the TMDB year. */ export interface SimilarCatalogItem { @@ -50,17 +57,28 @@ export interface CatalogMatch { categoryId: string; } +interface IndexedCatalogEntry extends CatalogMatch { + /** Year tag stripped from the provider title (base-tier entries only) */ + trailingYear: number | null; +} + /** - * Normalized-title → catalog entry index for per-title availability checks - * (actor page filmography). First occurrence of a title wins. + * Two-tier catalog index for per-title availability checks (actor page + * filmography). First occurrence of a title wins per tier. */ +export interface CatalogTitleIndex { + exact: Map; + base: Map; +} + export function buildCatalogTitleIndex( streams: readonly CatalogStream[] -): Map { - const index = new Map(); +): CatalogTitleIndex { + const exact = new Map(); + const base = new Map(); for (const stream of streams) { - const key = normalizeTitle(streamTitle(stream)); - if (!key || index.has(key)) { + const keys = normalizeTitleKeys(streamTitle(stream)); + if (!keys.exact) { continue; } const id = streamId(stream); @@ -69,9 +87,45 @@ export function buildCatalogTitleIndex( if (id === null || categoryId === undefined || categoryId === null) { continue; } - index.set(key, { id, categoryId: String(categoryId) }); + const entry: IndexedCatalogEntry = { + id, + categoryId: String(categoryId), + trailingYear: keys.trailingYear, + }; + if (!exact.has(keys.exact)) { + exact.set(keys.exact, { ...entry, trailingYear: null }); + } + // The base tier only exists for titles that carried a year tag + if (keys.base !== keys.exact && !base.has(keys.base)) { + base.set(keys.base, entry); + } } - return index; + return { exact, base }; +} + +/** + * Looks up a TMDB title (canonical — never year-stripped) in the catalog + * index. Base-tier hits require the provider's stripped year tag to be + * compatible with the TMDB year, so "Blade Runner" (1982) never claims the + * catalog's "Blade Runner 2049". + */ +export function lookupCatalogTitle( + index: CatalogTitleIndex, + title: string, + year?: number | null +): CatalogMatch | null { + const wanted = normalizeTitleKeys(title).exact; + if (!wanted) { + return null; + } + const exactHit = index.exact.get(wanted); + if (exactHit) { + return exactHit; + } + const baseHit = index.base.get(wanted); + return baseHit && titleYearsCompatible(year, baseHit.trailingYear) + ? baseHit + : null; } export function matchRecommendationsToCatalog( @@ -83,9 +137,10 @@ export function matchRecommendationsToCatalog( return []; } + // Wanted keys are the EXACT normalized TMDB titles const wanted = new Map(); for (const recommendation of recommendations) { - const key = normalizeTitle(recommendation.title); + const key = normalizeTitleKeys(recommendation.title).exact; if (key && !wanted.has(key)) { wanted.set(key, recommendation); } @@ -98,11 +153,26 @@ export function matchRecommendationsToCatalog( if (catalogHits.size >= wanted.size) { break; } - const key = normalizeTitle(streamTitle(stream)); - if (!key || !wanted.has(key) || catalogHits.has(key)) { + const keys = normalizeTitleKeys(streamTitle(stream)); + if (!keys.exact) { continue; } - catalogHits.set(key, stream); + if (wanted.has(keys.exact) && !catalogHits.has(keys.exact)) { + catalogHits.set(keys.exact, stream); + continue; + } + // Base tier: provider title carried a year tag — only match when + // that year does not contradict the recommendation's year + if (keys.base === keys.exact || catalogHits.has(keys.base)) { + continue; + } + const recommendation = wanted.get(keys.base); + if ( + recommendation && + titleYearsCompatible(recommendation.year, keys.trailingYear) + ) { + catalogHits.set(keys.base, stream); + } } const limit = options.limit ?? 12; diff --git a/libs/services/src/lib/catalog-title-match.service.ts b/libs/services/src/lib/catalog-title-match.service.ts index 7cd0ca505..a7cfa90c3 100644 --- a/libs/services/src/lib/catalog-title-match.service.ts +++ b/libs/services/src/lib/catalog-title-match.service.ts @@ -1,20 +1,25 @@ import { Injectable } from '@angular/core'; import { CatalogTitleMatch, - normalizeTitle, + normalizeTitleKeys, } from '@iptvnator/shared/interfaces'; /** - * Index of matches keyed by `type:normalizedTitle` for O(1) lookups when - * mapping a filmography onto the match list. First match per title wins. + * Index of matches keyed by `type:exactNormalizedTitle` for O(1) lookups + * when mapping a filmography onto the match list. Exact-title matches + * (trailingYear === null) win over year-stripped ones for the same key. */ export function buildTitleMatchIndex( matches: readonly CatalogTitleMatch[] ): Map { const index = new Map(); for (const match of matches) { - const key = `${match.type}:${normalizeTitle(match.queryTitle)}`; - if (!index.has(key)) { + const key = `${match.type}:${normalizeTitleKeys(match.queryTitle).exact}`; + const existing = index.get(key); + if ( + !existing || + (existing.trailingYear !== null && match.trailingYear === null) + ) { index.set(key, match); } } diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.ts index 0ec3c9988..a1ea1394a 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { TmdbCacheEntry, TmdbMediaType } from '@iptvnator/shared/interfaces'; +import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; /** * Environment-aware cache for TMDB lookups. @@ -25,7 +25,7 @@ export class TmdbCacheService { } async get( - mediaType: TmdbMediaType, + mediaType: TmdbCacheMediaType, lookupKey: string, language: string ): Promise { @@ -82,7 +82,7 @@ export class TmdbCacheService { } private memoryKey( - mediaType: TmdbMediaType, + mediaType: TmdbCacheMediaType, lookupKey: string, language: string ): string { diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 4c6d51e95..a2b4ecd49 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -112,9 +112,8 @@ export class TmdbEnrichmentService { /** * Person details with the full combined filmography, in the app - * language. Cached like details payloads; the `person:` lookup-key - * prefix namespaces the rows (the table's media_type CHECK only allows - * 'movie'/'tv', so person rows reuse 'movie'). + * language. Cached like details payloads under the 'person' media + * type. */ async getPersonDetails( personId: number @@ -127,7 +126,7 @@ export class TmdbEnrichmentService { const language = this.language(); const lookupKey = `person:${personId}`; - const cached = await this.cache.get('movie', lookupKey, language); + const cached = await this.cache.get('person', lookupKey, language); if ( this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) && cached?.payload @@ -146,7 +145,7 @@ export class TmdbEnrichmentService { ); await this.cache.set({ - mediaType: 'movie', + mediaType: 'person', lookupKey, language, tmdbId: personId, diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index 24a84b626..f583d6482 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -107,10 +107,15 @@ function tmdbRating(details: TmdbDetails): number | null { /** TMDB backdrop first, then the provider's own entries (deduplicated) */ function mergedBackdrops( details: TmdbDetails, - providerBackdrops: string[] | undefined + // Some panels send backdrop_path as a plain string despite the typing + providerBackdrops: string[] | string | undefined ): string[] { const tmdbUrl = tmdbBackdropUrl(details.backdrop_path); - const provider = (providerBackdrops ?? []).filter(Boolean); + const provider = ( + Array.isArray(providerBackdrops) + ? providerBackdrops + : [providerBackdrops] + ).filter((value): value is string => typeof value === 'string' && !!value); return tmdbUrl ? [tmdbUrl, ...provider.filter((url) => url !== tmdbUrl)] : provider; diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index c63bfc5c1..642ecfcfc 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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, @@ -295,16 +306,8 @@ const CREATE_TABLE_STATEMENTS = [ `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) - `CREATE TABLE IF NOT EXISTS tmdb_metadata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv')), - lookup_key TEXT NOT NULL, - language TEXT NOT NULL, - tmdb_id INTEGER, - payload TEXT, - fetched_at TEXT DEFAULT (datetime('now')) - )`, - `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`, + TMDB_METADATA_TABLE_SQL, + TMDB_METADATA_INDEX_SQL, ]; /** @@ -724,10 +727,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); diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index e2705e20b..0093dab99 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -359,7 +359,7 @@ export const tmdbMetadata = sqliteTable( 'tmdb_metadata', { id: integer('id').primaryKey({ autoIncrement: true }), - mediaType: text('media_type', { enum: ['movie', 'tv'] }).notNull(), + mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(), lookupKey: text('lookup_key').notNull(), language: text('language').notNull(), tmdbId: integer('tmdb_id'), diff --git a/libs/shared/interfaces/src/lib/catalog-title-match.interface.ts b/libs/shared/interfaces/src/lib/catalog-title-match.interface.ts index 4fb6414d8..b2547e7cc 100644 --- a/libs/shared/interfaces/src/lib/catalog-title-match.interface.ts +++ b/libs/shared/interfaces/src/lib/catalog-title-match.interface.ts @@ -13,4 +13,12 @@ export interface CatalogTitleMatch { categoryId: number; xtreamId: number; type: 'movie' | 'series'; + /** + * Release-year tag stripped from the matched catalog title when the + * match happened on the year-stripped tier ("The Matrix 1999" matching + * the query "The Matrix"), null for exact-title matches. Consumers + * with a known query year must reject incompatible years — this keeps + * "Blade Runner" from claiming a catalog "Blade Runner 2049". + */ + trailingYear: number | null; } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 26e516864..73d222fcb 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -31,7 +31,7 @@ import { import { PortalDebugEvent } from './portal-debug.interface'; import { CatalogTitleMatch } from './catalog-title-match.interface'; import { Settings } from './settings.interface'; -import { TmdbCacheEntry, TmdbMediaType } from './tmdb.interface'; +import { TmdbCacheEntry, TmdbCacheMediaType } from './tmdb.interface'; import { XtreamCategory } from './xtream-category.interface'; export const ELECTRON_BRIDGE_CONTENT_TYPES = { @@ -780,7 +780,7 @@ export interface ElectronBridgeApi { ) => Promise; // TMDB metadata cache dbGetTmdbMetadata: ( - mediaType: TmdbMediaType, + mediaType: TmdbCacheMediaType, lookupKey: string, language: string ) => Promise; diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts new file mode 100644 index 000000000..3c60b3d8a --- /dev/null +++ b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts @@ -0,0 +1,69 @@ +import { + normalizeTitle, + normalizeTitleKeys, + titleYearsCompatible, +} from './title-normalization.util'; + +describe('normalizeTitleKeys', () => { + it('keeps a trailing year in the exact form and strips it in base', () => { + expect(normalizeTitleKeys('Blade Runner 2049')).toEqual({ + exact: 'blade runner 2049', + base: 'blade runner', + trailingYear: 2049, + }); + }); + + it('returns identical tiers when there is no trailing year', () => { + expect(normalizeTitleKeys('Blade Runner')).toEqual({ + exact: 'blade runner', + base: 'blade runner', + trailingYear: null, + }); + }); + + it('strips quality tags and bracket groups on both tiers', () => { + expect(normalizeTitleKeys('The Matrix 1999 [4K] (Remastered)')).toEqual( + { + exact: 'the matrix 1999', + base: 'the matrix', + trailingYear: 1999, + } + ); + }); + + it('never strips a year that IS the whole title', () => { + expect(normalizeTitleKeys('2012')).toEqual({ + exact: '2012', + base: '2012', + trailingYear: null, + }); + }); + + it('keeps leading/mid-title years (only trailing years are tags)', () => { + expect(normalizeTitle('2001: A Space Odyssey')).toBe( + '2001 a space odyssey' + ); + }); + + it('only strips UPPERCASE language prefixes', () => { + expect(normalizeTitle('EN - The Boys s05')).toBe('the boys'); + expect(normalizeTitle('It: Chapter Two')).toBe('it chapter two'); + }); + + it('strips season suffixes on both tiers', () => { + expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys'); + expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны'); + }); +}); + +describe('titleYearsCompatible', () => { + it('accepts unknown years and ±1 tolerance', () => { + expect(titleYearsCompatible(null, 2049)).toBe(true); + expect(titleYearsCompatible(1999, undefined)).toBe(true); + expect(titleYearsCompatible(1999, 2000)).toBe(true); + }); + + it('rejects contradicting years', () => { + expect(titleYearsCompatible(1982, 2049)).toBe(false); + }); +}); diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index c7629fc95..3b746683b 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -27,11 +27,22 @@ const QUALITY_TAGS = new Set([ 'dubbed', ]); -/** Leading channel/language prefix like "EN - ", "DE| ", "FR: " */ -const LANGUAGE_PREFIX = /^[a-z]{2,3}\s*[-|:]\s+/i; +/** + * Leading channel/language prefix like "EN - ", "DE| ", "FR: ". + * UPPERCASE-only on purpose: a case-insensitive match would amputate real + * title words ("It: Chapter Two" → "Chapter Two"). + */ +const LANGUAGE_PREFIX = /^[A-Z]{2,3}\s*[-|:]\s+/; const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/; +/** + * Release-year tag at the very end of a title ("The Matrix 1999"). Only + * trailing years are stripped — an unanchored pattern would eat years that + * are part of the title ("2001: A Space Odyssey" → "a space odyssey"). + */ +const TRAILING_YEAR_PATTERN = /(?:^|\s)(19\d{2}|20\d{2})$/; + /** * Trailing season markers on series titles: "The Boys s05", "сезон 2". * Uses (?:^|\s) instead of \b — JS word boundaries are ASCII-only and @@ -40,14 +51,32 @@ const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/; const SEASON_SUFFIX_PATTERN = /(?:^|\s)(?:s\d{1,2}|season\s*\d{1,2}|сезон\s*\d{1,2}|staffel\s*\d{1,2}|temporada\s*\d{1,2})$/i; -export function normalizeTitle(raw: string | null | undefined): string { +/** + * A provider title normalized on two tiers. Trailing years on provider + * titles are ambiguous — usually a release tag ("The Matrix 1999") but + * sometimes part of the title itself ("Blade Runner 2049") — so matching + * must try the exact form first and only fall back to the year-stripped + * form when the stripped year does not contradict the other side's year. + */ +export interface NormalizedTitleKeys { + /** Fully normalized, trailing year KEPT ("blade runner 2049") */ + exact: string; + /** Trailing year stripped ("blade runner"); equals `exact` if none */ + base: string; + /** The trailing year removed in `base`, when there was one */ + trailingYear: number | null; +} + +export function normalizeTitleKeys( + raw: string | null | undefined +): NormalizedTitleKeys { if (!raw) { - return ''; + return { exact: '', base: '', trailingYear: null }; } const cleaned = raw // Inner classes exclude the opening delimiter too, so runaway - // inputs like "[[[[[…" backtrack linearly (CodeQL js/polynomial-redos) + // inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos) .replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ') .replace(LANGUAGE_PREFIX, '') .normalize('NFD') @@ -56,23 +85,53 @@ export function normalizeTitle(raw: string | null | undefined): string { .replace(/[^\p{L}\p{N}]+/gu, ' ') .split(' ') .filter((token) => token !== '' && !QUALITY_TAGS.has(token)) - .join(' '); - - // Trailing years are release tags ("The Matrix 1999"), but a year can - // also BE the title ("2012") — never normalize down to an empty string. - let result = cleaned.trim(); - const withoutYear = result - .replace(YEAR_PATTERN, '') - .replace(/\s+/g, ' ') + .join(' ') .trim(); - if (withoutYear) { - result = withoutYear; - } // Portal series list titles carry season suffixes ("The Boys s05"); // TMDB knows only the show title. - const withoutSeason = result.replace(SEASON_SUFFIX_PATTERN, '').trim(); - return withoutSeason || result; + const stripSeason = (value: string) => + value.replace(SEASON_SUFFIX_PATTERN, '').trim() || value; + + const exact = stripSeason(cleaned); + + // Trailing years are release tags ("The Matrix 1999"), but a year can + // also BE the title ("2012") — never normalize down to an empty string. + const yearMatch = cleaned.match(TRAILING_YEAR_PATTERN); + const withoutYear = cleaned + .replace(TRAILING_YEAR_PATTERN, '') + .replace(/\s+/g, ' ') + .trim(); + if (!yearMatch || !withoutYear) { + return { exact, base: exact, trailingYear: null }; + } + + return { + exact, + base: stripSeason(withoutYear), + trailingYear: Number(yearMatch[1]), + }; +} + +export function normalizeTitle(raw: string | null | undefined): string { + return normalizeTitleKeys(raw).base; +} + +/** + * True when a base-tier (year-stripped) title match is not contradicted by + * the known years of both sides. Unknown years never block a match. + */ +export function titleYearsCompatible( + a: number | null | undefined, + b: number | null | undefined +): boolean { + return ( + a === null || + a === undefined || + b === null || + b === undefined || + Math.abs(a - b) <= 1 + ); } /** Extract a release year from a date string or from tags in a raw title */ diff --git a/libs/shared/interfaces/src/lib/tmdb.interface.ts b/libs/shared/interfaces/src/lib/tmdb.interface.ts index 142328a90..ce0e4bd9d 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -8,6 +8,12 @@ export type TmdbMediaType = 'movie' | 'tv'; +/** + * Row namespace in the shared metadata cache table. Besides movie/tv + * details it also stores person payloads (`person:` lookup keys). + */ +export type TmdbCacheMediaType = TmdbMediaType | 'person'; + /** * Opt-in TMDB enrichment settings. Enrichment sends movie/series titles to * TMDB, so it is disabled by default for privacy. @@ -56,7 +62,7 @@ export interface TmdbRecommendation { * `tmdbId` is a cached "no confident match" verdict (negative cache) */ export interface TmdbCacheEntry { - mediaType: TmdbMediaType; + mediaType: TmdbCacheMediaType; lookupKey: string; /** TMDB language code the payload was fetched with, e.g. `en-US` */ language: string;