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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
+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