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

@@ -79,6 +79,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
@@ -112,7 +113,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -138,7 +141,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -41,12 +41,14 @@ import {
import {
Channel,
EpgProgram,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -113,6 +115,7 @@ class StubAudioPlayerComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
@@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => {
} as typeof window.electron;
syncStoreState(null);
playlistId.set('playlist-1');
localStorage.removeItem('m3u-sidebar-width');
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
player.set(VideoPlayer.VideoJs);
@@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(headerContext.action()).toBeNull();
});
@@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => {
},
} as Channel);
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
isLive: true,
headers: {
'user-agent': 'IPTVnator Test',
Referer: 'https://referrer.example.com',
Origin: 'https://origin.example.com',
},
};
component.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Archive Sample',
},
playback,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -577,19 +590,18 @@ describe('VideoPlayerComponent', () => {
container: 'm3u8',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
externalFallbackRecommended: true,
},
} satisfies PlaybackFallbackRequest);
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_MPV_PLAYER',
expect.objectContaining({
{
url: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
'user-agent': 'IPTVnator Test',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
})
}
);
});
@@ -640,12 +652,36 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
});
it('owns a collision-safe live session key that ignores resolved URL changes', () => {
playlistId.set('playlist|one');
syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel);
activePlaybackUrl.set('https://archive.example/first.m3u8');
fixture.detectChanges();
const playerView = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist|one',
contentId: 'channel|one',
});
expect(playerView.playbackSessionKey()).toBe(expected);
activePlaybackUrl.set('https://archive.example/second.m3u8');
fixture.detectChanges();
expect(playerView.playbackSessionKey()).toBe(expected);
syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel);
fixture.detectChanges();
expect(playerView.playbackSessionKey()).not.toBe(expected);
});
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
syncStoreState(sampleChannel);
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
@@ -657,8 +693,7 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
});
@@ -675,9 +710,7 @@ describe('VideoPlayerComponent', () => {
// The external-player guard declines DASH channels, so the inline
// player must stay — otherwise the session has no player at all.
expect(
fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
)
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
});
@@ -90,6 +90,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
@@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly activePlaybackUrl = this.store.selectSignal(
selectActivePlaybackUrl
);
readonly activeEpgProgram = this.store.selectSignal(
selectActiveEpgProgram
);
readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram);
readonly activeEpgProgramOrNull = computed(
() => this.activeEpgProgram() ?? null
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly playbackSessionKey = computed(() => {
const sourceId = this.activePlaylistId();
const contentId = this.activeChannel()?.id;
return sourceId && contentId !== undefined
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
});
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
readonly archivePlaybackAvailable = computed(() =>
@@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly epgChannelLogo = toSignal(
toObservable(this.activeChannel).pipe(
switchMap((channel) => {
const key = channel
? resolveChannelEpgLookupKey(channel)
: '';
const key = channel ? resolveChannelEpgLookupKey(channel) : '';
if (!key) {
return of('');
}
@@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
const currentEpgProgram = epgProgram as
| EpgProgram
| null
| undefined;
EpgProgram | null | undefined;
const currentIndex = channels.findIndex(
(channel) => channel.url === activeChannel.url
);