fix(xtream): isolate reused detail identities

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 6500807701
commit 1d996b85f0
8 files changed
+345 -23

No files matched your search

@@ -132,6 +132,37 @@ describe('enrichVodSelectionWithTmdb', () => {
expect(store.setSelectedItem).not.toHaveBeenCalled();
});
it('drops a same-id result after its playlist request becomes stale', async () => {
let isCurrentRequest = true;
const store = createStore(vodItem);
const enrichment = createEnrichment({
enrichMovie: jest.fn().mockImplementation(async () => {
isCurrentRequest = false;
store.replaceItem({
...vodItem,
info: {
...vodItem.info,
name: 'Movie from playlist B',
plot: 'Playlist B plot',
},
});
return { id: 603, overview: 'Playlist A TMDB overview' };
}),
} as Partial<TmdbEnrichmentService>);
await enrichVodSelectionWithTmdb(
store,
enrichment,
'42',
() => isCurrentRequest
);
expect(store.setSelectedItem).not.toHaveBeenCalled();
expect(store.selectedItem()?.['info']).toEqual(
expect.objectContaining({ plot: 'Playlist B plot' })
);
});
it('keeps provider data when no confident match was found', async () => {
const store = createStore(vodItem);
const enrichment = createEnrichment();
@@ -174,6 +205,37 @@ describe('enrichSerialSelectionWithTmdb', () => {
expect(enrichment.enrichTv).not.toHaveBeenCalled();
});
it('drops a same-id result after its playlist request becomes stale', async () => {
let isCurrentRequest = true;
const store = createStore(serialItem);
const enrichment = createEnrichment({
enrichTv: jest.fn().mockImplementation(async () => {
isCurrentRequest = false;
store.replaceItem({
...serialItem,
info: {
...serialItem.info,
name: 'Series from playlist B',
plot: 'Playlist B plot',
},
});
return { id: 70523, overview: 'Playlist A TMDB overview' };
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSelectionWithTmdb(
store,
enrichment,
'7',
() => isCurrentRequest
);
expect(store.setSelectedItem).not.toHaveBeenCalled();
expect(store.selectedItem()?.['info']).toEqual(
expect.objectContaining({ plot: 'Playlist B plot' })
);
});
});
describe('enrichSerialSeasonWithTmdb', () => {
@@ -250,4 +312,41 @@ describe('enrichSerialSeasonWithTmdb', () => {
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
});
it('drops a same-id season result after its playlist becomes stale', async () => {
let isCurrentPlaylist = true;
const store = createStore(
seasonSliceItem('Series from playlist A', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockImplementation(async () => {
isCurrentPlaylist = false;
store.replaceItem(
seasonSliceItem('Series from playlist B', {
'1': [{ episode_num: 1, season: 1 }],
})
);
return [{ episode_number: 1, name: 'Playlist A episode' }];
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(
store,
enrichment,
'1',
() => isCurrentPlaylist
);
expect(store.setSelectedItem).not.toHaveBeenCalled();
expect(
(
store.selectedItem()?.['episodes'] as Record<
string,
{ title: string }[]
>
)['1'][0].title
).toBe('Episode 1');
});
});
@@ -32,13 +32,17 @@ type SelectionRecord = { readonly [key: string]: unknown };
export async function enrichVodSelectionWithTmdb<TItem extends SelectionRecord>(
store: EnrichableSelectionStore<TItem>,
enrichment: TmdbEnrichmentService,
vodId: string | number
vodId: string | number,
isCurrentRequest: () => boolean = () => true
): Promise<void> {
if (!enrichment.isEnabled()) {
return;
}
const isCurrent = (): TItem | null => {
if (!isCurrentRequest()) {
return null;
}
const item = store.selectedItem();
return item && String(item['stream_id']) === String(vodId)
? item
@@ -87,13 +91,17 @@ export async function enrichSerialSelectionWithTmdb<
>(
store: EnrichableSelectionStore<TItem>,
enrichment: TmdbEnrichmentService,
serialId: string | number
serialId: string | number,
isCurrentRequest: () => boolean = () => true
): Promise<void> {
if (!enrichment.isEnabled()) {
return;
}
const isCurrent = (): TItem | null => {
if (!isCurrentRequest()) {
return null;
}
const item = store.selectedItem();
return item && String(item['series_id']) === String(serialId)
? item
@@ -136,20 +144,18 @@ export async function enrichSerialSelectionWithTmdb<
* enrichSerialSelectionWithTmdb). Merges real episode names, overviews and
* stills into `episodes[seasonKey]` by episode number.
*/
export async function enrichSerialSeasonWithTmdb<
TItem extends SelectionRecord,
>(
export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
store: EnrichableSelectionStore<TItem>,
enrichment: TmdbEnrichmentService,
seasonKey: string
seasonKey: string,
isCurrentSelection: () => boolean = () => true
): Promise<void> {
if (!enrichment.isEnabled()) {
if (!enrichment.isEnabled() || !isCurrentSelection()) {
return;
}
const selected = store.selectedItem() as unknown as
| (XtreamSerieDetails & { series_id?: string | number })
| null;
(XtreamSerieDetails & { series_id?: string | number }) | null;
const serialId = selected?.series_id;
const info = selected?.info;
const episodes = selected?.episodes?.[seasonKey];
@@ -180,13 +186,12 @@ export async function enrichSerialSeasonWithTmdb<
info.tmdb_id,
seasonNumber
);
if (!tmdbEpisodes?.length) {
if (!tmdbEpisodes?.length || !isCurrentSelection()) {
return;
}
const current = store.selectedItem() as unknown as
| (XtreamSerieDetails & { series_id?: string | number })
| null;
(XtreamSerieDetails & { series_id?: string | number }) | null;
const currentEpisodes = current?.episodes?.[seasonKey];
if (
!current ||
@@ -57,6 +57,7 @@ async function waitForCondition(
function configureStore(
xtreamApiService: {
getSeriesInfo?: jest.Mock;
getVodInfo: jest.Mock;
getVodStream: jest.Mock;
},
@@ -240,4 +241,112 @@ describe('XtreamStore VOD details recovery', () => {
expect(store.detailsError()).toBeNull();
expect(store.isLoadingDetails()).toBe(false);
});
it('clears the previous movie while a reused-route request is pending or fails', async () => {
const nextDetails = createDeferred<never>();
const xtreamApiService = {
getVodInfo: jest.fn(() => nextDetails.promise),
getVodStream: jest.fn(),
};
const store = configureStore(xtreamApiService);
store.setSelectedItem({
info: { name: 'Movie A' },
stream_id: 42,
xtream_id: 42,
});
store.fetchVodDetailsWithMetadata({
categoryId: 7,
vodId: '99',
});
expect(store.selectedItem()).toBeNull();
nextDetails.reject(new Error('Movie B unavailable'));
await waitForCondition(
() => store.detailsError() === 'Movie B unavailable'
);
expect(store.selectedItem()).toBeNull();
expect(store.isLoadingDetails()).toBe(false);
});
it('keeps the newer movie authoritative when an older base request resolves late', async () => {
const firstDetails = createDeferred<{
container_extension: string;
info: { name: string };
stream_id: number;
}>();
const xtreamApiService = {
getVodInfo: jest.fn((_credentials: unknown, vodId: string) =>
vodId === '42'
? firstDetails.promise
: Promise.resolve({
info: { name: 'Movie B' },
stream_id: 99,
container_extension: 'mp4',
})
),
getVodStream: jest.fn(),
};
const store = configureStore(xtreamApiService);
store.fetchVodDetailsWithMetadata({
categoryId: 7,
vodId: '42',
});
store.fetchVodDetailsWithMetadata({
categoryId: 7,
vodId: '99',
});
await waitForCondition(
() =>
resolveXtreamVodPlaybackSource(store.selectedItem())
?.streamId === 99
);
firstDetails.resolve({
info: { name: 'Movie A' },
stream_id: 42,
container_extension: 'mkv',
});
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({
streamId: 99,
containerExtension: 'mp4',
});
expect(store.detailsError()).toBeNull();
expect(store.isLoadingDetails()).toBe(false);
});
it('clears the previous series while a reused-route request is pending or fails', async () => {
const nextDetails = createDeferred<never>();
const xtreamApiService = {
getSeriesInfo: jest.fn(() => nextDetails.promise),
getVodInfo: jest.fn(),
getVodStream: jest.fn(),
};
const store = configureStore(xtreamApiService);
store.setSelectedItem({
info: { name: 'Series A' },
series_id: 42,
});
store.fetchSerialDetailsWithMetadata({
categoryId: 7,
serialId: '99',
});
expect(store.selectedItem()).toBeNull();
nextDetails.reject(new Error('Series B unavailable'));
await waitForCondition(
() => store.detailsError() === 'Series B unavailable'
);
expect(store.selectedItem()).toBeNull();
expect(store.isLoadingDetails()).toBe(false);
});
});
@@ -143,6 +143,7 @@ export const XtreamStore = signalStore(
store.setIsLoadingDetails(true);
store.setDetailsError(null);
store.setSelectedItem(null);
xtreamApiService
.getVodInfo(credentials, params.vodId)
.then((vodDetails: XtreamVodDetails) => {
@@ -171,7 +172,8 @@ export const XtreamStore = signalStore(
void enrichVodSelectionWithTmdb(
store,
tmdbEnrichment,
params.vodId
params.vodId,
isCurrentRequest
);
if (!recovery) {
@@ -241,6 +243,7 @@ export const XtreamStore = signalStore(
store.setIsLoadingDetails(true);
store.setDetailsError(null);
store.setSelectedItem(null);
xtreamApiService
.getSeriesInfo(credentials, params.serialId)
.then((serialDetails: XtreamSerieDetails) => {
@@ -253,7 +256,8 @@ export const XtreamStore = signalStore(
void enrichSerialSelectionWithTmdb(
store,
tmdbEnrichment,
params.serialId
params.serialId,
isCurrentRequest
);
})
.catch((error: unknown) => {
@@ -278,10 +282,15 @@ export const XtreamStore = signalStore(
* opens a season; a no-op without a show-level TMDB match.
*/
enrichSelectedSerialSeason(seasonKey: string): void {
const playlistId = store.currentPlaylist()?.id;
if (!playlistId) {
return;
}
void enrichSerialSeasonWithTmdb(
store,
tmdbEnrichment,
seasonKey
seasonKey,
() => store.currentPlaylist()?.id === playlistId
);
},
};
@@ -27,6 +27,8 @@ describe('VodDetailsRouteComponent — playback actions', () => {
getPlaybackPosition,
isDownloaded,
playDownload,
routeParams,
selectedItem,
} = stubs;
beforeEach(async () => {
@@ -326,6 +328,75 @@ describe('VodDetailsRouteComponent — playback actions', () => {
expect(playback.hasPlaybackPosition()).toBe(false);
});
it('does not expose a stale movie after a reused route changes identity', async () => {
currentPlaylist.set({ id: 'playlist-1' });
selectedItem.set({
info: { name: 'Movie A', description: 'Movie A details' },
movie_data: {
stream_id: 650020,
name: 'Movie A',
container_extension: 'mp4',
},
} as XtreamVodDetails);
fixture.detectChanges();
expect(fixture.componentInstance.selectedVodInfo()?.name).toBe(
'Movie A'
);
// The router reuses the detail component. The B request can fail,
// leaving the store's last successful selection (A) in place while
// route/download state has already switched to B.
downloadsAvailable.set(true);
isDownloaded.mockImplementation((vodId: number) => vodId === 650021);
routeParams.next({ vodId: '650021', categoryId: '235' });
stubs.detailsError.set('Movie B unavailable');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.selectedVodId()).toBe(650021);
expect(fixture.componentInstance.isDownloaded()).toBe(true);
expect(fixture.componentInstance.selectedItem()).toBeNull();
expect(fixture.componentInstance.selectedVodInfo()).toBeNull();
expect(fixture.componentInstance.playableVodItem()).toBeNull();
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).not.toContain('Movie A');
expect(host.textContent).toContain('Movie B unavailable');
expect(host.textContent).not.toContain('DOWNLOADS.OFFLINE');
expect(host.textContent).not.toContain('DOWNLOADS.PLAY_LOCAL');
expect(host.textContent).not.toContain(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
);
expect(host.querySelector('button.play-btn')).toBeNull();
await fixture.componentInstance.playFromProviderSource(
fixture.componentInstance.playableVodItem()
);
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
selectedItem.set({
info: { name: 'Movie B', description: 'Movie B details' },
movie_data: {
stream_id: 650021,
name: 'Movie B',
container_extension: 'mkv',
},
} as XtreamVodDetails);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.selectedVodInfo()?.name).toBe(
'Movie B'
);
expect(host.textContent).toContain('Movie B');
expect(host.textContent).toContain('DOWNLOADS.OFFLINE');
expect(host.textContent).toContain('DOWNLOADS.PLAY_LOCAL');
expect(host.textContent).toContain(
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
);
});
it('downloads the movie the route currently shows', async () => {
currentPlaylist.set({ id: 'playlist-1' });
fixture.detectChanges();
@@ -345,7 +345,7 @@ describe('VodDetailsRouteComponent', () => {
rating_kinopoisk: '7.455',
},
movie_data: {
stream_id: 678140,
stream_id: 650020,
name: 'City of McFarland (2015) DE',
added: '1750671180',
category_id: '235',
@@ -373,7 +373,7 @@ describe('VodDetailsRouteComponent', () => {
movie_image: 'https://example.com/poster.jpg',
},
movie_data: {
stream_id: 678140,
stream_id: 650020,
name: 'Metadata Without Backdrop',
added: '1750671180',
category_id: '235',
@@ -68,6 +68,26 @@ import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
readonly stream_id?: number | string;
readonly xtream_id?: number | string;
};
function resolveVodIdentity(item: XtreamVodDetails): number | null {
const candidate = item as XtreamVodIdentityItem;
const value =
item.movie_data?.stream_id ??
candidate.xtream_id ??
candidate.stream_id ??
candidate.id;
const id = typeof value === 'string' ? Number(value) : value;
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0
? id
: null;
}
@Component({
templateUrl: './vod-details-route.component.html',
styleUrls: [
@@ -134,11 +154,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isElectron = this.downloadsService.isAvailable;
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedItem = computed(
() =>
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null
);
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
return item && resolveVodIdentity(item) === this.selectedVodId()
? item
: null;
});
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
@@ -17,7 +17,7 @@ import {
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
/**
@@ -32,6 +32,10 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
/** Every stub the harness installs, so specs can drive and assert on them. */
export function createVodDetailsRouteStubs() {
return {
routeParams: new BehaviorSubject({
vodId: '650020',
categoryId: '235',
}),
selectedItem: signal<XtreamVodDetails | null>(null),
isLoadingDetails: signal(false),
detailsError: signal<string | null>(null),
@@ -82,6 +86,7 @@ export type VodDetailsRouteStubs = ReturnType<
/** Back to the state a fresh `beforeEach` expects. */
export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
stubs.routeParams.next({ vodId: '650020', categoryId: '235' });
stubs.selectedItem.set(null);
stubs.isLoadingDetails.set(false);
stubs.detailsError.set(null);
@@ -153,7 +158,7 @@ export async function configureVodDetailsRouteTestBed(
{
provide: ActivatedRoute,
useValue: {
params: of({ vodId: '650020', categoryId: '235' }),
params: stubs.routeParams,
snapshot: {
params: { vodId: '650020', categoryId: '235' },
},