mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
fix(playback): harden runtime guidance ownership
This commit is contained in:
1 parent
f6328bc873
commit
68b7bdcd51
9 files changed
+422
-52
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Playback recovery now removes old error actions as soon as a new source or player starts, even while Electron prepares stream headers. Stalker series playing inline now picks up refreshed episode names and navigation, and safely disables episode commands if the playing episode disappears.
|
||||
@@ -55,6 +55,15 @@ short-lived exact pending-request snapshot/guard that locates the selected
|
||||
episode and rejects stale or out-of-order completion. This keeps token refreshes
|
||||
in one logical session while ensuring colliding tracking hashes cannot select a
|
||||
sibling episode.
|
||||
After a Stalker episode mounts, the host retains only a frozen structural
|
||||
identity (source, normalized parent, series mode, exact season key, season and
|
||||
episode coordinates, and the credential-free session key). Each metadata,
|
||||
navigation, or autoplay read resolves those coordinates against the current
|
||||
`mappedSeasons()` value. Same-owner provider or TMDB refreshes therefore update
|
||||
the mounted episode without changing its session key; if the episode disappears,
|
||||
or if multiple episodes claim the same structural coordinates, the mounted
|
||||
commands fail closed. The command/ID-bearing identity never outlives the pending
|
||||
playback request.
|
||||
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
|
||||
the key unchanged to `WebPlayerViewComponent`.
|
||||
|
||||
@@ -629,13 +638,21 @@ nullable binding, both opaque tokens, and its live/VOD flag. A time update can
|
||||
change the resume position only while that exact capture still owns the current
|
||||
application, so a replaced source cannot repopulate cleared handoff state.
|
||||
|
||||
Selecting a built-in recommendation records the target, clears the diagnostic,
|
||||
and installs a temporary local override ahead of the host override and saved
|
||||
player setting. The new engine receives the latest finite VOD position as a
|
||||
best-effort resume point; live playback starts at the live edge. Retry reloads
|
||||
the active target without clearing attempts. Selecting MPV or VLC records the
|
||||
external target before emitting the existing fallback request. The system does
|
||||
not infer whether the external process ultimately played the stream.
|
||||
A separate fieldless intent token invalidates on each new source, target, or
|
||||
reload intent. It clears the prior diagnostic from visible and actionable state
|
||||
before any asynchronous Electron header handoff completes, without retaining
|
||||
source material. False or rejected current handoffs leave it detached, and a
|
||||
stale success, false result, or rejection cannot restore the old diagnostic or
|
||||
mutate the newer application state.
|
||||
|
||||
Selecting a built-in recommendation records the target, immediately detaches
|
||||
the diagnostic, and installs a temporary local override ahead of the host
|
||||
override and saved player setting. The new engine receives the latest finite
|
||||
VOD position as a best-effort resume point; live playback starts at the live
|
||||
edge. Retry reloads the active target without clearing attempts. Selecting MPV
|
||||
or VLC records the external target before emitting the existing fallback
|
||||
request. The system does not infer whether the external process ultimately
|
||||
played the stream.
|
||||
|
||||
No recommendation mutates `Settings.player` or another persisted setting.
|
||||
Recovery recommendations never auto-switch a player or source and do not
|
||||
|
||||
@@ -1162,7 +1162,19 @@ Series inline playback behavior is shared across all three modes:
|
||||
blocks the action rather than binding regular, embedded, or lazy VOD content
|
||||
to another mode; an exact canonical episode id still wins.
|
||||
|
||||
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
|
||||
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and uses
|
||||
two episode identities. A pending playback request keeps an exact,
|
||||
request-local provider command or episode ID so command rotation and hash
|
||||
collisions reject stale completion. Once mounted, the component freezes only
|
||||
the credential-free structural identity and session key (source, normalized
|
||||
parent, mode, season key, season number, and episode number).
|
||||
- The mounted structural identity is re-resolved against the current
|
||||
`mappedSeasons()` for metadata, Previous/Next, and autoplay. Same-owner
|
||||
provider and TMDB refreshes therefore expose current episode objects and
|
||||
commands without remounting the player; if the episode is missing or its
|
||||
structural coordinates are ambiguous, those surfaces and commands fail
|
||||
closed. Provider commands and IDs are never retained in mounted session
|
||||
state.
|
||||
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
|
||||
- Embedded players receive previous/next episode state for the current season only.
|
||||
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
|
||||
|
||||
+60
@@ -5,6 +5,8 @@ import {
|
||||
captureStalkerEpisodePlaybackSessionIdentity,
|
||||
createStalkerEpisodePlaybackSessionKey,
|
||||
resolveStalkerEpisodeStateByIdentity,
|
||||
resolveStalkerEpisodeStateByStructuralIdentity,
|
||||
toStalkerEpisodePlaybackStructuralIdentity,
|
||||
} from './stalker-episode-playback-session-key';
|
||||
|
||||
const episodeState = (
|
||||
@@ -103,6 +105,64 @@ describe('createStalkerEpisodePlaybackSessionKey', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes only credential-free structural fields for a mounted session', () => {
|
||||
const originalCmd =
|
||||
'https://user:password@stream.example/episode.mpg?access_token=secret';
|
||||
const identity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||
sourceId: 'playlist',
|
||||
parentSeriesId: 'series',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: episodeState({ originalCmd }),
|
||||
});
|
||||
if (!identity) throw new Error('Expected a captured request identity');
|
||||
|
||||
const retained = toStalkerEpisodePlaybackStructuralIdentity(identity);
|
||||
|
||||
expect(Object.isFrozen(retained)).toBe(true);
|
||||
expect(retained).toEqual({
|
||||
sourceId: identity.sourceId,
|
||||
parentSeriesId: identity.parentSeriesId,
|
||||
seriesMode: identity.seriesMode,
|
||||
seasonKey: identity.seasonKey,
|
||||
seasonNumber: identity.seasonNumber,
|
||||
episodeNumber: identity.episodeNumber,
|
||||
sessionKey: identity.sessionKey,
|
||||
});
|
||||
expect(JSON.stringify(retained)).not.toContain(originalCmd);
|
||||
expect(retained).not.toHaveProperty('originalEpisodeIdentity');
|
||||
});
|
||||
|
||||
it('fails closed when structural coordinates match multiple episodes', () => {
|
||||
const state = episodeState({ originalCmd: 'command-a' });
|
||||
const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||
sourceId: 'playlist',
|
||||
parentSeriesId: 'series',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: state,
|
||||
});
|
||||
if (!requestIdentity) {
|
||||
throw new Error('Expected a captured request identity');
|
||||
}
|
||||
const identity =
|
||||
toStalkerEpisodePlaybackStructuralIdentity(requestIdentity);
|
||||
|
||||
expect(
|
||||
resolveStalkerEpisodeStateByStructuralIdentity({
|
||||
episodesBySeason: {
|
||||
[state.seasonKey]: [
|
||||
state.episode,
|
||||
{
|
||||
...state.episode,
|
||||
id: 'another-tracking-id',
|
||||
originalCmd: 'command-b',
|
||||
},
|
||||
],
|
||||
},
|
||||
identity,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => {
|
||||
const stateA = episodeState({ originalId: 'provider|episode:token-a' });
|
||||
const stateB = episodeState({ originalId: 'provider|episode:token-b' });
|
||||
|
||||
+56
-3
@@ -28,6 +28,16 @@ export interface StalkerEpisodePlaybackSessionIdentity {
|
||||
readonly sessionKey: string;
|
||||
}
|
||||
|
||||
export interface StalkerEpisodePlaybackStructuralIdentity {
|
||||
readonly sourceId: string;
|
||||
readonly parentSeriesId: string;
|
||||
readonly seriesMode: StalkerSeriesDownloadMode;
|
||||
readonly seasonKey: string;
|
||||
readonly seasonNumber: number;
|
||||
readonly episodeNumber: number;
|
||||
readonly sessionKey: string;
|
||||
}
|
||||
|
||||
interface StalkerEpisodeStateLookupOptions {
|
||||
readonly episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>;
|
||||
}
|
||||
@@ -104,6 +114,29 @@ export function createStalkerEpisodePlaybackSessionKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function toStalkerEpisodePlaybackStructuralIdentity(
|
||||
identity: StalkerEpisodePlaybackSessionIdentity
|
||||
): StalkerEpisodePlaybackStructuralIdentity {
|
||||
const {
|
||||
sourceId,
|
||||
parentSeriesId,
|
||||
seriesMode,
|
||||
seasonKey,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
sessionKey,
|
||||
} = identity;
|
||||
return Object.freeze({
|
||||
sourceId,
|
||||
parentSeriesId,
|
||||
seriesMode,
|
||||
seasonKey,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
sessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason,
|
||||
episode,
|
||||
@@ -155,13 +188,30 @@ export function resolveStalkerEpisodeStateByIdentity({
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveStalkerEpisodeStateByStructuralIdentity({
|
||||
episodesBySeason,
|
||||
identity,
|
||||
}: StalkerEpisodeStateLookupOptions & {
|
||||
readonly identity: StalkerEpisodePlaybackStructuralIdentity;
|
||||
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
return findStalkerEpisodeState(
|
||||
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
|
||||
(_candidate, state) =>
|
||||
state.seasonNumber === identity.seasonNumber &&
|
||||
state.episodeNumber === identity.episodeNumber,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function findStalkerEpisodeState(
|
||||
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
|
||||
matches: (
|
||||
episode: XtreamSerieEpisode,
|
||||
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
|
||||
) => boolean
|
||||
) => boolean,
|
||||
requireUnique = false
|
||||
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
let match: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null = null;
|
||||
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
|
||||
for (
|
||||
let episodeIndex = 0;
|
||||
@@ -177,8 +227,11 @@ function findStalkerEpisodeState(
|
||||
previous: episodes[episodeIndex - 1] ?? null,
|
||||
next: episodes[episodeIndex + 1] ?? null,
|
||||
};
|
||||
if (matches(episode, state)) return state;
|
||||
if (!matches(episode, state)) continue;
|
||||
if (!requireUnique) return state;
|
||||
if (match) return null;
|
||||
match = state;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return match;
|
||||
}
|
||||
+33
-18
@@ -88,7 +88,10 @@ import {
|
||||
captureStalkerEpisodePlaybackSessionIdentity,
|
||||
resolveSelectedStalkerEpisodeState,
|
||||
resolveStalkerEpisodeStateByIdentity,
|
||||
resolveStalkerEpisodeStateByStructuralIdentity,
|
||||
toStalkerEpisodePlaybackStructuralIdentity,
|
||||
type StalkerEpisodePlaybackSessionIdentity,
|
||||
type StalkerEpisodePlaybackStructuralIdentity,
|
||||
} from './stalker-episode-playback-session-key';
|
||||
|
||||
interface SeriesPositionContext {
|
||||
@@ -186,8 +189,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
private seriesPositionsLoadGeneration = 0;
|
||||
private seriesPlaybackRequestGeneration = 0;
|
||||
private currentSeriesPlaybackOwnerKey = '';
|
||||
private readonly inlinePlaybackEpisodeState =
|
||||
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
|
||||
private readonly inlinePlaybackEpisodeIdentity =
|
||||
signal<StalkerEpisodePlaybackStructuralIdentity | null>(null);
|
||||
private lastSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
@@ -578,9 +581,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
vodSeriesSeasons: this.vodSeriesSeasons(),
|
||||
});
|
||||
});
|
||||
readonly inlineEpisodeState = computed(() =>
|
||||
this.inlinePlaybackEpisodeState()
|
||||
);
|
||||
readonly inlineEpisodeState = computed(() => {
|
||||
const identity = this.inlinePlaybackEpisodeIdentity();
|
||||
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
|
||||
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
|
||||
if (
|
||||
!identity ||
|
||||
this.playbackSessionKey() !== identity.sessionKey ||
|
||||
sourceId !== identity.sourceId ||
|
||||
parentSeriesId !== identity.parentSeriesId ||
|
||||
this.seriesMode() !== identity.seriesMode
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolveStalkerEpisodeStateByStructuralIdentity({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
identity,
|
||||
});
|
||||
});
|
||||
readonly playbackSessionKey = signal('');
|
||||
readonly inlineEpisodeMetadata = computed(() =>
|
||||
getSeriesEpisodeMetadata(this.inlineEpisodeState())
|
||||
@@ -755,7 +773,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
const mappedEpisode = episode as StalkerMappedEpisode;
|
||||
const item = this.displayItem();
|
||||
const episodeState = resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
@@ -764,14 +781,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
if (!item || !episodeState) return;
|
||||
this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey());
|
||||
|
||||
const mappedEpisode = episodeState.episode as StalkerMappedEpisode;
|
||||
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
|
||||
const command = isLazyVod
|
||||
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
|
||||
: mappedEpisode.originalCmd;
|
||||
const title = isLazyVod
|
||||
? `${item.info.name} - ${episode.title || `Episode ${episodeState.episodeNumber}`}`
|
||||
? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}`
|
||||
: item.info.name;
|
||||
const trackingId = Number(episode.id);
|
||||
const trackingId = Number(mappedEpisode.id);
|
||||
const startTime =
|
||||
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
|
||||
|
||||
@@ -839,7 +857,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
closeInlinePlayer(): void {
|
||||
this.seriesPlaybackRequestGeneration += 1;
|
||||
this.inlinePlayback.set(null);
|
||||
this.inlinePlaybackEpisodeState.set(null);
|
||||
this.inlinePlaybackEpisodeIdentity.set(null);
|
||||
this.playbackSessionKey.set('');
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
@@ -963,11 +981,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
this.lastSaveTime = 0;
|
||||
if (request.usesEmbeddedPlayer && request.identity) {
|
||||
this.setInlinePlayback(
|
||||
resolvedPlayback,
|
||||
request.identity.sessionKey,
|
||||
episodeState
|
||||
);
|
||||
this.setInlinePlayback(resolvedPlayback, request.identity);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1017,11 +1031,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
private setInlinePlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
sessionKey: string,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
identity: StalkerEpisodePlaybackSessionIdentity
|
||||
): void {
|
||||
this.playbackSessionKey.set(sessionKey);
|
||||
this.inlinePlaybackEpisodeState.set(episodeState);
|
||||
this.playbackSessionKey.set(identity.sessionKey);
|
||||
this.inlinePlaybackEpisodeIdentity.set(
|
||||
toStalkerEpisodePlaybackStructuralIdentity(identity)
|
||||
);
|
||||
this.inlinePlayback.set(playback);
|
||||
}
|
||||
|
||||
|
||||
+189
-1
@@ -35,6 +35,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
const serialSeasons = signal<unknown[]>([]);
|
||||
const vodSeasons = signal<unknown[]>([]);
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const tmdbGetSeason = jest.fn();
|
||||
const currentPlaylist = signal({
|
||||
_id: 'stalker|playlist',
|
||||
title: 'Portal',
|
||||
@@ -78,6 +79,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
tmdbGetSeason.mockReset().mockResolvedValue(null);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSeriesViewComponent],
|
||||
@@ -124,7 +126,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: {
|
||||
isEnabled: () => false,
|
||||
getSeason: jest.fn(),
|
||||
getSeason: tmdbGetSeason,
|
||||
getSeasonEpisodes: jest.fn(),
|
||||
},
|
||||
},
|
||||
@@ -471,6 +473,192 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
});
|
||||
|
||||
it('re-resolves mounted episode metadata and neighbors after a same-owner season refresh', async () => {
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'regular-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://regular|full-command',
|
||||
series: [1, 2, 3],
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const initialEpisodes = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0] as StalkerMappedEpisode[];
|
||||
fixture.componentInstance.onEpisodeClicked(initialEpisodes[1]);
|
||||
await fixture.whenStable();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'refreshed-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://refreshed|full-command',
|
||||
series: [1, 2, 3],
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const refreshedEpisodes = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0] as StalkerMappedEpisode[];
|
||||
const state = fixture.componentInstance.inlineEpisodeState();
|
||||
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
expect(state?.episode).toBe(refreshedEpisodes[1]);
|
||||
expect(state?.previous).toBe(refreshedEpisodes[0]);
|
||||
expect(state?.next).toBe(refreshedEpisodes[2]);
|
||||
|
||||
fixture.componentInstance.handleInlinePlaybackEnded();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
|
||||
'ffrt4://refreshed|full-command',
|
||||
'Series',
|
||||
'poster.jpg',
|
||||
3,
|
||||
Number(refreshedEpisodes[2].id),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('re-resolves mounted episode metadata after same-owner TMDB enrichment', async () => {
|
||||
await playFirstEpisode();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
tmdbGetSeason.mockResolvedValue({
|
||||
overview: 'Season overview',
|
||||
episodes: [{ episode_number: 1, name: 'TMDB Pilot' }],
|
||||
});
|
||||
const item = selectedItem();
|
||||
if (!item) throw new Error('Expected the selected series');
|
||||
selectedItem.set({
|
||||
...item,
|
||||
info: { ...item.info, tmdb_id: 314 },
|
||||
});
|
||||
|
||||
fixture.componentInstance.onSeasonSelected('1');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const currentEpisode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0];
|
||||
|
||||
expect(tmdbGetSeason).toHaveBeenCalledWith(314, 1);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
expect(fixture.componentInstance.inlineEpisodeState()?.episode).toBe(
|
||||
currentEpisode
|
||||
);
|
||||
expect(fixture.componentInstance.inlineEpisodeMetadata()?.title).toBe(
|
||||
'TMDB Pilot'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the current mapped episode when a stale lazy-series event resolves', async () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: 'series|parent',
|
||||
is_series: true,
|
||||
info: {
|
||||
name: 'Lazy series',
|
||||
movie_image: 'poster.jpg',
|
||||
tmdb_id: 2718,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.componentInstance.vodSeriesSeasons.set([
|
||||
{
|
||||
id: 'lazy-season',
|
||||
video_id: 'series|parent',
|
||||
season_number: '2',
|
||||
name: 'Season 2',
|
||||
episodes: [
|
||||
{
|
||||
id: 'provider-episode',
|
||||
series_number: 3,
|
||||
name: 'Episode 3',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const staleEpisode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0] as StalkerMappedEpisode;
|
||||
tmdbGetSeason.mockResolvedValue({
|
||||
episodes: [{ episode_number: 3, name: 'Current TMDB title' }],
|
||||
});
|
||||
|
||||
fixture.componentInstance.onSeasonSelected('2');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
Object.values(fixture.componentInstance.mappedSeasons())[0][0].title
|
||||
).toBe('Current TMDB title');
|
||||
|
||||
fixture.componentInstance.onEpisodeClicked(staleEpisode);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
|
||||
'/media/file_provider-episode.mpg',
|
||||
'Lazy series - Current TMDB title',
|
||||
'poster.jpg',
|
||||
3,
|
||||
expect.any(Number),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('fails mounted episode commands closed when a same-owner refresh removes the episode', async () => {
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'regular-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://regular|full-command',
|
||||
series: [1, 2, 3],
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const episodes = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0] as StalkerMappedEpisode[];
|
||||
fixture.componentInstance.onEpisodeClicked(episodes[1]);
|
||||
await fixture.whenStable();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'refreshed-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://refreshed|full-command',
|
||||
series: [1],
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
expect(fixture.componentInstance.inlineEpisodeState()).toBeNull();
|
||||
expect(fixture.componentInstance.inlineEpisodeMetadata()).toBeNull();
|
||||
expect(fixture.componentInstance.inlineSeriesNavigation()).toBeNull();
|
||||
|
||||
fixture.componentInstance.playPreviousEpisode();
|
||||
fixture.componentInstance.playNextEpisode();
|
||||
fixture.componentInstance.handleInlinePlaybackEnded();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
+19
-21
@@ -732,7 +732,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
expect(playerActionIds()).toEqual(['playback-recommendation-html5']);
|
||||
});
|
||||
|
||||
it('prevents a second rendered action while a player switch is pending', async () => {
|
||||
it('detaches the diagnostic while a player switch handoff is pending', async () => {
|
||||
const fallbackRequests: unknown[] = [];
|
||||
component.externalFallbackRequested.subscribe((request) =>
|
||||
fallbackRequests.push(request)
|
||||
@@ -742,13 +742,13 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
fixture.detectChanges();
|
||||
holdHeaderHandoff = true;
|
||||
const htmlButton = requiredButton('playback-recommendation-html5');
|
||||
const staleMpvButton = requiredButton('playback-fallback-mpv');
|
||||
|
||||
htmlButton.click();
|
||||
fixture.detectChanges();
|
||||
const mpvButton = requiredButton('playback-fallback-mpv');
|
||||
expect(query('playback-diagnostic-banner')).not.toBeNull();
|
||||
expect(mpvButton.disabled).toBe(true);
|
||||
mpvButton.click();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
staleMpvButton.click();
|
||||
expect(fallbackRequests).toEqual([]);
|
||||
|
||||
expect(headerResolvers).toHaveLength(1);
|
||||
@@ -783,17 +783,16 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
click('playback-recommendation-html5');
|
||||
fixture.detectChanges();
|
||||
expect(component.recoveryPending()).toBe(true);
|
||||
expect(requiredButton('playback-fallback-mpv').disabled).toBe(true);
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
|
||||
complete();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recoveryPending()).toBe(false);
|
||||
expect(requiredButton('playback-fallback-mpv').disabled).toBe(
|
||||
false
|
||||
);
|
||||
expect(component.playbackDiagnostic()).not.toBeNull();
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
expect(component.channel).toBeUndefined();
|
||||
}
|
||||
);
|
||||
@@ -827,8 +826,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recoveryPending()).toBe(true);
|
||||
expect(requiredButton('playback-fallback-mpv').disabled).toBe(true);
|
||||
expect(component.playbackDiagnostic()).not.toBeNull();
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(component.channel).toBeUndefined();
|
||||
|
||||
headerResolvers[1](false);
|
||||
@@ -840,7 +838,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
await render();
|
||||
vjs().playbackIssue.emit(mediaIssue('videojs'));
|
||||
fixture.detectChanges();
|
||||
const diagnostic = component.playbackDiagnostic();
|
||||
const failures: PlaybackDiagnosticCode[] = [];
|
||||
component.playbackFailed.subscribe((code) => failures.push(code));
|
||||
holdHeaderHandoff = true;
|
||||
@@ -851,6 +848,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
expect(sourceABinding).not.toBeNull();
|
||||
expect(headerResolvers).toHaveLength(1);
|
||||
expect(component.channel).toBeUndefined();
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
|
||||
setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' });
|
||||
const pendingState = {
|
||||
@@ -873,7 +871,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
vjsOptions: component.vjsOptions,
|
||||
failures,
|
||||
}).toEqual(pendingState);
|
||||
expect(component.playbackDiagnostic()).toBe(diagnostic);
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(streamHeaders.apply).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.detectChanges();
|
||||
@@ -881,7 +879,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
expect(component.activeBinding()).not.toBe(sourceABinding);
|
||||
headerResolvers[1](true);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.channel?.url).toBe(
|
||||
'https://example.com/source-b.m3u8'
|
||||
@@ -1060,7 +1057,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('clears a diagnostic only after the current header handoff succeeds', async () => {
|
||||
it('keeps a replaced-source diagnostic detached across every handoff outcome', async () => {
|
||||
await render();
|
||||
vjs().playbackIssue.emit(mediaIssue('videojs'));
|
||||
fixture.detectChanges();
|
||||
@@ -1068,27 +1065,28 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
|
||||
setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' });
|
||||
fixture.detectChanges();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
setPlayback({ streamUrl: 'https://example.com/current.m3u8' });
|
||||
fixture.detectChanges();
|
||||
expect(headerResolvers).toHaveLength(2);
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
|
||||
headerResolvers[0](true);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(query('playback-diagnostic-banner')).not.toBeNull();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
|
||||
headerResolvers[1](false);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(query('playback-diagnostic-banner')).not.toBeNull();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
|
||||
setPlayback({ streamUrl: 'https://example.com/final.m3u8' });
|
||||
fixture.detectChanges();
|
||||
headerResolvers[2](true);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(query('playback-diagnostic-banner')).toBeNull();
|
||||
expect(vjs().options()).toEqual(
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(component.vjsOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
sources: [
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -162,6 +162,21 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
readonly resolvedIsLive = this.applicationState.isLive;
|
||||
readonly playbackSourceRevisionToken = this.applicationState.sourceRevision;
|
||||
readonly playbackApplicationToken = this.applicationState.token;
|
||||
// Diagnostic visibility follows raw intent so an old action disappears
|
||||
// before the application effect enters an asynchronous Electron handoff.
|
||||
// Keep this token opaque: it must never retain playback payload fields.
|
||||
private readonly playbackDiagnosticIntentToken =
|
||||
computed<WebPlayerApplicationToken>(() => {
|
||||
if (this.playback() === null) {
|
||||
void this.streamUrl();
|
||||
void this.startTime();
|
||||
}
|
||||
void this.selectedPlayer();
|
||||
void this.reloadToken();
|
||||
return Symbol();
|
||||
});
|
||||
private readonly playbackDiagnosticOwnerToken =
|
||||
signal<WebPlayerApplicationToken | null>(null);
|
||||
readonly effectiveStartTime = computed(() =>
|
||||
this.recoverySession.resumeStartTime(
|
||||
this.startTime(),
|
||||
@@ -169,7 +184,9 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
)
|
||||
);
|
||||
readonly visiblePlaybackDiagnostic = computed(() =>
|
||||
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
|
||||
this.selectedPlayer() === VideoPlayer.EmbeddedMpv ||
|
||||
this.playbackDiagnosticOwnerToken() !==
|
||||
this.playbackDiagnosticIntentToken()
|
||||
? null
|
||||
: this.playbackDiagnostic()
|
||||
);
|
||||
@@ -229,6 +246,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
void this.playbackDiagnosticIntentToken();
|
||||
this.syncRecoverySession();
|
||||
const sourceRevision = this.playbackSourceRevisionToken();
|
||||
untracked(() =>
|
||||
@@ -298,6 +316,9 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
if (!this.recoverySession.recordFailure(binding)) {
|
||||
return;
|
||||
}
|
||||
this.playbackDiagnosticOwnerToken.set(
|
||||
this.playbackDiagnosticIntentToken()
|
||||
);
|
||||
this.playbackDiagnostic.set(issue);
|
||||
this.playbackFailed.emit(issue.code);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user