From 5511d66651371b114518b7debbeb92377aa65a84 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 09:47:41 +0200 Subject: [PATCH] fix(xtream): ignore stale VOD positions --- .../vod-details-playback.service.spec.ts | 101 +++++++++++++++++- .../vod-details-playback.service.ts | 18 +++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 90d7cc76b..f9f8c71b0 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -32,6 +32,32 @@ describe('VodDetailsPlaybackService — external session ownership', () => { const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); const activeSource = signal(null); + const currentPlaylist = signal({ id: ROUTE_PLAYLIST }); + const routeVodId = signal(ROUTE_VOD_ID); + const getPlaybackPosition = jest.fn(); + + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; + } + + function positionFor( + playlistId: string, + contentXtreamId: number, + positionSeconds: number + ): PlaybackPositionData { + return { + playlistId, + contentXtreamId, + contentType: 'vod', + positionSeconds, + durationSeconds: 7744, + }; + } function sessionFor(playlistId: string, contentXtreamId: number) { return { @@ -48,8 +74,12 @@ describe('VodDetailsPlaybackService — external session ownership', () => { beforeEach(() => { activeSession.set(null); activeSource.set(null); + currentPlaylist.set({ id: ROUTE_PLAYLIST }); + routeVodId.set(ROUTE_VOD_ID); positionListener = undefined; addRecentItem.mockClear(); + getPlaybackPosition.mockReset(); + getPlaybackPosition.mockResolvedValue(null); TestBed.configureTestingModule({ providers: [ @@ -57,7 +87,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { { provide: XtreamStore, useValue: { - currentPlaylist: signal({ id: ROUTE_PLAYLIST }), + currentPlaylist, addRecentItem, constructVodStreamUrl: jest .fn() @@ -71,7 +101,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { { provide: PORTAL_PLAYBACK_POSITIONS, useValue: { - getPlaybackPosition: jest.fn(), + getPlaybackPosition, savePlaybackPosition: jest.fn(), }, }, @@ -98,7 +128,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { service = TestBed.inject(VodDetailsPlaybackService); service.bind({ - vodId: signal(ROUTE_VOD_ID), + vodId: routeVodId, vodInfo: signal(null), activeSource, }); @@ -261,4 +291,69 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(service.vodPlaybackPosition()).toBeNull(); }); }); + + describe('stored position loads', () => { + it('drops a result after the route changes without starting another load', async () => { + const oldLoad = deferred(); + getPlaybackPosition.mockReturnValueOnce(oldLoad.promise); + const pending = service.loadPosition(ROUTE_PLAYLIST, ROUTE_VOD_ID); + + routeVodId.set(ROUTE_VOD_ID + 1); + oldLoad.resolve(positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120)); + await pending; + + expect(service.vodPlaybackPosition()).toBeNull(); + expect(service.routePlaybackPosition()).toBeNull(); + }); + + it('drops a result for the same VOD id after the playlist changes', async () => { + const oldLoad = deferred(); + getPlaybackPosition.mockReturnValueOnce(oldLoad.promise); + const pending = service.loadPosition(ROUTE_PLAYLIST, ROUTE_VOD_ID); + + currentPlaylist.set({ id: 'playlist-2' }); + oldLoad.resolve(positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120)); + await pending; + + expect(service.vodPlaybackPosition()).toBeNull(); + expect(service.routePlaybackPosition()).toBeNull(); + }); + + it('keeps the newest valid result when an older load finishes last', async () => { + const olderLoad = deferred(); + const newerLoad = deferred(); + getPlaybackPosition + .mockReturnValueOnce(olderLoad.promise) + .mockReturnValueOnce(newerLoad.promise); + + const olderPending = service.loadPosition( + ROUTE_PLAYLIST, + ROUTE_VOD_ID + ); + const newerPending = service.loadPosition( + ROUTE_PLAYLIST, + ROUTE_VOD_ID + ); + newerLoad.resolve(positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 300)); + await newerPending; + olderLoad.resolve(positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120)); + await olderPending; + + expect(service.vodPlaybackPosition()?.positionSeconds).toBe(300); + expect(service.routePlaybackPosition()?.positionSeconds).toBe(300); + }); + + it('does not publish a pending result after destruction', async () => { + const oldLoad = deferred(); + getPlaybackPosition.mockReturnValueOnce(oldLoad.promise); + const pending = service.loadPosition(ROUTE_PLAYLIST, ROUTE_VOD_ID); + + TestBed.resetTestingModule(); + oldLoad.resolve(positionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120)); + await pending; + + expect(service.vodPlaybackPosition()).toBeNull(); + expect(service.routePlaybackPosition()).toBeNull(); + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 953a65168..eef77a66c 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -64,6 +64,8 @@ export class VodDetailsPlaybackService { PlaybackPositionRuntimeBridgeService ); private readonly logger = createLogger('VodDetailsPlayback'); + private positionLoadGeneration = 0; + private destroyed = false; /** Signals bound from the host component via `bind()` */ private readonly bindings = signal(null); @@ -147,7 +149,11 @@ export class VodDetailsPlaybackService { } ) ?? null; - inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.()); + inject(DestroyRef).onDestroy(() => { + this.destroyed = true; + this.positionLoadGeneration++; + unsubscribePositionUpdates?.(); + }); } private ownsContent( @@ -325,11 +331,21 @@ export class VodDetailsPlaybackService { } async loadPosition(playlistId: string, vodId: number): Promise { + const generation = ++this.positionLoadGeneration; const position = await this.playbackPositions.getPlaybackPosition( playlistId, vodId, 'vod' ); + if ( + this.destroyed || + generation !== this.positionLoadGeneration || + this.xtreamStore.currentPlaylist()?.id !== playlistId || + this.bindings()?.vodId() !== vodId + ) { + return; + } + this.vodPlaybackPosition.set(position); this.routePlaybackPosition.set(position); }