fix(dashboard): keep every catalog row per title key when matching (#1425)

This commit is contained in:
4gray authored and GitHub committed 2026-08-13 07:31:59 +02:00
1 parent e3f72f7dce
commit 0cba49f3e2
15 files changed
+446 -173

No files matched your search

@@ -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.
+1
View File
@@ -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:<id>` 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)
@@ -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
+48 -25
View File
@@ -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 `[]`),
@@ -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<CatalogTitleMatch[] | null>(null);
private readonly globalIndex = computed(() =>
buildTitleMatchIndex(this.globalMatches() ?? [])
groupTitleMatchesByKey(this.globalMatches() ?? [])
);
readonly items = computed<ActorViewItem[]>(() => {
@@ -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<void> {
@@ -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<ActorViewItem[]>(() => {
@@ -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<void> {
@@ -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> = {}
): 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();
});
});
});
@@ -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<string, CatalogTitleMatch> {
const index = new Map<string, CatalogTitleMatch>();
): Map<string, CatalogTitleMatch[]> {
const grouped = new Map<string, CatalogTitleMatch[]>();
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<string, CatalogTitleMatch[]>
): CatalogTitleMatch | null {
const compatible: CatalogTitleMatch[] = [];
const seenKeys = new Set<string>();
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
);
}
/**
@@ -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' }),
@@ -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}`;
@@ -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' }))
);
@@ -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<string>();
for (const candidate of candidates) {
const match = pickCatalogMatch(candidate, grouped);
const match = pickTitleMatch(candidateLookup(candidate), grouped);
if (!match) {
continue;
}
@@ -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<DashboardRecommendationItem, 'match'>;
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<string, CatalogTitleMatch[]> {
const grouped = new Map<string, CatalogTitleMatch[]>();
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<string, CatalogTitleMatch[]>
): 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,
};
}
/**
@@ -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();
@@ -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<string, CatalogTitleMatch>
grouped: ReadonlyMap<string, CatalogTitleMatch[]>
): 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
);
}
}