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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 16:00:23 +02:00
1 parent aa92a3b826
commit ac3a4e91f3
25 files changed
+545 -124

No files matched your search

+1 -1
View File
@@ -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`
@@ -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
) =>
@@ -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' }],
@@ -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,
});
}
}
@@ -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<TmdbCacheEntry | null> {
@@ -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,
+10 -5
View File
@@ -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:<tmdbId>' -- details payload row
'title:<normalized>|year:<y>' -- search resolution row
'person:<personId>' -- 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
@@ -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);
}
}
@@ -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<void> {
// 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);
}
}
}
@@ -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;
}
@@ -70,10 +70,15 @@ export async function enrichVodSelectionWithTmdb<TItem extends SelectionRecord>(
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);
}
}
@@ -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<void> {
// 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);
}
}
}
@@ -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);
});
});
@@ -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<string, IndexedCatalogEntry>;
base: Map<string, IndexedCatalogEntry>;
}
export function buildCatalogTitleIndex(
streams: readonly CatalogStream[]
): Map<string, CatalogMatch> {
const index = new Map<string, CatalogMatch>();
): CatalogTitleIndex {
const exact = new Map<string, IndexedCatalogEntry>();
const base = new Map<string, IndexedCatalogEntry>();
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<string, TmdbRecommendation>();
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;
@@ -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<string, CatalogTitleMatch> {
const index = new Map<string, CatalogTitleMatch>();
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);
}
}
@@ -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<TmdbCacheEntry | null> {
@@ -82,7 +82,7 @@ export class TmdbCacheService {
}
private memoryKey(
mediaType: TmdbMediaType,
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
): string {
@@ -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,
+7 -2
View File
@@ -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;
+44 -10
View File
@@ -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);
+1 -1
View File
@@ -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'),
@@ -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;
}
@@ -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<ElectronBridgeResult>;
// TMDB metadata cache
dbGetTmdbMetadata: (
mediaType: TmdbMediaType,
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
) => Promise<TmdbCacheEntry | null>;
@@ -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);
});
});
@@ -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 */
@@ -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:<id>` 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;