mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user