From 3f0277091db120bc91e72e0460070a488a4aa6e7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Aug 2026 10:36:10 +0200 Subject: [PATCH] fix(portals): match Discover results by original title, guard stale loads - `/discover` returns titles localized to the app language while the provider catalog stores whatever the panel named the file, usually the original. Discarding `original_title`/`original_name` marked owned titles unavailable and sent the click to a search for the wrong name. Results carry the alias now, and both local and cross-playlist matching pass it the way the recommendations rail already does. - A facet change to B and back to A leaves two in-flight loads with the SAME key, so the key could not tell them apart: an older request failing after the newer one succeeded replaced valid results with an empty page. Recency decides who may commit, via the same request guard the matching path uses. Co-Authored-By: Claude Fable 5 --- .../lib/stalker-discover-route.component.ts | 28 ++++++++-- .../xtream-discover-route.component.ts | 37 ++++++++++--- .../lib/tmdb/tmdb-discover.service.spec.ts | 1 + .../src/lib/tmdb/tmdb-discover.spec.ts | 55 +++++++++++++++++++ libs/services/src/lib/tmdb/tmdb-discover.ts | 13 +++++ 5 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 libs/services/src/lib/tmdb/tmdb-discover.spec.ts diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts index d5d8cb6b3..049420343 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -78,6 +78,7 @@ export class StalkerDiscoverRouteComponent { readonly isMatchingGlobal = signal(false); private readonly globalMatches = signal(null); private readonly matchRequest = createLatestRequestGuard(); + private readonly discoverRequest = createLatestRequestGuard(); private readonly globalIndex = computed(() => groupTitleMatchesByKey(this.globalMatches() ?? []) ); @@ -102,8 +103,8 @@ export class StalkerDiscoverRouteComponent { constructor() { effect(() => { - const key = this.facetKey(); - void this.loadDiscover(key); + this.facetKey(); + void this.loadDiscover(); }); } @@ -158,7 +159,11 @@ export class StalkerDiscoverRouteComponent { return pickTitleMatch( { type: title.mediaType === 'movie' ? 'movie' : 'series', - titles: [title.title], + // The localized title first, then TMDB's original: the + // catalog stores whatever the panel named the file + titles: title.originalTitle + ? [title.title, title.originalTitle] + : [title.title], year: title.year, }, this.globalIndex() @@ -167,7 +172,14 @@ export class StalkerDiscoverRouteComponent { private async loadGlobalMatches(): Promise { const requestedKey = this.facetKey(); - const titles = this.results().map((title) => title.title); + // Both names go to the worker so its FTS can hit either one + const titles: string[] = []; + for (const result of this.results()) { + titles.push(result.title); + if (result.originalTitle) { + titles.push(result.originalTitle); + } + } const matchToken = this.matchRequest.start(); this.isMatchingGlobal.set(true); try { @@ -188,8 +200,12 @@ export class StalkerDiscoverRouteComponent { } } - private async loadDiscover(requestedKey: string): Promise { + private async loadDiscover(): Promise { const facets = this.facets(); + // A facet change to B and back to A leaves two in-flight requests + // with the SAME key, so recency — not the key — decides who may + // commit: otherwise the older one's failure blanks valid results + const token = this.discoverRequest.start(); this.isLoading.set(true); this.globalMatches.set(null); if (!hasDiscoverFacet(facets)) { @@ -202,7 +218,7 @@ export class StalkerDiscoverRouteComponent { genreId: facets.genreId, countryCode: facets.countryCode, }); - if (this.facetKey() !== requestedKey) { + if (!this.discoverRequest.isLatest(token)) { return; } this.results.set(titles ?? []); diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts index 54221a42a..a6b8afc2b 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -86,6 +86,7 @@ export class XtreamDiscoverRouteComponent { readonly isMatchingGlobal = signal(false); private readonly globalMatches = signal(null); private readonly matchRequest = createLatestRequestGuard(); + private readonly discoverRequest = createLatestRequestGuard(); private readonly vodIndex = computed(() => buildCatalogTitleIndex(this.xtreamStore.vodStreams()) @@ -117,8 +118,8 @@ export class XtreamDiscoverRouteComponent { constructor() { effect(() => { - const key = this.facetKey(); - void this.loadDiscover(key); + this.facetKey(); + void this.loadDiscover(); }); } @@ -180,14 +181,25 @@ export class XtreamDiscoverRouteComponent { private portalMatchFor(title: DiscoverTitle) { const index = title.mediaType === 'movie' ? this.vodIndex() : this.serialIndex(); - return lookupCatalogTitle(index, title.title, title.year); + // The localized title first, then TMDB's original: the catalog + // stores whatever the panel named the file + return ( + lookupCatalogTitle(index, title.title, title.year) ?? + (title.originalTitle + ? lookupCatalogTitle(index, title.originalTitle, title.year) + : null) + ); } private globalMatchFor(title: DiscoverTitle): CatalogTitleMatch | null { return pickTitleMatch( { type: title.mediaType === 'movie' ? 'movie' : 'series', - titles: [title.title], + // The localized title first, then TMDB's original: the + // catalog stores whatever the panel named the file + titles: title.originalTitle + ? [title.title, title.originalTitle] + : [title.title], year: title.year, }, this.globalIndex() @@ -196,7 +208,14 @@ export class XtreamDiscoverRouteComponent { private async loadGlobalMatches(): Promise { const requestedKey = this.facetKey(); - const titles = this.results().map((title) => title.title); + // Both names go to the worker so its FTS can hit either one + const titles: string[] = []; + for (const result of this.results()) { + titles.push(result.title); + if (result.originalTitle) { + titles.push(result.originalTitle); + } + } const matchToken = this.matchRequest.start(); this.isMatchingGlobal.set(true); try { @@ -217,8 +236,12 @@ export class XtreamDiscoverRouteComponent { } } - private async loadDiscover(requestedKey: string): Promise { + private async loadDiscover(): Promise { const facets = this.facets(); + // A facet change to B and back to A leaves two in-flight requests + // with the SAME key, so recency — not the key — decides who may + // commit: otherwise the older one's failure blanks valid results + const token = this.discoverRequest.start(); this.isLoading.set(true); this.globalMatches.set(null); if (!hasDiscoverFacet(facets)) { @@ -231,7 +254,7 @@ export class XtreamDiscoverRouteComponent { genreId: facets.genreId, countryCode: facets.countryCode, }); - if (this.facetKey() !== requestedKey) { + if (!this.discoverRequest.isLatest(token)) { return; } this.results.set(titles ?? []); diff --git a/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts index b91afc6bb..0fae1efed 100644 --- a/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts @@ -91,6 +91,7 @@ describe('TmdbDiscoverService', () => { tmdbId: 100, mediaType: 'movie', title: 'Movie 100', + originalTitle: null, year: 1990, posterUrl: null, }); diff --git a/libs/services/src/lib/tmdb/tmdb-discover.spec.ts b/libs/services/src/lib/tmdb/tmdb-discover.spec.ts new file mode 100644 index 000000000..d4616064d --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.spec.ts @@ -0,0 +1,55 @@ +import { mapDiscoverResults } from './tmdb-discover'; + +describe('mapDiscoverResults', () => { + it('keeps the original title as a matching alias', () => { + const [movie] = mapDiscoverResults( + [ + { + id: 1, + title: 'Ирония судьбы', + original_title: 'Ирония судьбы, или С лёгким паром!', + release_date: '1976-01-01', + }, + ], + 'movie' + ); + + // TMDB localizes `title` while the catalog stores whatever the + // panel named the file, which is often the original + expect(movie.title).toBe('Ирония судьбы'); + expect(movie.originalTitle).toBe( + 'Ирония судьбы, или С лёгким паром!' + ); + }); + + it('reads the original name for tv results', () => { + const [show] = mapDiscoverResults( + [{ id: 2, name: 'Тьма', original_name: 'Dark' }], + 'tv' + ); + + expect(show.originalTitle).toBe('Dark'); + }); + + it('has no alias when the original matches the localized title', () => { + const [movie] = mapDiscoverResults( + [{ id: 3, title: 'Dune', original_title: 'Dune' }], + 'movie' + ); + + expect(movie.originalTitle).toBeNull(); + }); + + it('drops untitled results and deduplicates by id', () => { + const mapped = mapDiscoverResults( + [ + { id: 4, title: 'Alien' }, + { id: 4, title: 'Alien' }, + { id: 5, title: ' ' }, + ], + 'movie' + ); + + expect(mapped.map((entry) => entry.tmdbId)).toEqual([4]); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-discover.ts b/libs/services/src/lib/tmdb/tmdb-discover.ts index 2a67da2ab..2d3b14ec9 100644 --- a/libs/services/src/lib/tmdb/tmdb-discover.ts +++ b/libs/services/src/lib/tmdb/tmdb-discover.ts @@ -11,6 +11,12 @@ export interface DiscoverTitle { tmdbId: number; mediaType: 'movie' | 'tv'; title: string; + /** + * TMDB's original-language title, kept as a matching alias: `title` + * is localized to the app language while the provider catalog stores + * whatever the panel named the file, which is often the original. + */ + originalTitle: string | null; year: number | null; posterUrl: string | null; } @@ -33,10 +39,17 @@ export function mapDiscoverResults( continue; } seen.add(result.id); + const originalTitle = ( + result.original_title ?? + result.original_name ?? + '' + ).trim(); titles.push({ tmdbId: result.id, mediaType, title, + originalTitle: + originalTitle && originalTitle !== title ? originalTitle : null, year: extractYear(result.release_date ?? result.first_air_date), posterUrl: tmdbPosterUrl(result.poster_path), });