mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:16:43 -08:00
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:
1 parent
aa92a3b826
commit
ac3a4e91f3
25 files changed
+545
-124
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user