mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(dashboard): keep every catalog row per title key when matching (#1425)
This commit is contained in:
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}`;
|
||||
|
||||
+1
-1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user