mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -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
@@ -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()"
|
||||
|
||||
+48
-15
@@ -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
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user