From 0cba49f3e21e028b6b531ead28c651e7d307e15d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:31:59 +0200 Subject: [PATCH] fix(dashboard): keep every catalog row per title key when matching (#1425) --- .changes/dashboard-trending-remake-matches.md | 9 + CLAUDE.md | 1 + .../operations/title-sources.operations.ts | 8 +- docs/architecture/tmdb-metadata-enrichment.md | 73 ++++--- .../src/lib/stalker-actor-route.component.ts | 26 ++- .../lib/actor/xtream-actor-route.component.ts | 26 ++- .../lib/catalog-title-match.service.spec.ts | 190 ++++++++++++++++++ .../src/lib/catalog-title-match.service.ts | 91 +++++++-- .../lib/cross-portal-similar.service.spec.ts | 16 ++ .../src/lib/cross-portal-similar.service.ts | 24 ++- .../dashboard-recommendations.service.spec.ts | 2 +- .../lib/dashboard-recommendations.service.ts | 9 +- .../src/lib/dashboard-recommendations.util.ts | 97 +++------ .../lib/dashboard-trending.service.spec.ts | 18 ++ .../src/lib/dashboard-trending.service.ts | 29 ++- 15 files changed, 446 insertions(+), 173 deletions(-) create mode 100644 .changes/dashboard-trending-remake-matches.md create mode 100644 libs/services/src/lib/catalog-title-match.service.spec.ts diff --git a/.changes/dashboard-trending-remake-matches.md b/.changes/dashboard-trending-remake-matches.md new file mode 100644 index 000000000..27449ee91 --- /dev/null +++ b/.changes/dashboard-trending-remake-matches.md @@ -0,0 +1,9 @@ +--- +type: fix +area: dashboard +--- + +Trending cards, the "Similar" rail and actor filmographies no longer show a +title as missing from your library when your playlists hold several versions of +it. A catalog with both "Dune 1984" and "Dune 2021" used to keep whichever came +first, so the other one lost its link even though you had it. diff --git a/CLAUDE.md b/CLAUDE.md index 14c3cc722..395cc5884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1300,6 +1300,7 @@ engine` (restart required) or - Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; 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` +- All `DB_MATCH_TITLES` consumers (Trending rail, "Because you watched" recommendations rail, cross-portal Similar rail, actor "All portals" scope) resolve the worker's flat result list through the shared `groupTitleMatchesByKey()` + `pickTitleMatch()` in `libs/services/src/lib/catalog-title-match.service.ts`. The grouping keeps EVERY row per `type:exactNormalizedTitle` on purpose — the year that separates same-titled rows belongs to the lookup, which the grouping cannot see, so collapsing first made a catalog holding both "Dune 1984" and "Dune 2021" drop whichever copy the user actually owns. `pickTitleMatch` then ranks year-compatible rows by evidence (exact year → untagged → any compatible) across all title aliases at once; only the recommendations rail passes an alias (TMDB `original_title`, via `candidateLookup()`). Multi-source VOD discovery deliberately stays off these helpers: there every copy is a distinct selectable source, not one best answer - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index ecc458d16..1fbc7ca55 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -14,10 +14,10 @@ import { * VOD multi-source discovery: find the SAME movie in the user's other * playlists. * - * Deliberately not built on `buildTitleMatchIndex()` — that helper keeps one - * match per title key because the Similar rail only needs "does this exist - * somewhere". Here every copy in every playlist is a distinct, selectable - * source, so all of them are returned. + * Deliberately not built on `pickTitleMatch()` — that helper resolves a + * lookup to the ONE best row, because the Similar/trending/actor rails only + * need "where can I play this". Here every copy in every playlist is a + * distinct, selectable source, so all of them are returned. * * Scope is Xtream-only: Stalker items never reach the `content` table (they * would need a live authenticated portal search), and M3U playlists are stored diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index f8db9ebdc..728cb6640 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -302,6 +302,40 @@ Filmography has two scopes: This also works from Stalker actor pages — the one place the Stalker catalog limitation is lifted. +### Resolving a batched match + +Every `DB_MATCH_TITLES` consumer — the Trending rail, the "Because you +watched" recommendations rail, the cross-portal "Similar" rail and the +actor page's "All portals" scope — turns the worker's flat result list +into one row through the same pair of helpers in +`libs/services/src/lib/catalog-title-match.service.ts`: + +```ts +const grouped = groupTitleMatchesByKey(matches); +const match = pickTitleMatch({ type, titles: [title], year }, grouped); +``` + +`groupTitleMatchesByKey` keeps **every** row per +`type:exactNormalizedTitle`. Collapsing to one row per key is the trap +this replaced: the year that separates same-titled rows belongs to the +LOOKUP, which the grouping cannot see, so a catalog holding both +"Dune 1984" and "Dune 2021" kept whichever the worker returned first and +the other lookup then failed its own year check with the right row +already discarded — rendering a movie the user owns as unavailable. + +`pickTitleMatch` ranks the year-compatible rows by evidence: a row whose +stripped year IS the lookup's year wins, then an untagged row (the only +tier reachable when the lookup year is unknown), then anything else +compatible. `titles` accepts aliases most-trusted-first; ranking spans all +aliases at once, so a weak hit under the first alias cannot veto a strong +one under the second, and alias order only breaks ties inside a tier. The +recommendations rail is the one caller that passes two — TMDB's localized +title plus `original_title` — via `candidateLookup()`; the rest pass one. + +Multi-source VOD discovery deliberately does NOT use these +(`operations/title-sources.operations.ts`): there every copy in every +playlist is a distinct selectable source, not a single best answer. + ## Cache Single table with several row kinds discriminated by the `lookup_key` @@ -460,31 +494,20 @@ since shipped.) the 1982 `"Blade Runner"`. A row that states no year records `null` and keeps excluding unconditionally; an unknown year on either side counts as agreeing, since re-recommending something already watched is the - worse failure. Matching and exclusion - work through BOTH the localized title and the TMDB original-title alias - (cards always display the localized form) — catalogs frequently name - items in their original language while the app language localizes the - TMDB titles; a year-incompatible first-alias match does not veto a - year-compatible match under the other alias. Only matched, - year-compatible titles render (each card navigates to its detail view). - The rail groups the worker's rows per key itself instead of reusing - `buildTitleMatchIndex`, which collapses to one row per key before the - candidate's year is known — with both `"Dune 1984"` and `"Dune 2021"` - in the catalog the wrong one can win that collapse, and the card is - then dropped by the year check with the right row already discarded. - Year-compatible rows from every alias form one pool ranked by - EVIDENCE, not by which alias found them: a row whose stripped year IS - the candidate's wins — positive evidence for that exact film — then an - untagged row (the shared helper's precedence, and the only tier - reachable when the candidate's year is unknown), then anything else - compatible. Alias order survives only as the tiebreaker inside a tier, - so an ambiguous untagged row under the localized title cannot outrank a - year-tagged row the original-title alias found. Title - collisions are resolved AFTER matching, by the catalog row a candidate - resolved to: same-titled remakes ("Dune" 1984 and 2021) are different - films that must both reach the matcher, while two candidates landing on - one row would otherwise render as duplicate cards opening the same - item. Fewer than + worse failure. Exclusion works through BOTH the localized title and + the TMDB original-title alias (cards always display the localized + form) — catalogs frequently name items in their original language + while the app language localizes the TMDB titles. + + Catalog matching itself is the shared pair described under + "Resolving a batched match": this rail is the caller that passes two + aliases, via `candidateLookup()`. Only matched, year-compatible titles + render, each card navigating to its detail view. What stays local to + the rail is what happens AFTER a row is picked — title collisions are + resolved by the catalog row a candidate resolved to, since same-titled + remakes ("Dune" 1984 and 2021) are different films that must both + reach the matcher, while two candidates landing on one row would + render as duplicate cards opening the same item. Fewer than `MIN_RECOMMENDATION_MATCHES` (5) hides the rail — and resets the latch entirely, because an empty match result is indistinguishable from a transient worker failure (`matchTitles` maps failures to `[]`), 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 0ccf45eeb..760ef4f3b 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 @@ -14,15 +14,12 @@ import { ActorProfile, CatalogTitleMatchService, TmdbEnrichmentService, - buildTitleMatchIndex, + groupTitleMatchesByKey, mapPersonFilmography, mapPersonProfile, + pickTitleMatch, } from '@iptvnator/services'; -import { - CatalogTitleMatch, - normalizeTitleKeys, - titleYearsCompatible, -} from '@iptvnator/shared/interfaces'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import { ActorViewComponent, ActorViewItem, @@ -75,7 +72,7 @@ export class StalkerActorRouteComponent { readonly isMatchingGlobal = signal(false); private readonly globalMatches = signal(null); private readonly globalIndex = computed(() => - buildTitleMatchIndex(this.globalMatches() ?? []) + groupTitleMatchesByKey(this.globalMatches() ?? []) ); readonly items = computed(() => { @@ -155,13 +152,14 @@ export class StalkerActorRouteComponent { private globalMatchFor( credit: ActorFilmographyCredit ): CatalogTitleMatch | null { - const type = credit.mediaType === 'movie' ? 'movie' : 'series'; - const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; - const match = this.globalIndex().get(key) ?? null; - return match && - titleYearsCompatible(credit.year, match.trailingYear) - ? match - : null; + return pickTitleMatch( + { + type: credit.mediaType === 'movie' ? 'movie' : 'series', + titles: [credit.title], + year: credit.year, + }, + this.globalIndex() + ); } private async loadGlobalMatches(): Promise { 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 84c6fd211..6a940bfd4 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 @@ -15,15 +15,12 @@ import { ActorProfile, CatalogTitleMatchService, TmdbEnrichmentService, - buildTitleMatchIndex, + groupTitleMatchesByKey, mapPersonFilmography, mapPersonProfile, + pickTitleMatch, } from '@iptvnator/services'; -import { - CatalogTitleMatch, - normalizeTitleKeys, - titleYearsCompatible, -} from '@iptvnator/shared/interfaces'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import { ActorViewComponent, ActorViewItem, @@ -89,7 +86,7 @@ export class XtreamActorRouteComponent { buildCatalogTitleIndex(this.xtreamStore.serialStreams()) ); private readonly globalIndex = computed(() => - buildTitleMatchIndex(this.globalMatches() ?? []) + groupTitleMatchesByKey(this.globalMatches() ?? []) ); readonly items = computed(() => { @@ -183,13 +180,14 @@ export class XtreamActorRouteComponent { private globalMatchFor( credit: ActorFilmographyCredit ): CatalogTitleMatch | null { - const type = credit.mediaType === 'movie' ? 'movie' : 'series'; - const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; - const match = this.globalIndex().get(key) ?? null; - return match && - titleYearsCompatible(credit.year, match.trailingYear) - ? match - : null; + return pickTitleMatch( + { + type: credit.mediaType === 'movie' ? 'movie' : 'series', + titles: [credit.title], + year: credit.year, + }, + this.globalIndex() + ); } private async loadGlobalMatches(): Promise { diff --git a/libs/services/src/lib/catalog-title-match.service.spec.ts b/libs/services/src/lib/catalog-title-match.service.spec.ts new file mode 100644 index 000000000..eb93acb16 --- /dev/null +++ b/libs/services/src/lib/catalog-title-match.service.spec.ts @@ -0,0 +1,190 @@ +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { + groupTitleMatchesByKey, + pickTitleMatch, +} from './catalog-title-match.service'; + +describe('catalog title matching', () => { + const match = ( + overrides: Partial = {} + ): CatalogTitleMatch => ({ + queryTitle: 'Dune', + playlistId: 'pl-1', + playlistName: 'My Portal', + categoryId: 7, + xtreamId: 1, + type: 'movie', + trailingYear: null, + ...overrides, + }); + + const pick = ( + lookup: { + titles: string[]; + year: number | null; + type?: 'movie' | 'series'; + }, + matches: CatalogTitleMatch[] + ) => + pickTitleMatch( + { type: lookup.type ?? 'movie', ...lookup }, + groupTitleMatchesByKey(matches) + ); + + describe('groupTitleMatchesByKey', () => { + it('keeps every row sharing a key, in worker order', () => { + const first = match({ trailingYear: 1984, xtreamId: 84 }); + const second = match({ trailingYear: 2021, xtreamId: 21 }); + + expect(groupTitleMatchesByKey([first, second])).toEqual( + new Map([['movie:dune', [first, second]]]) + ); + }); + + it('separates the same title across media types', () => { + const movie = match({ type: 'movie' }); + const series = match({ type: 'series' }); + + expect([...groupTitleMatchesByKey([movie, series]).keys()]).toEqual( + ['movie:dune', 'series:dune'] + ); + }); + }); + + describe('pickTitleMatch', () => { + it('prefers the row whose stripped year is the lookup year', () => { + const picked = pick({ titles: ['Dune'], year: 2021 }, [ + match({ trailingYear: 1984, xtreamId: 84 }), + match({ trailingYear: 2021, xtreamId: 21 }), + ]); + + expect(picked?.xtreamId).toBe(21); + }); + + it('prefers the exact-year row over an untagged one', () => { + // An untagged row merely fails to contradict the lookup; a row + // carrying the year is positive evidence for this exact film. + const picked = pick({ titles: ['Dune'], year: 2021 }, [ + match({ trailingYear: null, xtreamId: 7 }), + match({ trailingYear: 2021, xtreamId: 21 }), + ]); + + expect(picked?.xtreamId).toBe(21); + }); + + it('prefers an untagged row over a merely year-compatible one', () => { + // No row states 2021. An untagged "Dune" does not contradict + // the lookup at all, while a "Dune 2020" only survives on the + // one-year drift — so the untagged row outranks it. + const picked = pick({ titles: ['Dune'], year: 2021 }, [ + match({ trailingYear: 2020, xtreamId: 20 }), + match({ trailingYear: null, xtreamId: 7 }), + ]); + + expect(picked?.xtreamId).toBe(7); + }); + + it('prefers an untagged row when the lookup year is unknown', () => { + const picked = pick({ titles: ['Dune'], year: null }, [ + match({ trailingYear: 1984, xtreamId: 84 }), + match({ trailingYear: null, xtreamId: 7 }), + ]); + + expect(picked?.xtreamId).toBe(7); + }); + + it('rejects every year-incompatible row', () => { + expect( + pick({ titles: ['Blade Runner'], year: 1982 }, [ + match({ queryTitle: 'Blade Runner', trailingYear: 2049 }), + ]) + ).toBeNull(); + }); + + it('accepts a one-year drift', () => { + expect( + pick({ titles: ['Dune'], year: 2021 }, [ + match({ trailingYear: 2020, xtreamId: 20 }), + ])?.xtreamId + ).toBe(20); + }); + + it('does not match across media types', () => { + expect( + pick({ titles: ['Dune'], year: 2021, type: 'series' }, [ + match({ type: 'movie', trailingYear: 2021 }), + ]) + ).toBeNull(); + }); + + it('ranks by evidence across aliases, not by alias order', () => { + // The localized title finds only an untagged row while the + // original-title alias holds the row carrying the lookup's own + // year — the better evidence must win regardless of which + // alias found it. + const picked = pick( + { titles: ['The Hunt', 'Jagten'], year: 2012 }, + [ + match({ + queryTitle: 'The Hunt', + trailingYear: null, + xtreamId: 1, + }), + match({ + queryTitle: 'Jagten', + trailingYear: 2012, + xtreamId: 2, + }), + ] + ); + + expect(picked?.xtreamId).toBe(2); + }); + + it('falls back to the alias when the first alias is incompatible', () => { + const picked = pick( + { titles: ['The Hunt', 'Jagten'], year: 2012 }, + [ + match({ + queryTitle: 'The Hunt', + trailingYear: 2020, + xtreamId: 1, + }), + match({ + queryTitle: 'Jagten', + trailingYear: null, + xtreamId: 2, + }), + ] + ); + + expect(picked?.xtreamId).toBe(2); + }); + + it('breaks ties inside a tier by alias order', () => { + const picked = pick( + { titles: ['The Hunt', 'Jagten'], year: 2012 }, + [ + match({ + queryTitle: 'Jagten', + trailingYear: null, + xtreamId: 2, + }), + match({ + queryTitle: 'The Hunt', + trailingYear: null, + xtreamId: 1, + }), + ] + ); + + expect(picked?.xtreamId).toBe(1); + }); + + it('returns null when nothing matched the title at all', () => { + expect( + pick({ titles: ['Unmatched'], year: null }, [match()]) + ).toBeNull(); + }); + }); +}); diff --git a/libs/services/src/lib/catalog-title-match.service.ts b/libs/services/src/lib/catalog-title-match.service.ts index a7cfa90c3..f8cab5a60 100644 --- a/libs/services/src/lib/catalog-title-match.service.ts +++ b/libs/services/src/lib/catalog-title-match.service.ts @@ -2,28 +2,93 @@ import { Injectable } from '@angular/core'; import { CatalogTitleMatch, normalizeTitleKeys, + titleYearsCompatible, } from '@iptvnator/shared/interfaces'; +/** What one caller is looking for, in catalog-match terms */ +export interface CatalogTitleLookup { + readonly type: 'movie' | 'series'; + /** + * Title aliases to try, most-trusted first. Usually a single title; a + * caller that also knows an original-language title passes it second, + * since a catalog often names an item in its original language while + * the app language localizes the TMDB title. + */ + readonly titles: readonly string[]; + /** Release year, or null when unknown — unknown never rejects a row */ + readonly year: number | null; +} + /** - * 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. + * EVERY match per `type:exactNormalizedTitle`, in the order the worker + * returned them. + * + * Deliberately not collapsed to one row per key: the year that decides + * between same-titled rows belongs to the LOOKUP, which this function + * cannot see. A catalog holding both "Dune 1984" and "Dune 2021" would + * otherwise keep whichever arrived first, and a 2021 lookup would then + * fail its year check with the right row already discarded — rendering + * as "not in your library" for a movie the library does hold. */ -export function buildTitleMatchIndex( +export function groupTitleMatchesByKey( matches: readonly CatalogTitleMatch[] -): Map { - const index = new Map(); +): Map { + const grouped = new Map(); for (const match of matches) { 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); + const rows = grouped.get(key); + if (rows) { + rows.push(match); + } else { + grouped.set(key, [match]); } } - return index; + return grouped; +} + +/** + * The catalog row this lookup should resolve to, or null. + * + * Aliases are tried in order, but ranking is by EVIDENCE across all of + * them at once — a bad hit under the first alias must not veto a good one + * under the second. A row whose stripped year IS the lookup's wins: that + * is positive evidence for this exact film, while an untagged row merely + * fails to contradict one ("Dune" could be either cut, so resolving a + * 2021 lookup to it when "Dune 2021" also exists throws better evidence + * away). Untagged rows come next — the only tier reachable when the + * lookup's own year is unknown — then anything else year-compatible. + * Alias order survives as the tiebreaker within a tier. + */ +export function pickTitleMatch( + lookup: CatalogTitleLookup, + grouped: ReadonlyMap +): CatalogTitleMatch | null { + const compatible: CatalogTitleMatch[] = []; + const seenKeys = new Set(); + for (const title of lookup.titles) { + const key = `${lookup.type}:${normalizeTitleKeys(title).exact}`; + // Aliases routinely normalize to one key (TMDB repeats the title + // as original_title for English-language items). Re-scanning it + // cannot change the pick, so this only skips the wasted pass. + if (seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + for (const row of grouped.get(key) ?? []) { + if (titleYearsCompatible(lookup.year, row.trailingYear)) { + compatible.push(row); + } + } + } + + return ( + (lookup.year !== null + ? compatible.find((row) => row.trailingYear === lookup.year) + : undefined) ?? + compatible.find((row) => row.trailingYear === null) ?? + compatible[0] ?? + null + ); } /** diff --git a/libs/services/src/lib/cross-portal-similar.service.spec.ts b/libs/services/src/lib/cross-portal-similar.service.spec.ts index 81a48bf76..a3d2db210 100644 --- a/libs/services/src/lib/cross-portal-similar.service.spec.ts +++ b/libs/services/src/lib/cross-portal-similar.service.spec.ts @@ -98,6 +98,22 @@ describe('CrossPortalSimilarService', () => { expect(items[0].match.playlistId).toBe('other'); }); + it('picks the year-compatible copy when one playlist holds several', async () => { + matchTitles.mockResolvedValue([ + match({ queryTitle: 'Dune', trailingYear: 1984, xtreamId: 84 }), + match({ queryTitle: 'Dune', trailingYear: 2021, xtreamId: 21 }), + ]); + const service = createService(); + + const items = await service.matchRecommendations( + [rec('Dune', 2021)], + 'movie' + ); + + expect(items).toHaveLength(1); + expect(items[0].match.xtreamId).toBe(21); + }); + it('drops type mismatches and year-incompatible matches', async () => { matchTitles.mockResolvedValue([ match({ type: 'series' }), diff --git a/libs/services/src/lib/cross-portal-similar.service.ts b/libs/services/src/lib/cross-portal-similar.service.ts index 73081bc83..ca6068b56 100644 --- a/libs/services/src/lib/cross-portal-similar.service.ts +++ b/libs/services/src/lib/cross-portal-similar.service.ts @@ -2,12 +2,11 @@ import { Injectable, inject } from '@angular/core'; import { CatalogTitleMatch, TmdbRecommendation, - normalizeTitleKeys, - titleYearsCompatible, } from '@iptvnator/shared/interfaces'; import { CatalogTitleMatchService, - buildTitleMatchIndex, + groupTitleMatchesByKey, + pickTitleMatch, } from './catalog-title-match.service'; /** One TMDB recommendation found in an imported Xtream playlist */ @@ -48,9 +47,9 @@ export class CrossPortalSimilarService { const matches = await this.titleMatch.matchTitles( recommendations.map((recommendation) => recommendation.title) ); - // Filter before indexing so a title also present in another + // Filter before grouping so a title also present in another // playlist survives the exclusion of the current one - const index = buildTitleMatchIndex( + const grouped = groupTitleMatchesByKey( matches.filter( (match) => match.type === type && @@ -65,12 +64,15 @@ export class CrossPortalSimilarService { if (items.length >= limit) { break; } - const key = `${type}:${normalizeTitleKeys(recommendation.title).exact}`; - const match = index.get(key); - if ( - !match || - !titleYearsCompatible(recommendation.year, match.trailingYear) - ) { + const match = pickTitleMatch( + { + type, + titles: [recommendation.title], + year: recommendation.year, + }, + grouped + ); + if (!match) { continue; } const dedupeKey = `${match.playlistId}:${match.type}:${match.xtreamId}`; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts index 3de79c66d..e92a6798a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -92,7 +92,7 @@ describe('DashboardRecommendationsService', () => { }, }); // The catalog rows a tv candidate can match are series rows — - // buildTitleMatchIndex keys them by the match's own type. + // groupTitleMatchesByKey keys them by the match's own type. matchTitles.mockImplementation(async (titles: string[]) => titles.map((title) => match(title, { type: 'series' })) ); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts index 223b3a47d..52f122b9d 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts @@ -2,6 +2,8 @@ import { Injectable, inject, signal } from '@angular/core'; import { CatalogTitleMatchService, TmdbEnrichmentService, + groupTitleMatchesByKey, + pickTitleMatch, } from '@iptvnator/services'; import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; import { DashboardDataService } from './dashboard-data.service'; @@ -15,9 +17,8 @@ import { ExclusionIndex, RecommendationCandidate, buildLoadKey, - groupMatchesByKey, + candidateLookup, isExcludedCandidate, - pickCatalogMatch, toCandidates, trustedReleaseYear, } from './dashboard-recommendations.util'; @@ -423,7 +424,7 @@ export class DashboardRecommendationsService { } } const matches = await this.titleMatch.matchTitles(queryTitles); - const grouped = groupMatchesByKey(matches); + const grouped = groupTitleMatchesByKey(matches); // Title collisions are resolved HERE rather than before matching: // two candidates that resolve to the same catalog row would render @@ -432,7 +433,7 @@ export class DashboardRecommendationsService { const items: DashboardRecommendationItem[] = []; const claimedRows = new Set(); for (const candidate of candidates) { - const match = pickCatalogMatch(candidate, grouped); + const match = pickTitleMatch(candidateLookup(candidate), grouped); if (!match) { continue; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts index 8dc85eec4..55eea98b7 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -1,5 +1,5 @@ import { extractYear, tmdbPosterUrl } from '@iptvnator/services'; -import type { TmdbSearchResult } from '@iptvnator/services'; +import type { CatalogTitleLookup, TmdbSearchResult } from '@iptvnator/services'; import { CatalogTitleMatch, normalizeTitleKeys, @@ -33,7 +33,10 @@ export interface DashboardRecommendationItem { seedTitle: string; } -export type RecommendationCandidate = Omit; +export type RecommendationCandidate = Omit< + DashboardRecommendationItem, + 'match' +>; /** * The release year an activity row STATES in a metadata field, never one @@ -88,9 +91,7 @@ interface CandidateKeys { * Both matching tiers for each of the candidate's aliases — localized * title first, original-title alias second. */ -function candidateKeySets( - candidate: RecommendationCandidate -): CandidateKeys[] { +function candidateKeySets(candidate: RecommendationCandidate): CandidateKeys[] { const type = candidate.mediaType === 'movie' ? 'movie' : 'series'; const toKeys = (title: string): CandidateKeys => { const keys = normalizeTitleKeys(title); @@ -107,76 +108,28 @@ function candidateKeySets( return sets; } -/** Exact-tier keys only — used for dedupe and catalog-match lookup */ -function candidateTitleKeys(candidate: RecommendationCandidate): string[] { - return candidateKeySets(candidate).map((keys) => keys.exact); -} - /** - * EVERY match per `type:exactNormalizedTitle`, in the order the worker - * returned them. + * The candidate as a catalog lookup, localized title first and the + * original-title alias second. * - * Deliberately not the shared `buildTitleMatchIndex`: that collapses to - * one row per key before the candidate's year is known, so a catalog - * holding both "Dune 1984" and "Dune 2021" keeps whichever arrived first - * and a 2021 recommendation then fails the year check with the right row - * already discarded. Keeping every row lets the year gate choose. + * The grouping and ranking themselves are the shared + * `groupTitleMatchesByKey()` / `pickTitleMatch()` + * (`@iptvnator/services`) — the Trending rail, the cross-portal Similar + * rail and the actor pages resolve a batched `DB_MATCH_TITLES` result the + * same way, and this rail is just the one that also carries an alias. + * `pickTitleMatch` folds aliases that normalize to one key, so passing + * both unconditionally is safe. */ -export function groupMatchesByKey( - matches: readonly CatalogTitleMatch[] -): Map { - const grouped = new Map(); - for (const match of matches) { - const key = `${match.type}:${normalizeTitleKeys(match.queryTitle).exact}`; - grouped.set(key, [...(grouped.get(key) ?? []), match]); - } - return grouped; -} - -/** - * The catalog row this recommendation should link to, or null. - * - * Aliases are tried in order (localized title, then original-title), and - * only year-compatible rows qualify — a localized title can hit a - * same-named different-year row while the alias holds the correct match, - * so a bad hit must not veto the good one. - * - * Ranking is by EVIDENCE, across every alias at once. A row whose - * stripped year IS the candidate's wins: that is positive evidence for - * this exact film, while a row with no year is merely not contradicting - * one ("Dune" could be either cut, so linking a 2021 recommendation to it - * when "Dune 2021" also exists throws the better evidence away — and that - * holds whichever alias found which). Untagged rows come next — mirroring - * `buildTitleMatchIndex`'s precedence, and the only tier reachable when - * the candidate's own year is unknown — then anything else compatible. - * Alias order survives only as the tiebreaker inside a tier. - */ -export function pickCatalogMatch( - candidate: RecommendationCandidate, - grouped: ReadonlyMap -): CatalogTitleMatch | null { - // Every alias contributes to one pool, ranked by evidence rather than - // by which alias found it: an untagged row under the localized title - // must not outrank a row the original-title alias found carrying the - // candidate's own year. Rows enter in alias order, so `find` still - // breaks ties the old way — localized first. - const compatible: CatalogTitleMatch[] = []; - for (const key of candidateTitleKeys(candidate)) { - for (const row of grouped.get(key) ?? []) { - if (titleYearsCompatible(candidate.year, row.trailingYear)) { - compatible.push(row); - } - } - } - - return ( - (candidate.year !== null - ? compatible.find((row) => row.trailingYear === candidate.year) - : undefined) ?? - compatible.find((row) => row.trailingYear === null) ?? - compatible[0] ?? - null - ); +export function candidateLookup( + candidate: RecommendationCandidate +): CatalogTitleLookup { + return { + type: candidate.mediaType === 'movie' ? 'movie' : 'series', + titles: candidate.originalTitle + ? [candidate.title, candidate.originalTitle] + : [candidate.title], + year: candidate.year, + }; } /** diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts index 9f8c1595b..c55447a92 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts @@ -107,6 +107,24 @@ describe('DashboardTrendingService', () => { expect(service.items()[0].match).toBeNull(); }); + it('picks the year-compatible row when the catalog holds several', async () => { + // Two year-stripped rows share one key; the wrong one arrives + // first. Collapsing before the year check would discard the right + // one and render the card as "not in your library". + getTrendingWeek.mockResolvedValue([ + entry({ title: 'Dune', year: 2021 }), + ]); + matchTitles.mockResolvedValue([ + match({ queryTitle: 'Dune', trailingYear: 1984, xtreamId: 84 }), + match({ queryTitle: 'Dune', trailingYear: 2021, xtreamId: 21 }), + ]); + const service = createService(); + + await service.load(); + + expect(service.items()[0].match?.xtreamId).toBe(21); + }); + it('loads only once per session after a successful load', async () => { const service = createService(); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts index a67e387d1..11824e065 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts @@ -2,13 +2,10 @@ import { Injectable, inject, signal } from '@angular/core'; import { CatalogTitleMatchService, TmdbEnrichmentService, - buildTitleMatchIndex, + groupTitleMatchesByKey, + pickTitleMatch, } from '@iptvnator/services'; -import { - CatalogTitleMatch, - normalizeTitleKeys, - titleYearsCompatible, -} from '@iptvnator/shared/interfaces'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import type { TmdbTrendingEntry } from '@iptvnator/services'; /** One trending card: TMDB entry + optional library match for navigation */ @@ -58,12 +55,12 @@ export class DashboardTrendingService { const matches = await this.titleMatch.matchTitles( entries.map((entry) => entry.title) ); - const index = buildTitleMatchIndex(matches); + const grouped = groupTitleMatchesByKey(matches); this.items.set( entries.map((entry) => ({ ...entry, - match: this.matchFor(entry, index), + match: this.matchFor(entry, grouped), })) ); this.loadedOnce = true; @@ -76,13 +73,15 @@ export class DashboardTrendingService { private matchFor( entry: TmdbTrendingEntry, - index: ReadonlyMap + grouped: ReadonlyMap ): CatalogTitleMatch | null { - const type = entry.mediaType === 'movie' ? 'movie' : 'series'; - const key = `${type}:${normalizeTitleKeys(entry.title).exact}`; - const match = index.get(key) ?? null; - return match && titleYearsCompatible(entry.year, match.trailingYear) - ? match - : null; + return pickTitleMatch( + { + type: entry.mediaType === 'movie' ? 'movie' : 'series', + titles: [entry.title], + year: entry.year, + }, + grouped + ); } }