mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
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),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user