From ac12272db6e53065d262e65612540e106f40db0f Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 09:24:07 +0200 Subject: [PATCH] fix(stalker): preserve progress with scoped episode IDs --- .../src/lib/stalker-series.adapters.spec.ts | 164 ++- .../src/lib/stalker-series.adapters.ts | 65 +- ...lker-series-position-compatibility.spec.ts | 573 ++++++++ .../stalker-series-position-compatibility.ts | 203 +++ .../stalker-series-view.component.ts | 460 ++++++- ...series-view.position-compatibility.spec.ts | 1182 +++++++++++++++++ 6 files changed, 2569 insertions(+), 78 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts index 6a65d44c6..df4f64560 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts @@ -10,6 +10,7 @@ import { StalkerSelectedVodItem } from './models'; type EpisodeWithMetadata = { custom_sid?: string; id?: string; + legacyTrackingId?: number; originalId?: string; originalCmd?: string; }; @@ -68,7 +69,10 @@ describe('stalker-series.adapters', () => { isExpanded: false, }, ], - 'poster.jpg' + { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + } ); expect(mapped['1']).toHaveLength(2); @@ -78,39 +82,152 @@ describe('stalker-series.adapters', () => { expect(firstEpisode.id).not.toBe(mapped['1'][1].id); }); - it('derives missing VOD-series season numbers from natural season order', () => { - const mapped = mapVodSeriesEpisodes([ + it('generates deterministic VOD-series episode IDs for the same parent and provider episode', () => { + const seasons = [ { - id: 'season-2', - video_id: 'v1', - name: 'Season 2', - season_number: '', - episodes: [ - { - id: 'episode-2', - series_number: 1, - name: 'Second season pilot', - }, - ], - isLoading: false, - isExpanded: false, - }, - { - id: 'season-1', + id: 's1', video_id: 'v1', name: 'Season 1', - season_number: '', + season_number: '1', episodes: [ { - id: 'episode-1', + id: 'provider-episode-1', series_number: 1, - name: 'Pilot', + name: 'Episode 1', }, ], isLoading: false, isExpanded: false, }, - ]); + ]; + const options = { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + }; + + const firstMapping = mapVodSeriesEpisodes(seasons, options); + const secondMapping = mapVodSeriesEpisodes(seasons, options); + + expect(firstMapping['1'][0].id).toBe('604391373'); + expect(firstMapping['1'][0].id).toBe(secondMapping['1'][0].id); + }); + + it('scopes VOD-series episode IDs by parent while preserving the legacy tracking ID', () => { + const seasons = [ + { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + episodes: [ + { + id: 'provider-episode-1', + series_number: 1, + name: 'Episode 1', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]; + + const firstSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 100, + fallbackPoster: 'poster.jpg', + }); + const secondSeries = mapVodSeriesEpisodes(seasons, { + parentSeriesId: 200, + fallbackPoster: 'poster.jpg', + }); + const firstEpisode = firstSeries['1'][0] as EpisodeWithMetadata; + const secondEpisode = secondSeries['1'][0] as EpisodeWithMetadata; + + expect(firstEpisode.id).not.toBe(secondEpisode.id); + expect(firstEpisode.legacyTrackingId).toBe(624320047); + expect(firstEpisode.legacyTrackingId).toBe( + secondEpisode.legacyTrackingId + ); + }); + + it('scopes VOD-series episode IDs by provider episode identity', () => { + const baseSeason = { + id: 's1', + video_id: 'v1', + name: 'Season 1', + season_number: '1', + isLoading: false, + isExpanded: false, + }; + const firstSeries = mapVodSeriesEpisodes( + [ + { + ...baseSeason, + episodes: [ + { + id: 'provider-episode-1', + series_number: 1, + name: 'Episode 1', + }, + ], + }, + ], + { parentSeriesId: 100 } + ); + const secondSeries = mapVodSeriesEpisodes( + [ + { + ...baseSeason, + episodes: [ + { + id: 'provider-episode-2', + series_number: 1, + name: 'Episode 1', + }, + ], + }, + ], + { parentSeriesId: 100 } + ); + + expect(firstSeries['1'][0].id).not.toBe(secondSeries['1'][0].id); + }); + + it('derives missing VOD-series season numbers from natural season order', () => { + const mapped = mapVodSeriesEpisodes( + [ + { + id: 'season-2', + video_id: 'v1', + name: 'Season 2', + season_number: '', + episodes: [ + { + id: 'episode-2', + series_number: 1, + name: 'Second season pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-1', + video_id: 'v1', + name: 'Season 1', + season_number: '', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ], + { parentSeriesId: 100 } + ); expect(mapped['Season 1'][0].season).toBe(1); expect(mapped['Season 2'][0].season).toBe(2); @@ -132,6 +249,7 @@ describe('stalker-series.adapters', () => { expect(mapped['1']).toHaveLength(2); const firstEpisode = mapped['1'][0] as EpisodeWithMetadata; expect(firstEpisode.custom_sid).toBe('regular-series'); + expect(firstEpisode.id).toBe('91189090'); expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg'); }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts index 23bd5e1fc..17ec5dcec 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts @@ -29,7 +29,13 @@ export interface StalkerSeriesSeasonVm { series: number[]; } +export interface MapVodSeriesEpisodesOptions { + parentSeriesId: string | number; + fallbackPoster?: string; +} + export interface StalkerMappedEpisode extends XtreamSerieEpisode { + legacyTrackingId?: number; originalId?: string; originalCmd?: string; } @@ -44,15 +50,34 @@ function hashString(str: string): number { return Math.abs(hash); } -function generateEpisodeId( - seed: string, +function generateLegacyVodEpisodeId( episodeNum: number, - seasonKey: string, - isVodSeries: boolean + seasonKey: string +): number { + return hashString(`vod_${seasonKey}_${episodeNum}`); +} + +function generateVodEpisodeId(options: { + parentSeriesId: string | number; + providerEpisodeId: string; + seasonKey: string; + episodeNum: number; +}): number { + return hashString( + JSON.stringify([ + 'vod', + String(options.parentSeriesId), + options.providerEpisodeId, + options.seasonKey, + options.episodeNum, + ]) + ); +} + +function generateRegularEpisodeId( + seed: string, + episodeNum: number ): number { - if (isVodSeries) { - return hashString(`vod_${seasonKey}_${episodeNum}`); - } return hashString(`${seed}_ep_${episodeNum}`); } @@ -153,7 +178,7 @@ function createBaseEpisode( export function mapVodSeriesEpisodes( seasons: ReadonlyArray, - fallbackPoster?: string + options: MapVodSeriesEpisodesOptions ): Record { const mapped: Record = {}; @@ -165,12 +190,17 @@ export function mapVodSeriesEpisodes( const episodeNum = toEpisodeNumber(episode.series_number) || toEpisodeNumber(episode.episode_num); - const trackingId = generateEpisodeId( - String(episode.id ?? ''), + const providerEpisodeId = String(episode.id ?? ''); + const legacyTrackingId = generateLegacyVodEpisodeId( episodeNum, - seasonKey, - true + seasonKey ); + const trackingId = generateVodEpisodeId({ + parentSeriesId: options.parentSeriesId, + providerEpisodeId, + seasonKey, + episodeNum, + }); return { ...createBaseEpisode( @@ -181,14 +211,15 @@ export function mapVodSeriesEpisodes( 'vod-series', seasonNum, { - movie_image: episode.cover || fallbackPoster, + movie_image: episode.cover || options.fallbackPoster, plot: episode.description || '', duration: episode.duration ? `${episode.duration} min` : '', } ), - originalId: String(episode.id ?? ''), + legacyTrackingId, + originalId: providerEpisodeId, } as StalkerMappedEpisode; }); }); @@ -205,11 +236,9 @@ export function mapRegularSeriesEpisodes( seasons.forEach((season, index) => { const seasonKey = String(index + 1); mapped[seasonKey] = (season.series ?? []).map((episodeNum) => { - const trackingId = generateEpisodeId( + const trackingId = generateRegularEpisodeId( String(season.cmd ?? ''), - episodeNum, - seasonKey, - false + episodeNum ); return { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts new file mode 100644 index 000000000..4b5b37a1a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts @@ -0,0 +1,573 @@ +import type { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import { + clearStalkerSeriesPosition, + reconcileStalkerSeriesPositions, + saveStalkerSeriesPosition, + StalkerSeriesPositionPartialSaveError, +} from './stalker-series-position-compatibility'; + +const PLAYLIST_ID = 'playlist-1'; +const SERIES_ID = 100; +const FOREIGN_SERIES_ID = 200; +const SCOPED_TRACKING_ID = 1001; +const LEGACY_TRACKING_ID = 501; + +interface EpisodeOptions { + trackingId?: number; + legacyTrackingId?: number; + seasonNumber?: number; + episodeNumber?: number; +} + +interface RejectedOwnershipCase { + name: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +} + +function createEpisode(options: EpisodeOptions = {}): StalkerMappedEpisode { + const episodeNumber = options.episodeNumber ?? 2; + return { + id: String(options.trackingId ?? SCOPED_TRACKING_ID), + episode_num: episodeNumber, + title: `Episode ${episodeNumber}`, + container_extension: 'mpg', + info: {}, + custom_sid: 'vod-series', + added: '', + season: options.seasonNumber ?? 1, + direct_source: '', + legacyTrackingId: options.legacyTrackingId, + originalId: `provider-${episodeNumber}`, + }; +} + +function createPosition( + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId: SCOPED_TRACKING_ID, + contentType: 'episode', + seriesXtreamId: SERIES_ID, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 45, + durationSeconds: 120, + playlistId: PLAYLIST_ID, + ...overrides, + }; +} + +function reconcile( + episodes: readonly XtreamSerieEpisode[], + seriesPositions: readonly PlaybackPositionData[], + seriesXtreamId = SERIES_ID +) { + return reconcileStalkerSeriesPositions({ + seriesXtreamId, + episodesBySeason: { '1': episodes }, + seriesPositions, + }); +} + +describe('stalker series position compatibility', () => { + it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition({ positionSeconds: 90 }); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + positionSeconds: 30, + }); + + const result = reconcile( + [episode], + [legacyPosition, exactPosition] + ); + + expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toBe( + exactPosition + ); + expect( + result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(legacyPosition); + expect( + result.positionsByTrackingId.has(LEGACY_TRACKING_ID) + ).toBe(false); + }); + + it('aliases only the current parent legacy row under the scoped ID without mutating it', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const currentParentLegacy = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: undefined, + episodeNumber: undefined, + }); + const foreignParentLegacy = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: FOREIGN_SERIES_ID, + positionSeconds: 99, + }); + + const result = reconcile( + [episode], + [foreignParentLegacy, currentParentLegacy] + ); + const migrated = result.positionsByTrackingId.get(SCOPED_TRACKING_ID); + + expect(migrated).toEqual({ + ...currentParentLegacy, + contentXtreamId: SCOPED_TRACKING_ID, + seriesXtreamId: SERIES_ID, + seasonNumber: 1, + episodeNumber: 2, + }); + expect(migrated).not.toBe(currentParentLegacy); + expect(currentParentLegacy.contentXtreamId).toBe(LEGACY_TRACKING_ID); + expect( + result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(currentParentLegacy); + + const foreignResult = reconcile( + [episode], + [foreignParentLegacy], + SERIES_ID + ); + expect(foreignResult.positionsByTrackingId.size).toBe(0); + expect(foreignResult.legacyPositionByTrackingId.size).toBe(0); + }); + + it('matches present coordinates numerically and treats nullish coordinates as absent', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const numericStringCoordinates = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: '1' as unknown as number, + episodeNumber: '2' as unknown as number, + }); + const absentCoordinates = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: null as unknown as number, + episodeNumber: undefined, + }); + + expect( + reconcile( + [episode], + [numericStringCoordinates] + ).positionsByTrackingId.has(SCOPED_TRACKING_ID) + ).toBe(true); + expect( + reconcile( + [episode], + [absentCoordinates] + ).positionsByTrackingId.has(SCOPED_TRACKING_ID) + ).toBe(true); + }); + + it('rejects a legacy alias when either present coordinate conflicts', () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const wrongSeason = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seasonNumber: 3, + }); + const wrongEpisode = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + episodeNumber: 8, + }); + + for (const position of [wrongSeason, wrongEpisode]) { + const result = reconcile([episode], [position]); + expect(result.positionsByTrackingId.size).toBe(0); + expect(result.legacyPositionByTrackingId.size).toBe(0); + } + }); + + it('never aliases an episode without legacy tracking metadata', () => { + const episode = createEpisode(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + + const result = reconcile([episode], [legacyPosition]); + + expect(result.positionsByTrackingId.size).toBe(0); + expect(result.legacyPositionByTrackingId.size).toBe(0); + }); + + it('awaits a scoped save before clearing the confirmed legacy row', async () => { + const order: string[] = []; + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const position = reconcile( + [ + createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }), + ], + [legacyPosition] + ).positionsByTrackingId.get(SCOPED_TRACKING_ID); + expect(position).toBeDefined(); + if (!position) { + throw new Error('Expected a migrated scoped position'); + } + const repository = { + savePlaybackPosition: jest.fn( + async (_playlistId: string, saved: PlaybackPositionData) => { + order.push(`save:${saved.contentXtreamId}`); + } + ), + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(`clear:${contentXtreamId}`); + } + ), + }; + + await expect( + saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(true); + expect(order).toEqual([ + `save:${SCOPED_TRACKING_ID}`, + `clear:${LEGACY_TRACKING_ID}`, + ]); + }); + + it('does not clear legacy when the scoped save rejects', async () => { + const saveError = new Error('save failed'); + const repository = { + savePlaybackPosition: jest.fn().mockRejectedValue(saveError), + clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), + }; + + await expect( + saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }) + ).rejects.toBe(saveError); + expect(repository.clearPlaybackPosition).not.toHaveBeenCalled(); + }); + + it('reports a partial save when legacy cleanup rejects after the scoped write', async () => { + const order: string[] = []; + const clearError = new Error('clear failed'); + const repository = { + savePlaybackPosition: jest.fn( + async (_playlistId: string, position: PlaybackPositionData) => { + order.push(`save:${position.contentXtreamId}`); + } + ), + clearPlaybackPosition: jest.fn( + async (_playlistId: string, contentXtreamId: number) => { + order.push(`clear:${contentXtreamId}`); + throw clearError; + } + ), + }; + + const save = saveStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }); + + const rejection = await save.catch( + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf( + StalkerSeriesPositionPartialSaveError + ); + if ( + !(rejection instanceof + StalkerSeriesPositionPartialSaveError) + ) { + throw new Error('Expected a partial save error'); + } + expect(rejection.cause).toBe(clearError); + expect(rejection.scopedPositionSaved).toBe(true); + expect(order).toEqual([ + `save:${SCOPED_TRACKING_ID}`, + `clear:${LEGACY_TRACKING_ID}`, + ]); + }); + + const rejectedOwnershipCases: RejectedOwnershipCase[] = [ + { + name: 'different parents', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: FOREIGN_SERIES_ID, + }), + }, + { + name: 'missing scoped parent', + position: createPosition({ seriesXtreamId: undefined }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'missing legacy parent', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + seriesXtreamId: undefined, + }), + }, + { + name: 'equal scoped and legacy IDs', + position: createPosition(), + legacyPosition: createPosition(), + }, + { + name: 'non-episode scoped content', + position: createPosition({ contentType: 'vod' }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'non-episode legacy content', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + contentType: 'vod', + }), + }, + { + name: 'conflicting scoped playlist', + position: createPosition({ playlistId: 'playlist-2' }), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }), + }, + { + name: 'conflicting legacy playlist', + position: createPosition(), + legacyPosition: createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + playlistId: 'playlist-2', + }), + }, + { + name: 'missing legacy row', + position: createPosition(), + }, + ]; + + it.each(rejectedOwnershipCases)( + 'does not clean an unowned legacy row for $name', + async ({ position, legacyPosition }) => { + const saveRepository = { + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + clearPlaybackPosition: jest + .fn() + .mockResolvedValue(undefined), + }; + await expect( + saveStalkerSeriesPosition({ + repository: saveRepository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(false); + expect( + saveRepository.savePlaybackPosition + ).toHaveBeenCalledWith(PLAYLIST_ID, position); + expect( + saveRepository.clearPlaybackPosition + ).not.toHaveBeenCalled(); + + const clearRepository = { + clearPlaybackPosition: jest + .fn() + .mockResolvedValue(undefined), + }; + await expect( + clearStalkerSeriesPosition({ + repository: clearRepository, + playlistId: PLAYLIST_ID, + position, + legacyPosition, + }) + ).resolves.toBe(false); + expect( + clearRepository.clearPlaybackPosition + ).toHaveBeenCalledTimes(1); + expect( + clearRepository.clearPlaybackPosition + ).toHaveBeenCalledWith( + PLAYLIST_ID, + position.contentXtreamId, + position.contentType + ); + } + ); + + it('clears confirmed legacy before scoped state and cannot resurrect it', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + positionSeconds: 20, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const order: string[] = []; + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(`clear:${contentXtreamId}`); + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).resolves.toBe(true); + expect(order).toEqual([ + `clear:${LEGACY_TRACKING_ID}`, + `clear:${SCOPED_TRACKING_ID}`, + ]); + + const refreshed = reconcile([episode], [...rows.values()]); + expect(refreshed.positionsByTrackingId.size).toBe(0); + expect(refreshed.legacyPositionByTrackingId.size).toBe(0); + }); + + it('leaves exact scoped progress when legacy cleanup rejects', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const clearError = new Error('legacy clear failed'); + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + if (contentXtreamId === LEGACY_TRACKING_ID) { + throw clearError; + } + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).rejects.toBe(clearError); + expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition); + expect( + reconcile([episode], [...rows.values()]) + .positionsByTrackingId + .get(SCOPED_TRACKING_ID) + ).toBe(exactPosition); + }); + + it('leaves exact scoped progress when scoped clear rejects after legacy cleanup', async () => { + const episode = createEpisode({ + legacyTrackingId: LEGACY_TRACKING_ID, + }); + const exactPosition = createPosition(); + const legacyPosition = createPosition({ + contentXtreamId: LEGACY_TRACKING_ID, + }); + const rows = new Map([ + [SCOPED_TRACKING_ID, exactPosition], + [LEGACY_TRACKING_ID, legacyPosition], + ]); + const order: number[] = []; + const clearError = new Error('scoped clear failed'); + const repository = { + clearPlaybackPosition: jest.fn( + async ( + _playlistId: string, + contentXtreamId: number + ) => { + order.push(contentXtreamId); + if (contentXtreamId === SCOPED_TRACKING_ID) { + throw clearError; + } + rows.delete(contentXtreamId); + } + ), + }; + + await expect( + clearStalkerSeriesPosition({ + repository, + playlistId: PLAYLIST_ID, + position: exactPosition, + legacyPosition, + }) + ).rejects.toBe(clearError); + expect(order).toEqual([ + LEGACY_TRACKING_ID, + SCOPED_TRACKING_ID, + ]); + expect(rows.has(LEGACY_TRACKING_ID)).toBe(false); + expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition); + + const refreshed = reconcile([episode], [...rows.values()]); + expect( + refreshed.positionsByTrackingId.get(SCOPED_TRACKING_ID) + ).toBe(exactPosition); + expect(refreshed.legacyPositionByTrackingId.size).toBe(0); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts new file mode 100644 index 000000000..f796c31da --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts @@ -0,0 +1,203 @@ +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util'; +import type { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; + +export interface ReconciledStalkerSeriesPositions { + positionsByTrackingId: Map; + legacyPositionByTrackingId: Map; +} + +export class StalkerSeriesPositionPartialSaveError extends Error { + readonly cause: unknown; + readonly scopedPositionSaved = true as const; + + constructor(cause: unknown) { + super( + 'Scoped Stalker series position was saved, but legacy cleanup failed' + ); + this.name = 'StalkerSeriesPositionPartialSaveError'; + this.cause = cause; + } +} + +function matchesMappedCoordinate( + value: number | undefined, + mappedValue: number +): boolean { + return value == null || Number(value) === mappedValue; +} + +function isCompatibleLegacyPosition( + position: PlaybackPositionData, + episode: XtreamSerieEpisode +): boolean { + return ( + matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) && + matchesMappedCoordinate( + position.episodeNumber, + Number(episode.episode_num) + ) + ); +} + +export function reconcileStalkerSeriesPositions(options: { + seriesXtreamId: number; + episodesBySeason: Readonly< + Record + >; + seriesPositions: readonly PlaybackPositionData[]; +}): ReconciledStalkerSeriesPositions { + const positionsByTrackingId = new Map(); + const legacyPositionByTrackingId = new Map< + number, + PlaybackPositionData + >(); + const indexedPositions = new Map(); + + for (const position of options.seriesPositions) { + if ( + position.contentType === 'episode' && + position.seriesXtreamId === options.seriesXtreamId + ) { + indexedPositions.set(position.contentXtreamId, position); + } + } + + for (const episodes of Object.values(options.episodesBySeason)) { + for (const episode of episodes) { + const trackingId = Number(episode.id); + if (!Number.isFinite(trackingId)) { + continue; + } + + const exactPosition = indexedPositions.get(trackingId); + if (exactPosition) { + positionsByTrackingId.set(trackingId, exactPosition); + } + + const legacyTrackingId = (episode as StalkerMappedEpisode) + .legacyTrackingId; + if ( + legacyTrackingId == null || + legacyTrackingId === trackingId + ) { + continue; + } + + const legacyPosition = indexedPositions.get(legacyTrackingId); + if ( + !legacyPosition || + !isCompatibleLegacyPosition(legacyPosition, episode) + ) { + continue; + } + + legacyPositionByTrackingId.set(trackingId, legacyPosition); + if (!exactPosition) { + positionsByTrackingId.set(trackingId, { + ...legacyPosition, + contentXtreamId: trackingId, + seriesXtreamId: options.seriesXtreamId, + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }); + } + } + } + + return { + positionsByTrackingId, + legacyPositionByTrackingId, + }; +} + +function ownsLegacyPosition( + playlistId: string, + position: PlaybackPositionData, + legacyPosition: PlaybackPositionData | undefined +): legacyPosition is PlaybackPositionData { + return ( + position.contentType === 'episode' && + legacyPosition?.contentType === 'episode' && + position.contentXtreamId !== legacyPosition.contentXtreamId && + position.seriesXtreamId != null && + legacyPosition.seriesXtreamId != null && + position.seriesXtreamId === legacyPosition.seriesXtreamId && + (!position.playlistId || position.playlistId === playlistId) && + (!legacyPosition.playlistId || + legacyPosition.playlistId === playlistId) + ); +} + +export async function saveStalkerSeriesPosition(options: { + repository: Pick< + PortalPlaybackPositions, + 'savePlaybackPosition' | 'clearPlaybackPosition' + >; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise { + await options.repository.savePlaybackPosition( + options.playlistId, + options.position + ); + + if ( + !ownsLegacyPosition( + options.playlistId, + options.position, + options.legacyPosition + ) + ) { + return false; + } + + try { + await options.repository.clearPlaybackPosition( + options.playlistId, + options.legacyPosition.contentXtreamId, + options.legacyPosition.contentType + ); + } catch (cause) { + throw new StalkerSeriesPositionPartialSaveError(cause); + } + return true; +} + +export async function clearStalkerSeriesPosition(options: { + repository: Pick; + playlistId: string; + position: PlaybackPositionData; + legacyPosition?: PlaybackPositionData; +}): Promise { + if ( + ownsLegacyPosition( + options.playlistId, + options.position, + options.legacyPosition + ) + ) { + await options.repository.clearPlaybackPosition( + options.playlistId, + options.legacyPosition.contentXtreamId, + options.legacyPosition.contentType + ); + await options.repository.clearPlaybackPosition( + options.playlistId, + options.position.contentXtreamId, + options.position.contentType + ); + return true; + } + + await options.repository.clearPlaybackPosition( + options.playlistId, + options.position.contentXtreamId, + options.position.contentType + ); + return false; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 88d6d48a1..568de3646 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -76,6 +76,19 @@ import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, } from './stalker-series-quick-start'; +import { + clearStalkerSeriesPosition, + reconcileStalkerSeriesPositions, + saveStalkerSeriesPosition, + StalkerSeriesPositionPartialSaveError, +} from './stalker-series-position-compatibility'; + +interface SeriesPositionContext { + readonly generation: number; + readonly playlistId: string; + readonly seriesXtreamId: number; + readonly mutationKey: string; +} /** * Component for displaying series/episodes for Stalker portal content. @@ -120,6 +133,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly episodePlaybackPositions = signal< Map >(new Map()); + private readonly rawSeriesPositions = signal< + readonly PlaybackPositionData[] + >([]); + private readonly legacyPositionByTrackingId = signal< + Map + >(new Map()); + private activeSeriesPositionContext: SeriesPositionContext | null = null; + private seriesPositionContextGeneration = 0; + private readonly seriesPositionMutationQueues = new Map< + string, + Promise + >(); + private readonly pendingSeriesPositionLoads = new Map< + SeriesPositionContext, + Set + >(); + private readonly seriesPositionReloadKeys = new Set(); + private seriesPositionsLoadGeneration = 0; private lastSaveTime = 0; private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); @@ -276,24 +307,68 @@ export class StalkerSeriesViewComponent implements OnDestroy { effect(() => { const item = this.displayItem(); const playlist = this.stalkerStore.currentPlaylist(); - if (item && playlist?._id) { - const normalizedSeriesId = this.toSeriesId(item.id); + const normalizedSeriesId = this.toSeriesId(item?.id ?? 0); + if (item && playlist?._id && normalizedSeriesId > 0) { this.logger.debug('Loading positions for series', { id: item.id, seriesId: normalizedSeriesId, isSeries: item.is_series, }); - if (!isNaN(normalizedSeriesId)) { - void this.loadSeriesPositions( - playlist._id, - normalizedSeriesId - ); - } - } else { + this.rawSeriesPositions.set([]); this.episodePlaybackPositions.set(new Map()); + this.legacyPositionByTrackingId.set(new Map()); + const context = this.activateSeriesPositionContext( + playlist._id, + normalizedSeriesId + ); + void this.loadSeriesPositions(context); + } else { + this.activeSeriesPositionContext = null; + this.seriesPositionContextGeneration++; + this.seriesPositionsLoadGeneration++; } }); + effect(() => { + const item = this.displayItem(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + const seriesXtreamId = this.toSeriesId(item?.id ?? 0); + const rawSeriesPositions = this.rawSeriesPositions(); + const episodesBySeason = this.mappedSeasons(); + + if (!item || !playlistId || seriesXtreamId <= 0) { + if (rawSeriesPositions.length > 0) { + this.rawSeriesPositions.set([]); + } + if (this.episodePlaybackPositions().size > 0) { + this.episodePlaybackPositions.set(new Map()); + } + if (this.legacyPositionByTrackingId().size > 0) { + this.legacyPositionByTrackingId.set(new Map()); + } + return; + } + + const reconciled = reconcileStalkerSeriesPositions({ + seriesXtreamId, + episodesBySeason, + seriesPositions: rawSeriesPositions, + }); + if ( + rawSeriesPositions.length === 0 && + reconciled.positionsByTrackingId.size === 0 && + untracked(() => this.episodePlaybackPositions().size) > 0 + ) { + return; + } + this.episodePlaybackPositions.set( + reconciled.positionsByTrackingId + ); + this.legacyPositionByTrackingId.set( + reconciled.legacyPositionByTrackingId + ); + }); + effect(() => { const session = this.externalPlayback.activeSession(); const item = this.displayItem(); @@ -344,7 +419,18 @@ export class StalkerSeriesViewComponent implements OnDestroy { return; } - this.updateEpisodePlaybackPosition(data); + // The facade/runtime already saved this row. Repeat the + // idempotent upsert because only this view owns the + // scoped-to-legacy cleanup mapping. + void this.persistSeriesPosition( + playlistId, + data + ).catch((error: unknown) => { + this.logger.error( + 'Failed to persist runtime series position', + error + ); + }); } ) ?? null; } @@ -388,22 +474,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { */ readonly mappedSeasons = computed>( () => { + const displayItem = this.displayItem(); const base = this.isVodSeries() - ? mapVodSeriesEpisodes( - this.vodSeriesSeasons(), - this.displayItem()?.info?.movie_image - ) + ? mapVodSeriesEpisodes(this.vodSeriesSeasons(), { + parentSeriesId: this.toSeriesId(displayItem?.id ?? 0), + fallbackPoster: displayItem?.info?.movie_image, + }) : mapRegularSeriesEpisodes( this.regularSeasons(), - this.displayItem()?.info?.movie_image + displayItem?.info?.movie_image ); // Overlay lazily fetched TMDB episode data (real names, // overviews, stills) — a no-op while nothing is fetched - return this.tmdbSeasons.overlay( - base, - this.displayItem()?.info?.tmdb_id - ); + return this.tmdbSeasons.overlay(base, displayItem?.info?.tmdb_id); } ); @@ -750,11 +834,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { positionSeconds: Math.floor(event.currentTime), durationSeconds: Math.floor(event.duration), }; - void this.playbackPositions.savePlaybackPosition( + void this.persistSeriesPosition( playback.contentInfo.playlistId, position - ); - this.updateEpisodePlaybackPosition(position); + ).catch((error: unknown) => { + this.logger.error( + 'Failed to persist inline series position', + error + ); + }); } showCopyNotification(): void { @@ -894,20 +982,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { } if (request.nextPosition) { - await this.playbackPositions.savePlaybackPosition( + await this.persistSeriesPosition( playlistId, request.nextPosition ); - this.updateEpisodePlaybackPosition(request.nextPosition); return; } - await this.playbackPositions.clearPlaybackPosition( + await this.clearSeriesPosition( playlistId, - request.contentXtreamId, - 'episode' + request.contentXtreamId ); - this.removeEpisodePlaybackPosition(request.contentXtreamId); } async downloadEpisode(episode: XtreamSerieEpisode): Promise { @@ -975,19 +1060,320 @@ export class StalkerSeriesViewComponent implements OnDestroy { } private async loadSeriesPositions( + context: SeriesPositionContext + ): Promise { + const generation = ++this.seriesPositionsLoadGeneration; + this.trackPendingSeriesPositionLoad(context, generation); + try { + await this.waitForSeriesPositionMutations( + context.mutationKey + ); + + if ( + generation !== this.seriesPositionsLoadGeneration || + !this.isSeriesPositionContextActive(context) + ) { + return; + } + + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + context.playlistId, + context.seriesXtreamId + ); + + if ( + generation !== this.seriesPositionsLoadGeneration || + !this.isSeriesPositionContextActive(context) + ) { + return; + } + + this.rawSeriesPositions.set(positions); + } finally { + this.untrackPendingSeriesPositionLoad(context, generation); + } + } + + private activateSeriesPositionContext( playlistId: string, seriesXtreamId: number + ): SeriesPositionContext { + const context: SeriesPositionContext = { + generation: ++this.seriesPositionContextGeneration, + playlistId, + seriesXtreamId, + mutationKey: JSON.stringify([playlistId, seriesXtreamId]), + }; + this.activeSeriesPositionContext = context; + return context; + } + + private isSeriesPositionContextActive( + context: SeriesPositionContext + ): boolean { + const activeContext = this.activeSeriesPositionContext; + return ( + activeContext === context && + activeContext.generation === context.generation && + this.stalkerStore.currentPlaylist()?._id === + context.playlistId && + this.toSeriesId(this.displayItem()?.id ?? 0) === + context.seriesXtreamId + ); + } + + private waitForSeriesPositionMutations( + mutationKey: string ): Promise { - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); - const positionsMap = new Map(); - positions.forEach((position) => { - positionsMap.set(position.contentXtreamId, position); + return ( + this.seriesPositionMutationQueues.get(mutationKey) ?? + Promise.resolve() + ); + } + + private trackPendingSeriesPositionLoad( + context: SeriesPositionContext, + generation: number + ): void { + const generations = + this.pendingSeriesPositionLoads.get(context) ?? + new Set(); + generations.add(generation); + this.pendingSeriesPositionLoads.set(context, generations); + } + + private untrackPendingSeriesPositionLoad( + context: SeriesPositionContext, + generation: number + ): void { + const generations = + this.pendingSeriesPositionLoads.get(context); + generations?.delete(generation); + if (generations?.size === 0) { + this.pendingSeriesPositionLoads.delete(context); + } + } + + private hasCurrentPendingSeriesPositionLoad( + context: SeriesPositionContext + ): boolean { + return Boolean( + this.pendingSeriesPositionLoads + .get(context) + ?.has(this.seriesPositionsLoadGeneration) + ); + } + + private enqueueSeriesPositionMutation( + context: SeriesPositionContext, + operation: () => Promise + ): Promise { + if (this.hasCurrentPendingSeriesPositionLoad(context)) { + this.seriesPositionReloadKeys.add(context.mutationKey); + } + this.seriesPositionsLoadGeneration++; + const previous = this.waitForSeriesPositionMutations( + context.mutationKey + ); + const result = previous.then(operation); + const barrier = result.then( + () => undefined, + () => undefined + ); + this.seriesPositionMutationQueues.set( + context.mutationKey, + barrier + ); + void barrier.then(() => { + if ( + this.seriesPositionMutationQueues.get( + context.mutationKey + ) === barrier + ) { + this.seriesPositionMutationQueues.delete( + context.mutationKey + ); + this.reloadSeriesPositionsAfterMutations( + context.mutationKey + ); + } }); - this.episodePlaybackPositions.set(positionsMap); + return result; + } + + private reloadSeriesPositionsAfterMutations( + mutationKey: string + ): void { + if (!this.seriesPositionReloadKeys.delete(mutationKey)) { + return; + } + const context = this.activeSeriesPositionContext; + if ( + !context || + context.mutationKey !== mutationKey || + !this.isSeriesPositionContextActive(context) || + this.hasCurrentPendingSeriesPositionLoad(context) + ) { + return; + } + void this.loadSeriesPositions(context); + } + + private getSeriesPositionMutationContext( + playlistId: string, + seriesXtreamId?: number | null + ): SeriesPositionContext | null { + const context = this.activeSeriesPositionContext; + if ( + !context || + context.playlistId !== playlistId || + (seriesXtreamId != null && + context.seriesXtreamId !== seriesXtreamId) + ) { + return null; + } + return context; + } + + private persistSeriesPosition( + playlistId: string, + position: PlaybackPositionData + ): Promise { + const context = this.getSeriesPositionMutationContext( + playlistId, + position.seriesXtreamId + ); + if (!context) { + return Promise.resolve(); + } + const legacyPosition = + this.legacyPositionByTrackingId().get( + position.contentXtreamId + ); + return this.enqueueSeriesPositionMutation(context, async () => { + let clearedLegacy: boolean; + try { + clearedLegacy = await saveStalkerSeriesPosition({ + repository: this.playbackPositions, + playlistId, + position, + legacyPosition, + }); + } catch (error) { + if ( + error instanceof + StalkerSeriesPositionPartialSaveError && + this.isSeriesPositionContextActive(context) + ) { + this.publishSavedSeriesPosition( + position, + legacyPosition, + false + ); + } + throw error; + } + if (!this.isSeriesPositionContextActive(context)) { + return; + } + this.publishSavedSeriesPosition( + position, + legacyPosition, + clearedLegacy + ); + }); + } + + private publishSavedSeriesPosition( + position: PlaybackPositionData, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([ + position.contentXtreamId, + ]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map( + this.legacyPositionByTrackingId() + ); + legacyPositions.delete(position.contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set([ + ...this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has( + candidate.contentXtreamId + ) + ), + position, + ]); + this.updateEpisodePlaybackPosition(position); + } + + private clearSeriesPosition( + playlistId: string, + contentXtreamId: number + ): Promise { + const context = this.getSeriesPositionMutationContext(playlistId); + if (!context) { + return Promise.resolve(); + } + const position = + this.episodePlaybackPositions().get(contentXtreamId) ?? { + contentXtreamId, + contentType: 'episode', + positionSeconds: 0, + playlistId, + seriesXtreamId: context.seriesXtreamId, + }; + const legacyPosition = + this.legacyPositionByTrackingId().get(contentXtreamId); + return this.enqueueSeriesPositionMutation(context, async () => { + const clearedLegacy = await clearStalkerSeriesPosition({ + repository: this.playbackPositions, + playlistId, + position, + legacyPosition, + }); + if (!this.isSeriesPositionContextActive(context)) { + return; + } + this.publishClearedSeriesPosition( + contentXtreamId, + legacyPosition, + clearedLegacy + ); + }); + } + + private publishClearedSeriesPosition( + contentXtreamId: number, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([contentXtreamId]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map( + this.legacyPositionByTrackingId() + ); + legacyPositions.delete(contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set( + this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has( + candidate.contentXtreamId + ) + ) + ); + this.removeEpisodePlaybackPosition(contentXtreamId); } private async loadAndPlayVodSeriesSeason( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts new file mode 100644 index 000000000..bc88b8662 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts @@ -0,0 +1,1182 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerVodSource, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + CrossPortalSimilarService, + DownloadsService, + PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; + +const PLAYLIST_ID = 'playlist-1'; +const SECOND_PLAYLIST_ID = 'playlist-2'; +const SERIES_A_ID = 100; +const SERIES_B_ID = 200; +const SCOPED_A_ID = 604391373; +const LEGACY_ID = 624320047; +const REGULAR_ID = 91189090; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function createVodItem(seriesId: number): StalkerVodSource { + return { + id: String(seriesId), + is_series: '1', + info: { + name: `Series ${seriesId}`, + description: 'Lazy series', + movie_image: 'poster.jpg', + }, + }; +} + +function createRegularItem(seriesId: number): StalkerVodSource { + return { + id: String(seriesId), + cmd: `/media/file_${seriesId}.mpg`, + info: { + name: `Regular Series ${seriesId}`, + description: 'Regular series', + movie_image: 'poster.jpg', + }, + }; +} + +function createSeason( + seriesId: number, + episodes: VodSeriesSeasonVm['episodes'] = [] +): VodSeriesSeasonVm { + return { + id: 'season-1', + video_id: String(seriesId), + name: 'Season 1', + season_number: '1', + episodes, + isLoading: false, + isExpanded: false, + }; +} + +function createProviderEpisode( + id = 'provider-episode-1', + episodeNumber = 1 +) { + return { + id, + series_number: episodeNumber, + name: episodeNumber === 1 ? 'Pilot' : `Episode ${episodeNumber}`, + }; +} + +function createPosition( + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId: SCOPED_A_ID, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 40, + durationSeconds: 100, + playlistId: PLAYLIST_ID, + ...overrides, + }; +} + +describe('StalkerSeriesViewComponent position compatibility', () => { + let fixture: ComponentFixture; + let repositoryRows: PlaybackPositionData[]; + let repositoryOrder: string[]; + let runtimePositionListener: + | ((position: PlaybackPositionData) => void) + | undefined; + + const selectedContentType = signal<'series' | 'vod'>('vod'); + const selectedItem = signal( + createVodItem(SERIES_A_ID) + ); + const currentPlaylist = signal<{ _id: string } | null>({ + _id: PLAYLIST_ID, + }); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + const fetchVodSeriesEpisodes = jest.fn(); + const getSeriesPlaybackPositions = jest.fn(); + const savePlaybackPosition = jest.fn(); + const clearPlaybackPosition = jest.fn(); + const onPlaybackPositionUpdate = jest.fn(); + + async function settle(): Promise { + for (let pass = 0; pass < 4; pass++) { + fixture.detectChanges(); + await Promise.resolve(); + } + } + + async function startWithLoadedEpisode( + seriesId = SERIES_A_ID + ): Promise { + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: String(seriesId), + name: 'Season 1', + season_number: '1', + }, + ]); + await settle(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(seriesId, [createProviderEpisode()]), + ]); + await settle(); + return Number( + fixture.componentInstance.mappedSeasons()['1'][0].id + ); + } + + async function startPendingTwoEpisodeLoad(): Promise<{ + firstPosition: PlaybackPositionData; + pendingLoad: Deferred; + secondPosition: PlaybackPositionData; + }> { + await startWithLoadedEpisode(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(SERIES_A_ID, [ + createProviderEpisode(), + createProviderEpisode('provider-episode-2', 2), + ]), + ]); + await settle(); + const [firstEpisode, secondEpisode] = + fixture.componentInstance.mappedSeasons()['1']; + const firstPosition = createPosition({ + contentXtreamId: Number(firstEpisode.id), + positionSeconds: 15, + }); + const secondPosition = createPosition({ + contentXtreamId: Number(secondEpisode.id), + episodeNumber: 2, + positionSeconds: 25, + }); + repositoryRows = [firstPosition, secondPosition]; + const pendingLoad = createDeferred(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => pendingLoad.promise + ); + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + return { firstPosition, pendingLoad, secondPosition }; + } + + beforeEach(async () => { + repositoryRows = []; + repositoryOrder = []; + runtimePositionListener = undefined; + selectedContentType.set('vod'); + selectedItem.set(createVodItem(SERIES_A_ID)); + currentPlaylist.set({ _id: PLAYLIST_ID }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([]); + + fetchVodSeriesEpisodes.mockReset(); + fetchVodSeriesEpisodes.mockResolvedValue([createProviderEpisode()]); + getSeriesPlaybackPositions.mockReset(); + getSeriesPlaybackPositions.mockImplementation( + async ( + _playlistId: string, + seriesXtreamId: number + ): Promise => + repositoryRows.filter( + (position) => + position.seriesXtreamId === seriesXtreamId + ) + ); + savePlaybackPosition.mockReset(); + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push( + `save:${position.contentXtreamId}` + ); + repositoryRows = repositoryRows.filter( + (row) => + row.contentXtreamId !== + position.contentXtreamId + ); + repositoryRows.push(position); + } + ); + clearPlaybackPosition.mockReset(); + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push(`clear:${contentXtreamId}`); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + } + ); + onPlaybackPositionUpdate.mockReset(); + onPlaybackPositionUpdate.mockImplementation( + ( + listener: (position: PlaybackPositionData) => void + ) => { + runtimePositionListener = listener; + return jest.fn(); + } + ); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => + serialSeasonsResource(), + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes, + resolveVodPlayback: jest.fn(), + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition, + clearPlaybackPosition, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { + provide: CrossPortalSimilarService, + useValue: { + isAvailable: false, + matchRecommendations: jest.fn(), + buildLink: jest.fn(), + }, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + navigateByUrl: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { + startDownload: jest.fn(), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { + template: '', + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('reconciles positions that arrive before lazy episodes', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 33, + }), + ]; + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: String(SERIES_A_ID), + name: 'Season 1', + season_number: '1', + }, + ]); + + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + const season = fixture.componentInstance.vodSeriesSeasons()[0]; + await fixture.componentInstance.loadEpisodesForSeason(season); + await settle(); + + expect( + Number( + fixture.componentInstance.mappedSeasons()['1'][0].id + ) + ).toBe(SCOPED_A_ID); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toEqual( + expect.objectContaining({ + contentXtreamId: SCOPED_A_ID, + positionSeconds: 33, + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + }) + ); + }); + + it('keeps exact progress while retaining legacy cleanup metadata for watched saves', async () => { + const exactPosition = createPosition({ positionSeconds: 88 }); + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 22, + }), + exactPosition, + ]; + await startWithLoadedEpisode(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(exactPosition); + + const watchedPosition = createPosition({ + positionSeconds: 100, + durationSeconds: 100, + }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: watchedPosition, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(watchedPosition); + + const seasons = fixture.componentInstance.vodSeriesSeasons(); + fixture.componentInstance.vodSeriesSeasons.set( + seasons.map((season) => ({ + ...season, + episodes: [...season.episodes], + })) + ); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 99 }), + }); + expect( + repositoryOrder.filter( + (entry) => entry === `clear:${LEGACY_ID}` + ) + ).toHaveLength(1); + }); + + it('publishes a partial save and retries the same legacy cleanup', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 22, + }), + ]; + await startWithLoadedEpisode(); + const clearError = new Error('clear failed'); + clearPlaybackPosition.mockRejectedValueOnce(clearError); + const newerPosition = createPosition({ positionSeconds: 70 }); + + await expect( + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: newerPosition, + }) + ).rejects.toEqual( + expect.objectContaining({ + cause: clearError, + scopedPositionSaved: true, + }) + ); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(newerPosition); + + const latestPosition = createPosition({ positionSeconds: 80 }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: latestPosition, + }); + + expect( + clearPlaybackPosition.mock.calls.map( + ([, contentXtreamId]) => contentXtreamId + ) + ).toEqual([LEGACY_ID, LEGACY_ID]); + expect(repositoryRows).toEqual([latestPosition]); + }); + + it('ignores a deferred series-A response after selection changes to series B', async () => { + const seriesAResponse = createDeferred< + PlaybackPositionData[] + >(); + const seriesBLegacy = createPosition({ + contentXtreamId: LEGACY_ID, + seriesXtreamId: SERIES_B_ID, + positionSeconds: 77, + }); + getSeriesPlaybackPositions.mockImplementation( + ( + _playlistId: string, + seriesXtreamId: number + ): Promise => + seriesXtreamId === SERIES_A_ID + ? seriesAResponse.promise + : Promise.resolve([seriesBLegacy]) + ); + + await settle(); + selectedItem.set(createVodItem(SERIES_B_ID)); + await settle(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(SERIES_B_ID, [createProviderEpisode()]), + ]); + await settle(); + const scopedSeriesBId = Number( + fixture.componentInstance.mappedSeasons()['1'][0].id + ); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(scopedSeriesBId)?.positionSeconds + ).toBe(77); + + seriesAResponse.resolve([ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 11, + }), + ]); + await settle(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(scopedSeriesBId)?.positionSeconds + ).toBe(77); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(1); + }); + + it('clears the previous playlist progress when the same series ID is selected', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 55, + }), + ]; + getSeriesPlaybackPositions.mockImplementation( + async ( + playlistId: string, + seriesXtreamId: number + ): Promise => + playlistId === PLAYLIST_ID + ? repositoryRows.filter( + (position) => + position.seriesXtreamId === + seriesXtreamId + ) + : [] + ); + await startWithLoadedEpisode(); + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID)?.positionSeconds + ).toBe(55); + + currentPlaylist.set({ _id: 'playlist-2' }); + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + expect(getSeriesPlaybackPositions).toHaveBeenLastCalledWith( + SECOND_PLAYLIST_ID, + SERIES_A_ID + ); + }); + + it('does not publish a deferred save after the playlist context switches away and back', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const secondPlaylistPosition = createPosition({ + playlistId: SECOND_PLAYLIST_ID, + positionSeconds: 80, + }); + const reloadedPosition = createPosition({ positionSeconds: 95 }); + let firstPlaylistLoads = 0; + getSeriesPlaybackPositions.mockImplementation( + async (playlistId: string): Promise => { + if (playlistId === SECOND_PLAYLIST_ID) { + return [secondPlaylistPosition]; + } + firstPlaylistLoads++; + return [ + firstPlaylistLoads === 1 + ? initialPosition + : reloadedPosition, + ]; + } + ); + savePlaybackPosition.mockImplementation(async () => { + await saveCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 60 }), + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + const loadsBeforeSaveCompleted = firstPlaylistLoads; + + saveCompletion.resolve(); + await pendingSave; + await settle(); + + expect(loadsBeforeSaveCompleted).toBe(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(reloadedPosition); + }); + + it('does not publish a deferred clear after the playlist context switches away and back', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const secondPlaylistPosition = createPosition({ + playlistId: SECOND_PLAYLIST_ID, + positionSeconds: 80, + }); + const reloadedPosition = createPosition({ positionSeconds: 95 }); + let firstPlaylistLoads = 0; + getSeriesPlaybackPositions.mockImplementation( + async (playlistId: string): Promise => { + if (playlistId === SECOND_PLAYLIST_ID) { + return [secondPlaylistPosition]; + } + firstPlaylistLoads++; + return [ + firstPlaylistLoads === 1 + ? initialPosition + : reloadedPosition, + ]; + } + ); + clearPlaybackPosition.mockImplementation(async () => { + await clearCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + const loadsBeforeClearCompleted = firstPlaylistLoads; + + clearCompletion.resolve(); + await pendingClear; + await settle(); + + expect(loadsBeforeClearCompleted).toBe(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(reloadedPosition); + }); + + it('does not publish a deferred save after the playlist signal changes before the context effect', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + savePlaybackPosition.mockImplementation(async () => { + await saveCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 60 }), + }); + await Promise.resolve(); + currentPlaylist.set({ _id: SECOND_PLAYLIST_ID }); + saveCompletion.resolve(); + await pendingSave; + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(initialPosition); + }); + + it('does not publish a deferred clear after the series signal changes before the context effect', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + clearPlaybackPosition.mockImplementation(async () => { + await clearCompletion.promise; + }); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + selectedItem.set(createVodItem(SERIES_B_ID)); + clearCompletion.resolve(); + await pendingClear; + + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(initialPosition); + }); + + it('serializes a slow save before a later clear so the clear wins', async () => { + const saveCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 60 }); + repositoryRows = [initialPosition]; + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push('save:start'); + await saveCompletion.promise; + repositoryRows = repositoryRows.filter( + (row) => + row.contentXtreamId !== + position.contentXtreamId + ); + repositoryRows.push(position); + repositoryOrder.push('save:end'); + } + ); + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push('clear'); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + } + ); + await startWithLoadedEpisode(); + + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await Promise.resolve(); + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + saveCompletion.resolve(); + await Promise.all([pendingSave, pendingClear]); + await settle(); + + expect(repositoryOrder).toEqual([ + 'save:start', + 'save:end', + 'clear', + ]); + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + }); + + it('serializes a slow clear before a later save so the save wins', async () => { + const clearCompletion = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 70 }); + repositoryRows = [initialPosition]; + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push('clear:start'); + await clearCompletion.promise; + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + repositoryOrder.push('clear:end'); + } + ); + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push('save'); + repositoryRows = repositoryRows.filter( + (row) => + row.contentXtreamId !== + position.contentXtreamId + ); + repositoryRows.push(position); + } + ); + await startWithLoadedEpisode(); + + const pendingClear = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await Promise.resolve(); + const pendingSave = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await Promise.resolve(); + clearCompletion.resolve(); + await Promise.all([pendingClear, pendingSave]); + await settle(); + + expect(repositoryOrder).toEqual([ + 'clear:start', + 'clear:end', + 'save', + ]); + expect(repositoryRows).toEqual([savedPosition]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(savedPosition); + }); + + it('does not let a load started before a save overwrite the saved position', async () => { + const staleLoad = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + const savedPosition = createPosition({ positionSeconds: 70 }); + repositoryRows = [initialPosition]; + await startWithLoadedEpisode(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => staleLoad.promise + ); + + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: savedPosition, + }); + await settle(); + staleLoad.resolve([initialPosition]); + await settle(); + + expect(repositoryRows).toEqual([savedPosition]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(savedPosition); + }); + + it('does not let a load started before a clear resurrect the cleared position', async () => { + const staleLoad = createDeferred(); + const initialPosition = createPosition({ positionSeconds: 15 }); + repositoryRows = [initialPosition]; + await startWithLoadedEpisode(); + getSeriesPlaybackPositions.mockImplementationOnce( + () => staleLoad.promise + ); + + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await settle(); + staleLoad.resolve([initialPosition]); + await settle(); + + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + }); + + it('reloads unrelated episode rows after a save invalidates a pending load', async () => { + const { firstPosition, pendingLoad, secondPosition } = + await startPendingTwoEpisodeLoad(); + const savedPosition = { + ...firstPosition, + positionSeconds: 70, + }; + + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: firstPosition.contentXtreamId, + nextPosition: savedPosition, + }); + await settle(); + pendingLoad.resolve([firstPosition, secondPosition]); + await settle(); + + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(3); + expect( + fixture.componentInstance.episodePlaybackPositions() + ).toEqual( + new Map([ + [savedPosition.contentXtreamId, savedPosition], + [secondPosition.contentXtreamId, secondPosition], + ]) + ); + }); + + it('reloads all episode rows after a mutation invalidates a pending load and rejects', async () => { + const { firstPosition, pendingLoad, secondPosition } = + await startPendingTwoEpisodeLoad(); + const saveError = new Error('save failed'); + savePlaybackPosition.mockRejectedValue(saveError); + + const mutation = + fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: firstPosition.contentXtreamId, + nextPosition: { + ...firstPosition, + positionSeconds: 70, + }, + }); + await expect(mutation).rejects.toBe(saveError); + await settle(); + pendingLoad.resolve([firstPosition, secondPosition]); + await settle(); + + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(3); + expect( + fixture.componentInstance.episodePlaybackPositions() + ).toEqual( + new Map([ + [firstPosition.contentXtreamId, firstPosition], + [secondPosition.contentXtreamId, secondPosition], + ]) + ); + }); + + it('migrates inline progress before cleanup and does not restore the legacy alias', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 25, + }), + ]; + await startWithLoadedEpisode(); + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://example.test/episode.mpg', + title: 'Series 100 - Pilot', + contentInfo: { + playlistId: PLAYLIST_ID, + contentXtreamId: SCOPED_A_ID, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + }, + }); + + fixture.componentInstance.handleInlineTimeUpdate({ + currentTime: 60, + duration: 100, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect(repositoryRows.map((row) => row.contentXtreamId)).toEqual([ + SCOPED_A_ID, + ]); + + const seasons = fixture.componentInstance.vodSeriesSeasons(); + fixture.componentInstance.vodSeriesSeasons.set( + seasons.map((season) => ({ + ...season, + episodes: [...season.episodes], + })) + ); + await settle(); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: createPosition({ positionSeconds: 70 }), + }); + + expect( + repositoryOrder.filter( + (entry) => entry === `clear:${LEGACY_ID}` + ) + ).toHaveLength(1); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID)?.positionSeconds + ).toBe(70); + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(1); + }); + + it('repeats a matching runtime save so the view can clear confirmed legacy progress', async () => { + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 30, + }), + ]; + await startWithLoadedEpisode(); + const runtimePosition = createPosition({ positionSeconds: 65 }); + + expect(runtimePositionListener).toBeDefined(); + runtimePositionListener?.(runtimePosition); + await settle(); + + expect(repositoryOrder).toEqual([ + `save:${SCOPED_A_ID}`, + `clear:${LEGACY_ID}`, + ]); + expect( + fixture.componentInstance + .episodePlaybackPositions() + .get(SCOPED_A_ID) + ).toBe(runtimePosition); + }); + + it('clears confirmed legacy before scoped watched state without resurrection after refetch', async () => { + const exactPosition = createPosition({ + positionSeconds: 100, + durationSeconds: 100, + }); + repositoryRows = [ + createPosition({ + contentXtreamId: LEGACY_ID, + positionSeconds: 20, + }), + exactPosition, + ]; + await startWithLoadedEpisode(); + + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: null, + }); + await settle(); + + expect(repositoryOrder).toEqual([ + `clear:${LEGACY_ID}`, + `clear:${SCOPED_A_ID}`, + ]); + expect(repositoryRows).toEqual([]); + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + + currentPlaylist.set(null); + await settle(); + currentPlaylist.set({ _id: PLAYLIST_ID }); + await settle(); + + expect( + fixture.componentInstance.episodePlaybackPositions().size + ).toBe(0); + expect(getSeriesPlaybackPositions).toHaveBeenCalledTimes(2); + }); + + it('does not clear legacy state for regular-series episodes', async () => { + selectedContentType.set('series'); + selectedItem.set(createRegularItem(SERIES_A_ID)); + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_100.mpg', + series: [1], + }, + ]); + repositoryRows = [ + createPosition({ + contentXtreamId: REGULAR_ID, + positionSeconds: 45, + }), + ]; + await settle(); + + const watchedPosition = createPosition({ + contentXtreamId: REGULAR_ID, + positionSeconds: 100, + }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: REGULAR_ID, + nextPosition: watchedPosition, + }); + + expect(savePlaybackPosition).toHaveBeenCalledWith( + PLAYLIST_ID, + watchedPosition + ); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + }); + + it('does not clear a coordinate-conflicting legacy row', async () => { + const exactPosition = createPosition({ positionSeconds: 50 }); + repositoryRows = [ + exactPosition, + createPosition({ + contentXtreamId: LEGACY_ID, + seasonNumber: 9, + positionSeconds: 25, + }), + ]; + await startWithLoadedEpisode(); + + const watchedPosition = createPosition({ positionSeconds: 100 }); + await fixture.componentInstance.handlePlaybackToggleRequested({ + contentXtreamId: SCOPED_A_ID, + nextPosition: watchedPosition, + }); + + expect(savePlaybackPosition).toHaveBeenCalledWith( + PLAYLIST_ID, + watchedPosition + ); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + }); +});