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

@@ -0,0 +1,47 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
describe('createUnifiedLivePlaybackSessionKey', () => {
it('keeps an M3U key across transport changes and separates channels', () => {
const first = m3uItem({
uid: 'm3u::pl-1::https://old.example/live.m3u8',
streamUrl: 'https://old.example/live.m3u8',
channelId: 'provider-channel-7',
});
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'pl-1',
contentId: 'provider-channel-7',
});
expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
uid: 'm3u::pl-1::https://new.example/timeshift.m3u8',
streamUrl: 'https://new.example/timeshift.m3u8',
})
).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
channelId: 'provider-channel-8',
})
).not.toBe(expected);
});
});
function m3uItem(
overrides: Partial<UnifiedCollectionItem>
): UnifiedCollectionItem {
return {
uid: 'm3u::pl-1::channel',
name: 'Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-1',
playlistName: 'Playlist',
...overrides,
};
}
@@ -0,0 +1,23 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
export function createUnifiedLivePlaybackSessionKey(
item: UnifiedCollectionItem | null
): string {
const sourceId = item?.playlistId.trim() ?? '';
const contentId = item ? getUnifiedLiveContentId(item) : '';
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
}
function getUnifiedLiveContentId(item: UnifiedCollectionItem): string {
switch (item.sourceType) {
case 'm3u':
return item.channelId?.trim() || item.m3uChannel?.id?.trim() || '';
case 'xtream':
return item.xtreamId == null ? '' : String(item.xtreamId);
case 'stalker':
return item.stalkerId == null ? '' : String(item.stalkerId).trim();
}
}
@@ -59,6 +59,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="currentStreamUrl()"
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
@@ -86,7 +87,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -113,7 +116,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -25,10 +25,7 @@ import {
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
@@ -46,6 +43,7 @@ import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
@Directive({
selector: '[appResizable]',
@@ -125,6 +123,7 @@ class StubAudioPlayerComponent {
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
@@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => {
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
expect(webPlayer.title()).toBe('Xtream Live');
const sourceId = item.playlistId;
const contentId = String(item.xtreamId);
const key = sessionKey({ kind: 'live', sourceId, contentId });
expect(webPlayer.playbackSessionKey()).toBe(key);
component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' });
expect(component.playbackSessionKey()).not.toBe(key);
component.activeItem.set(item);
const playback = webPlayer.playback();
expect(playback).toEqual(
expect.objectContaining({
@@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => {
throw new Error('Expected wrapper playback to be set');
}
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
expect(
fixture.nativeElement.querySelector('app-vjs-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-html-video-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-art-player')
).toBeNull();
const host = fixture.nativeElement;
expect(host.querySelector('app-vjs-player')).toBeNull();
expect(host.querySelector('app-html-video-player')).toBeNull();
expect(host.querySelector('app-art-player')).toBeNull();
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
@@ -618,16 +619,13 @@ describe('UnifiedLiveTabComponent', () => {
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: false,
},
});
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/xtream.m3u8',
}),
'mpv'
);
const [forwardedPlayback, forwardedPlayer] =
portalPlayer.openExternalPlayback.mock.calls[0];
expect(forwardedPlayback).toBe(playback);
expect(forwardedPlayer).toBe('mpv');
});
it('does not wait for M3U program lookup before opening playback', async () => {
@@ -749,9 +747,9 @@ describe('UnifiedLiveTabComponent', () => {
// auth-gated streams) before the audio element gets the URL, and
// clears it again on close.
const setUserAgent = jest.fn().mockResolvedValue(true);
(
window.electron as unknown as Record<string, unknown>
)['setUserAgent'] = setUserAgent;
(window.electron as unknown as Record<string, unknown>)[
'setUserAgent'
] = setUserAgent;
const item = {
...buildLiveItem('stalker'),
name: 'Jazz Radio',
@@ -1177,6 +1175,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
const item = await selectXtreamArchiveChannel();
const key = component.playbackSessionKey();
component.onTimelineProgramActivated(timeshiftEvent());
await fixture.whenStable();
@@ -1192,6 +1191,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
expect(component.inlinePlayback()?.isLive).toBe(false);
expect(component.playbackSessionKey()).toBe(key);
expect(component.activeTimeshiftProgram()?.title).toBe(
'Xtream Show'
);
@@ -62,6 +62,7 @@ import {
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
@Component({
selector: 'app-unified-live-tab',
@@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent {
* portal archive fields (tvArchive/tvArchiveDuration) and to
* supply credentials for Xtream catch-up URL resolution. */
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
readonly playbackSessionKey = computed(() =>
createUnifiedLivePlaybackSessionKey(this.activeItem())
);
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
@@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent {
return getLiveEpgPanelSummary(this.activeDetail());
});
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
@@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent {
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(
program.stopTimestamp,
program.stop
);
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
if (startEpoch == null || stopEpoch == null) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
@@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent {
};
});
}
}