mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -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
+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