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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 10:36:10 +02:00
1 parent 0bd02deb4f
commit 3f0277091d
5 files changed
+121 -13

No files matched your search

@@ -78,6 +78,7 @@ export class StalkerDiscoverRouteComponent {
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(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<void> {
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<void> {
private async loadDiscover(): Promise<void> {
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 ?? []);
@@ -86,6 +86,7 @@ export class XtreamDiscoverRouteComponent {
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(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<void> {
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<void> {
private async loadDiscover(): Promise<void> {
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 ?? []);
@@ -91,6 +91,7 @@ describe('TmdbDiscoverService', () => {
tmdbId: 100,
mediaType: 'movie',
title: 'Movie 100',
originalTitle: null,
year: 1990,
posterUrl: null,
});
@@ -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]);
});
});
@@ -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),
});