mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(xtream): isolate reused detail identities
This commit is contained in:
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' },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user