mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(portal): let a failed lazy-season fetch retry the Stalker resume handoff
A transient portal failure while hydrating the season a resume target lives in left the claim held, so the handoff could never start that episode again for the lifetime of the detail host. Releasing the claim on every failure is not the fix: a failed fetch flips the season's `isLoading` back and leaves it unloaded, which re-runs the effect that asked, so an unconditional release would request the season forever from a portal that is simply down. `StalkerResumeSeasonHydration` bounds it at two attempts instead, and its state is a signal — nothing else changes when the fetch settles, so a plain field would never wake the effect to spend the retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f430dec851
commit
ad624182ca
4 files changed
+248
-5
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-4
@@ -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<string | null>(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<number, PlaybackPositionData>
|
||||
>(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(() => {
|
||||
|
||||
+105
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user