mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-17 06:36:15 -08:00
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
This commit is contained in:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
+1
@@ -96,6 +96,7 @@
|
||||
@if (isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
|
||||
+122
-45
@@ -29,13 +29,21 @@ import {
|
||||
EpgTimelineComponent,
|
||||
EpgTimelineSummary,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { GridListComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@@ -78,10 +86,11 @@ class StubGridListComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
@@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(true),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
@@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentPlaylist.set(playlist);
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
@@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
xtreamUrlService.resolveCatchupUrl.mockClear();
|
||||
portalPlayer.isEmbeddedPlayer.mockReset();
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
portalPlayer.openExternalPlayback.mockClear();
|
||||
|
||||
epgItems.set([]);
|
||||
currentEpgItem.set(null);
|
||||
@@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the host-owned live key across URL replacement and changes it with the channel', () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
currentPlaylist.set({ ...playlist, id: 'playlist|one' });
|
||||
const first = { ...sampleChannel, xtream_id: 101 };
|
||||
component.playLive(first);
|
||||
fixture.detectChanges();
|
||||
|
||||
const webPlayer = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist|one',
|
||||
contentId: 101,
|
||||
});
|
||||
expect(webPlayer.playbackSessionKey()).toBe(expected);
|
||||
|
||||
xtreamStore.constructStreamUrl.mockReturnValueOnce(
|
||||
'https://example.com/replaced-timeshift.ts'
|
||||
);
|
||||
component.playLive(first);
|
||||
fixture.detectChanges();
|
||||
expect(webPlayer.playbackSessionKey()).toBe(expected);
|
||||
|
||||
component.playLive({ ...first, xtream_id: 102 });
|
||||
fixture.detectChanges();
|
||||
expect(webPlayer.playbackSessionKey()).not.toBe(expected);
|
||||
});
|
||||
|
||||
it('forwards the exact resolved live playback to external fallback', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/fallback.m3u8',
|
||||
title: 'Channel 101',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
} as PlaybackFallbackRequest);
|
||||
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
portalPlayer.openExternalPlayback.mock.calls[0];
|
||||
expect(forwardedPlayback).toBe(playback);
|
||||
expect(forwardedPlayer).toBe('mpv');
|
||||
});
|
||||
|
||||
it('updates live root pagination from the header paginator', () => {
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
@@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the active archive program in the live EPG panel summary', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
it('does not attach a pending catchup result to a newer live owner', async () => {
|
||||
let resolveCatchup!: (url: string) => void;
|
||||
xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCatchup = resolve;
|
||||
})
|
||||
);
|
||||
component.playLive(sampleChannel);
|
||||
const catchup = component.onProgramActivated({
|
||||
type: 'timeshift',
|
||||
program: buildArchivedProgram(),
|
||||
});
|
||||
const newerChannel = {
|
||||
...sampleChannel,
|
||||
xtream_id: 202,
|
||||
name: 'Channel 202',
|
||||
};
|
||||
xtreamStore.constructStreamUrl.mockReturnValueOnce(
|
||||
'https://example.com/channel-202.ts'
|
||||
);
|
||||
component.playLive(newerChannel);
|
||||
resolveCatchup('https://stale.example/channel-101-timeshift.ts');
|
||||
await catchup;
|
||||
|
||||
expect(component.activePlayback()?.streamUrl).toBe(
|
||||
'https://example.com/channel-202.ts'
|
||||
);
|
||||
expect(component.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 202,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the active archive program in the live EPG panel summary', async () => {
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
'archived',
|
||||
@@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('returns archive playback to the selected live stream from the panel action', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
currentEpgItem.set(
|
||||
buildEpgItem(
|
||||
'current',
|
||||
@@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
isLive: true,
|
||||
})
|
||||
);
|
||||
expect(timeline.componentInstance.summary()?.title).toBe('Current Show');
|
||||
expect(timeline.componentInstance.summary()?.title).toBe(
|
||||
'Current Show'
|
||||
);
|
||||
expect(timeline.componentInstance.summaryLabelKey()).toBe(
|
||||
'EPG.CURRENT_PROGRAM'
|
||||
);
|
||||
@@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('publishes the active archive program in remote-control status', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
currentEpgItem.set(
|
||||
buildEpgItem(
|
||||
'current',
|
||||
@@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('passes the active catchup program to the EPG list until live playback resumes', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
'1',
|
||||
@@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const timeline = fixture.debugElement.query(
|
||||
By.directive(StubEpgTimelineComponent)
|
||||
);
|
||||
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true);
|
||||
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
|
||||
@@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const buildArchivedProgram = (): EpgProgram => ({
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
});
|
||||
|
||||
function buildEpgItem(
|
||||
id: string,
|
||||
title: string,
|
||||
|
||||
@@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private playbackRequestId = 0;
|
||||
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly activeLiveItemId = signal<number | null>(null);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const contentId = this.activeLiveItemId();
|
||||
return sourceId && contentId
|
||||
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
readonly activeStreamUrl = computed(
|
||||
() => this.activePlayback()?.streamUrl ?? ''
|
||||
);
|
||||
@@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
item: XtreamLiveChannelItem,
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
|
||||
) {
|
||||
this.playbackRequestId += 1;
|
||||
const streamUrl = this.xtreamStore.constructStreamUrl(item);
|
||||
this.activeCatchupProgram.set(null);
|
||||
// Keep both root/recently-added playback and same-category replays in
|
||||
// sync with the category rail. For already-selected channels this is a
|
||||
// store no-op.
|
||||
this.selectLiveItemCategory(item);
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
title: item.title ?? item.name ?? '',
|
||||
@@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.playbackRequestId += 1;
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
}
|
||||
@@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.playbackRequestId;
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
|
||||
playlist.id,
|
||||
{
|
||||
@@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
stopTimestamp,
|
||||
playlist.serverTimezone
|
||||
);
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.xtreamStore.currentPlaylist()?.id !== playlist.id ||
|
||||
this.activeLiveItemId() !== item.xtream_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCatchupProgram.set(program);
|
||||
this.activePlayback.set({
|
||||
|
||||
+15
-2
@@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService {
|
||||
private lastSaveTime = 0;
|
||||
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly inlinePlaybackSessionEpisodeState =
|
||||
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
|
||||
readonly episodePlaybackPositions = this.playbackPositionState.positions;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
@@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService {
|
||||
contentInfo,
|
||||
};
|
||||
|
||||
this.startPlayback(playback);
|
||||
const episodeState = resolveSeriesPlaybackEpisodeState({
|
||||
episodesBySeason: selectedItem.episodes,
|
||||
currentEpisodeId: episode.id,
|
||||
fallbackSeasonNumber: Number(episode.season),
|
||||
fallbackEpisodeNumber: Number(episode.episode_num),
|
||||
});
|
||||
this.startPlayback(playback, episodeState);
|
||||
}
|
||||
|
||||
playQuickStartEpisode(): void {
|
||||
@@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService {
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.inlinePlaybackSessionEpisodeState.set(null);
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
@@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService {
|
||||
});
|
||||
}
|
||||
|
||||
private startPlayback(playback: ResolvedPortalPlayback): void {
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
): void {
|
||||
this.lastSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlaybackSessionEpisodeState.set(episodeState);
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="item.info.name"
|
||||
|
||||
@@ -22,10 +22,11 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details.component';
|
||||
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
@@ -54,6 +55,7 @@ class StubSeasonContainerComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly episodeMetadata = input<unknown>(null);
|
||||
readonly seriesTitle = input<string | null>(null);
|
||||
@@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => {
|
||||
let seriesResumeTarget: ReturnType<
|
||||
typeof signal<SeriesResumeTarget | null>
|
||||
>;
|
||||
let routeParams: BehaviorSubject<{
|
||||
categoryId: string;
|
||||
serialId: string;
|
||||
}>;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.history.replaceState({}, '', window.location.href);
|
||||
@@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => {
|
||||
getSeriesPlaybackPositions.mockClear();
|
||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
|
||||
routeParams = new BehaviorSubject({
|
||||
categoryId: '3',
|
||||
serialId: '103',
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SerialDetailsComponent],
|
||||
@@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => {
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({
|
||||
categoryId: '3',
|
||||
serialId: '103',
|
||||
}),
|
||||
params: routeParams,
|
||||
snapshot: {
|
||||
params: {
|
||||
categoryId: '3',
|
||||
@@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
const firstEpisodeKey = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey);
|
||||
expect(inlinePlayer.seriesNavigation()).toEqual({
|
||||
canPrevious: false,
|
||||
canNext: true,
|
||||
@@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => {
|
||||
canNext: false,
|
||||
autoplayEnabled: true,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
|
||||
|
||||
inlinePlayer.playbackEnded.emit();
|
||||
fixture.detectChanges();
|
||||
@@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => {
|
||||
expect.objectContaining({ id: '2001' })
|
||||
);
|
||||
});
|
||||
|
||||
it('owns the parent identity from the route and ignores replacement playback payloads', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const item = fixture.componentInstance.selectedItem();
|
||||
const episode = item?.episodes?.['1'][0];
|
||||
if (!item || !episode) {
|
||||
throw new Error('Expected the serial fixture and first episode');
|
||||
}
|
||||
fixture.componentInstance.playEpisode(episode);
|
||||
fixture.detectChanges();
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://alternative.example/replaced.mkv',
|
||||
title: 'Alternative payload',
|
||||
headers: { Authorization: 'Bearer replacement' },
|
||||
contentInfo: {
|
||||
playlistId: 'alternative-playlist',
|
||||
contentXtreamId: 999001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 999,
|
||||
seasonNumber: 9,
|
||||
episodeNumber: 9,
|
||||
},
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
|
||||
selectedItem.set({ ...item, series_id: 999 });
|
||||
routeParams.next({ categoryId: '3', serialId: '104' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.componentInstance.playEpisode(episode);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 104,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from '../tmdb-similar.util';
|
||||
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
|
||||
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
|
||||
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
|
||||
|
||||
@Component({
|
||||
selector: 'app-serial-details',
|
||||
@@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
readonly quickStartAction = this.playback.quickStartAction;
|
||||
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
|
||||
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
|
||||
readonly playbackSessionKey = computed(() =>
|
||||
createSerialPlaybackSessionKey(
|
||||
this.xtreamStore.currentPlaylist()?.id,
|
||||
this.routeParams().serialId,
|
||||
this.playback.inlinePlaybackSessionEpisodeState()
|
||||
)
|
||||
);
|
||||
/** "Up Next" rail entries for the inline player (series only). */
|
||||
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
|
||||
buildUpNextRailItems({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
|
||||
|
||||
export function createSerialPlaybackSessionKey(
|
||||
sourceId: string | undefined,
|
||||
seriesId: number | string | undefined,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
): string {
|
||||
const normalizedSeriesId = Number(seriesId);
|
||||
if (
|
||||
!sourceId ||
|
||||
!Number.isSafeInteger(normalizedSeriesId) ||
|
||||
normalizedSeriesId <= 0 ||
|
||||
!episodeState
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId,
|
||||
contentId: episodeState.episode.id,
|
||||
seriesId: normalizedSeriesId,
|
||||
seasonNumber: episodeState.seasonNumber,
|
||||
episodeNumber: episodeState.episodeNumber,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { BehaviorSubject, NEVER, of } from 'rxjs';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-inline-player',
|
||||
@@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
template: '<div data-testid="inline-vod-player"></div>',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<unknown>(null);
|
||||
// Multi-source wiring: the real player takes these, so the stub has to
|
||||
// accept them or the template binding fails to compile.
|
||||
@@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
thumbnail: 'https://example.com/catalog-poster.jpg',
|
||||
})
|
||||
);
|
||||
const expectedKey = createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 650020,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://copy.example/movie/9001.mkv',
|
||||
title: 'Catalog movie',
|
||||
headers: { 'User-Agent': 'Copy Provider' },
|
||||
contentInfo: {
|
||||
playlistId: 'copy-playlist',
|
||||
contentXtreamId: 9001,
|
||||
contentType: 'vod',
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
|
||||
expect(
|
||||
host.querySelector('app-portal-detail-shell')?.classList
|
||||
).toContain('shell-host--watch');
|
||||
@@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
selectedItem.set(richItem());
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(true);
|
||||
const firstSessionKey = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
window.history.replaceState({}, '', window.location.href);
|
||||
selectedItem.set({
|
||||
@@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(false);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||
firstSessionKey
|
||||
);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 650021,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('plays a rich downloaded movie locally without a usable provider source', async () => {
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
@if (playableVodItem()) {
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[alternativeSources]="multiSource.alternatives()"
|
||||
[allSources]="multiSource.sources()"
|
||||
|
||||
@@ -72,6 +72,7 @@ import { VodDetailsDownloadsService } from './vod-details-downloads.service';
|
||||
import { VodDetailsSimilarService } from './vod-details-similar.service';
|
||||
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
|
||||
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
type XtreamVodIdentityItem = XtreamVodDetails & {
|
||||
readonly id?: number | string;
|
||||
@@ -162,6 +163,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly isFavorite = this.xtreamStore.isFavorite;
|
||||
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const contentId = this.selectedVodId();
|
||||
return sourceId && Number.isFinite(contentId) && contentId > 0
|
||||
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
readonly providerOnly = computed(() => {
|
||||
this.routeParams();
|
||||
return isProviderOnlyDetailState(window.history.state);
|
||||
|
||||
Reference in new issue
Block a user