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

@@ -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
);
}
}