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

@@ -21,7 +21,15 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey as resolveDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -141,6 +149,7 @@ describe('WebPlayerViewComponent', () => {
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
PlaybackDiagnosticPanelComponent,
// Real, not stubbed: the point of the test below is
// that this row's Check action reaches the host.
VodSourceRowComponent,
@@ -156,6 +165,7 @@ describe('WebPlayerViewComponent', () => {
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -236,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
]);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const check = fixture.debugElement.query(
@@ -258,7 +268,7 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -295,7 +305,7 @@ describe('WebPlayerViewComponent', () => {
const issue = createHttpDiagnostic();
fixture.detectChanges();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -307,8 +317,8 @@ describe('WebPlayerViewComponent', () => {
expect(banner.nativeElement.textContent).toContain('HTTP 404');
expect(mpvButton).toBeNull();
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -318,13 +328,57 @@ describe('WebPlayerViewComponent', () => {
);
});
it.each([
['browser-access', createBrowserAccessDiagnostic],
['unsupported-codec', createUnsupportedCodecDiagnostic],
])(
'uses neutral %s guidance when ClearKey playback cannot be transferred',
(_diagnostic, createDiagnostic) => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
fixture.componentRef.setInput('playback', {
streamUrl: 'https://provider.example/protected.mpd',
title: 'Protected stream',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
fixture.detectChanges();
emitPlaybackIssue(createDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-banner"]')
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION'
);
expect(
banner.query(
By.css(
'[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]'
)
)
).toBeNull();
}
);
it('renders only sanitized structured HLS evidence in technical details', () => {
const issue = createStructuredHlsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -345,10 +399,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured VHS evidence in technical details', () => {
const issue = createStructuredVhsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -374,10 +428,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured Shaka evidence in technical details', () => {
const issue = createStructuredShakaDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -404,13 +458,13 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured mpegts evidence in technical details', () => {
const issue = createStructuredMpegTsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(details).toEqual(
expect.arrayContaining([
{
@@ -432,59 +486,6 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'application/x-mpegURL',
},
]);
});
it('uses the Matroska mime type for MKV paths', () => {
const streamUrl = 'https://example.com/archive/movie.mkv';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/mp2t',
},
]);
});
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
const streamUrl = 'https://example.com/movie/123.ts';
fixture.componentRef.setInput('playback', {
@@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => {
const pendingSettings = new Subject<unknown>();
storageMap.get.mockReturnValue(pendingSettings.asObservable());
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -627,11 +629,11 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
component.playbackDiagnostic.set(createUnsupportedCodecDiagnostic());
fixture.detectChanges();
component.requestExternalFallback('mpv');
component.requestRecommendedPlayer('mpv');
expect(component.playbackDiagnostic()).toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
@@ -796,10 +798,10 @@ describe('WebPlayerViewComponent', () => {
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticTitleKey(issue)).toBe(
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
);
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
@@ -808,14 +810,14 @@ describe('WebPlayerViewComponent', () => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
fixture.detectChanges();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -838,7 +840,7 @@ describe('WebPlayerViewComponent', () => {
fixture.detectChanges();
const issue = createUnsupportedCodecDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = fixture.debugElement.query(
@@ -851,8 +853,8 @@ describe('WebPlayerViewComponent', () => {
expect(details.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
@@ -888,7 +890,7 @@ describe('WebPlayerViewComponent', () => {
expect.objectContaining({ reloadToken: 0 })
);
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const retryButton = fixture.debugElement.query(
@@ -902,9 +904,12 @@ describe('WebPlayerViewComponent', () => {
retryButton.nativeElement.click();
fixture.detectChanges();
const retriedPlayer = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(component.playbackDiagnostic()).toBeNull();
expect(player.options()).toEqual(
expect(retriedPlayer.options()).toEqual(
expect.objectContaining({ reloadToken: 1 })
);
expect(
@@ -1029,6 +1034,24 @@ describe('WebPlayerViewComponent', () => {
);
});
});
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
fixture.detectChanges();
const binding = component.activeBinding();
expect(binding).not.toBeNull();
if (!binding) {
throw new Error('Expected an active inline playback binding');
}
component.handlePlaybackIssue(issue, binding);
}
function getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return resolveDiagnosticDescriptionKey(
issue,
runtimeCapabilities.supportsManagedExternalPlayers,
true
);
}
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1041,7 +1064,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
}
@@ -1056,7 +1078,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'blocked by CORS policy',
externalFallbackRecommended: true,
};
}
@@ -1071,7 +1092,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
details: 'manifestIncompatibleCodecsError',
externalFallbackRecommended: true,
};
}
@@ -1086,7 +1106,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
externalFallbackRecommended: false,
};
}
@@ -1104,7 +1123,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
nativeErrorMessage: 'source not supported',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
};
}
@@ -1128,7 +1146,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -1154,7 +1171,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1182,7 +1198,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
failure: 'network',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1209,6 +1224,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}