diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts index 18278bfc1..46708c1be 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts @@ -5,7 +5,9 @@ import type { } from '@iptvnator/portal/stalker/data-access'; import { findStalkerResumeLazySeason, + MAX_RESUME_SEASON_HYDRATION_ATTEMPTS, resolveStalkerResumeEpisode, + StalkerResumeSeasonHydration, stalkerSeriesResumeKey, } from './stalker-series-resume'; @@ -151,6 +153,59 @@ describe('findStalkerResumeLazySeason', () => { }); }); +describe('StalkerResumeSeasonHydration', () => { + it('holds the claim while a request is in flight', () => { + const hydration = new StalkerResumeSeasonHydration(); + + expect(hydration.canRequest('a')).toBe(true); + hydration.begin('a'); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('allows a bounded retry after a failed fetch, then gives up', () => { + // A failed fetch leaves the season unloaded and re-runs the effect, + // so an unbounded release would loop against a dead portal. + const hydration = new StalkerResumeSeasonHydration(); + + for ( + let attempt = 1; + attempt < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS; + attempt++ + ) { + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(true); + } + + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('stops asking once the portal answered', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.settle('a', true); + + expect(hydration.canRequest('a')).toBe(false); + }); + + it('starts a different target fresh and ignores a late settlement', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.begin('b'); + // 'a' settling late must not hand 'b' an extra attempt. + hydration.settle('a', false); + expect(hydration.canRequest('b')).toBe(false); + + hydration.settle('b', false); + expect(hydration.canRequest('b')).toBe(true); + expect(hydration.canRequest('a')).toBe(true); + }); +}); + describe('stalkerSeriesResumeKey', () => { it('identifies one handoff per playlist and coordinates', () => { expect(stalkerSeriesResumeKey('stalker-1', target)).toBe( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts index 9d62616c9..a17021a17 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts @@ -1,4 +1,4 @@ -import { InjectionToken, Signal, signal } from '@angular/core'; +import { InjectionToken, Signal, signal, WritableSignal } from '@angular/core'; import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonNumber, @@ -70,6 +70,75 @@ export function resolveStalkerResumeEpisode(options: { ); } +/** + * How many times one resume target may ask the portal for the lazy season + * it lives in. + * + * A failed fetch flips the season's `isLoading` back and leaves it + * unloaded, which re-runs the effect that asked — so releasing the claim on + * every failure would hammer a portal that is simply down, while holding it + * forever strands the handoff after a single transient error. Two attempts + * buys the recovery and then stops: the detail is open either way, and its + * "Resume episode" button hydrates the same season on demand. + */ +export const MAX_RESUME_SEASON_HYDRATION_ATTEMPTS = 2; + +/** + * The lazy-season hydration a resume target has asked for, so the owning + * effect can tell "already in flight" from "failed, may try once more" from + * "given up". Scoped to one series view; a different target starts fresh. + */ +export class StalkerResumeSeasonHydration { + /** + * A signal, not a plain field: nothing else changes when a fetch fails + * (the season's own `isLoading` has already flipped back by then), so a + * reader effect would never re-run to spend the retry. + */ + private readonly claim: WritableSignal<{ + key: string; + attempts: number; + pending: boolean; + } | null> = signal(null); + + /** May this target request its season now? */ + canRequest(key: string): boolean { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return true; + } + return ( + !claim.pending && + claim.attempts < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + ); + } + + /** Claim the request before dispatching it. */ + begin(key: string): void { + const claim = this.claim(); + const attempts = claim?.key === key ? claim.attempts : 0; + this.claim.set({ key, attempts: attempts + 1, pending: true }); + } + + /** + * Record how the request ended. A late settlement for a target that is + * no longer the one being hydrated is ignored, so it cannot hand a + * retry to whatever claimed the slot after it. + */ + settle(key: string, answered: boolean): void { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return; + } + this.claim.set({ + key, + attempts: answered + ? MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + : claim.attempts, + pending: false, + }); + } +} + /** * For a lazy Ministra VOD series: the not-yet-hydrated season the target * lives in, so the host can fetch it before the episode can be resolved. 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 65fea60d6..417b8f293 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 @@ -21,6 +21,7 @@ import { findStalkerResumeLazySeason, resolveStalkerResumeEpisode, STALKER_SERIES_RESUME_TARGET, + StalkerResumeSeasonHydration, stalkerSeriesResumeKey, } from './stalker-series-resume'; import { @@ -235,7 +236,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly seriesPositionsLoadedKey = signal(null); private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET); private consumedSeriesResumeKey: string | null = null; - private seriesResumeSeasonLoadKey: string | null = null; + private readonly seriesResumeSeasonHydration = + new StalkerResumeSeasonHydration(); private readonly legacyPositionByTrackingId = signal< Map >(new Map()); @@ -505,11 +507,23 @@ export class StalkerSeriesViewComponent implements OnDestroy { const lazySeason = this.isVodSeries() ? findStalkerResumeLazySeason({ target, seasons }) : null; - if (!lazySeason || this.seriesResumeSeasonLoadKey === resumeKey) { + if ( + !lazySeason || + !this.seriesResumeSeasonHydration.canRequest(resumeKey) + ) { return; } - this.seriesResumeSeasonLoadKey = resumeKey; - untracked(() => void this.loadEpisodesForSeason(lazySeason)); + this.seriesResumeSeasonHydration.begin(resumeKey); + untracked( + () => + void this.loadEpisodesForSeason(lazySeason).then( + (answered) => + this.seriesResumeSeasonHydration.settle( + resumeKey, + answered + ) + ) + ); }); effect(() => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts index 9b307666a..8c7aa38d6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts @@ -251,6 +251,111 @@ describe('StalkerSeriesViewComponent dashboard resume handoff', () => { expect(resolveVodPlayback).not.toHaveBeenCalled(); }); + it('retries the lazy season once after a transient portal failure', async () => { + // A failed fetch leaves the season unloaded, so the handoff would + // otherwise be stranded for the lifetime of this detail host. + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' }, + }); + serialSeasons.set([]); + vodSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes + .mockRejectedValueOnce(new Error('portal down')) + .mockResolvedValue([ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ]); + getSeriesPlaybackPositions.mockResolvedValue([ + { + playlistId: 'stalker-1', + contentXtreamId: 123_456, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 90, + durationSeconds: 1500, + } satisfies PlaybackPositionData, + ]); + seriesResumeTarget.set({ + seriesXtreamId: 50001, + contentXtreamId: 123_456, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + await settle(); + await settle(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second', + 'vod.jpg', + 2, + trackingIdOf('1', 2), + 90 + ); + }); + + it('stops asking the portal after the bounded retry also fails', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' }, + }); + serialSeasons.set([]); + vodSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes.mockRejectedValue(new Error('portal down')); + getSeriesPlaybackPositions.mockResolvedValue([ + { + playlistId: 'stalker-1', + contentXtreamId: 123_456, + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 90, + durationSeconds: 1500, + } satisfies PlaybackPositionData, + ]); + seriesResumeTarget.set({ + seriesXtreamId: 50001, + contentXtreamId: 123_456, + seasonNumber: 1, + episodeNumber: 2, + }); + + await settle(); + await settle(); + await settle(); + await settle(); + + // Bounded: a portal that is simply down is asked twice, not forever. + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).not.toHaveBeenCalled(); + }); + it('hydrates the lazy VOD season the handoff lives in, then resumes its episode', async () => { selectedContentType.set('vod'); selectedItem.set({