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
+47
@@ -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,
|
||||
};
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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()"
|
||||
|
||||
+23
-23
@@ -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'
|
||||
);
|
||||
|
||||
+6
-8
@@ -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 {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user