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:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

@@ -96,6 +96,7 @@
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
@@ -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({
@@ -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);