mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
* 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
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { By } from '@angular/platform-browser';
|
|
import {
|
|
MockHls,
|
|
TEST_CHANNEL,
|
|
cleanupSharedControlsTests,
|
|
configureSharedControlsTests,
|
|
hlsInstances,
|
|
lifecycle,
|
|
mpegTsCreatePlayer,
|
|
mpegTsInstances,
|
|
mpegTsIsSupported,
|
|
observeBridgeSourceBinding,
|
|
readHtmlPlayerInternals,
|
|
renderSharedControls,
|
|
type SharedControlsFixture,
|
|
} from './html-video-player.component.shared-controls.spec-fixtures';
|
|
|
|
describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
|
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
|
|
const fixtures: SharedControlsFixture[] = [];
|
|
|
|
beforeAll(async () => {
|
|
({ HtmlVideoPlayerComponent } =
|
|
await import('./html-video-player.component'));
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await configureSharedControlsTests(HtmlVideoPlayerComponent);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupSharedControlsTests(fixtures);
|
|
});
|
|
|
|
it('retains an HLS source created before bridge initialization', () => {
|
|
const { adapter } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{
|
|
channel: TEST_CHANNEL,
|
|
isLive: false,
|
|
showCaptions: true,
|
|
}
|
|
);
|
|
|
|
expect(hlsInstances).toHaveLength(1);
|
|
expect(adapter.capabilities().audioTracks).toBe(true);
|
|
expect(adapter.state().audioTracks).toEqual([
|
|
{ id: 0, label: 'English', selected: true },
|
|
{ id: 1, label: 'Deutsch', selected: false },
|
|
]);
|
|
});
|
|
|
|
it('binds HLS controls after media attach and before source loading', () => {
|
|
const { component } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures
|
|
);
|
|
lifecycle.length = 0;
|
|
|
|
component.playChannel(TEST_CHANNEL);
|
|
|
|
const attachIndex = lifecycle.indexOf('hls:attachMedia');
|
|
const bindIndex = lifecycle.indexOf(
|
|
`on:${MockHls.Events.AUDIO_TRACKS_UPDATED}`
|
|
);
|
|
const loadIndex = lifecycle.indexOf('hls:loadSource');
|
|
expect(attachIndex).toBeGreaterThanOrEqual(0);
|
|
expect(bindIndex).toBeGreaterThanOrEqual(0);
|
|
expect(loadIndex).toBeGreaterThanOrEqual(0);
|
|
expect(attachIndex).toBeLessThan(bindIndex);
|
|
expect(bindIndex).toBeLessThan(loadIndex);
|
|
});
|
|
|
|
it('does not write HLS provider data to development logs', () => {
|
|
const secret = 'hls-dev-log-secret';
|
|
const debug = jest
|
|
.spyOn(console, 'debug')
|
|
.mockImplementation(() => undefined);
|
|
jest.replaceProperty(process.env, 'NODE_ENV', 'development');
|
|
|
|
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
|
|
channel: {
|
|
...TEST_CHANNEL,
|
|
name: `Provider channel ${secret}`,
|
|
url: `https://user:${secret}@provider.example/live.m3u8?token=${secret}`,
|
|
epgParams: `&epg-token=${secret}`,
|
|
},
|
|
});
|
|
|
|
const serializedLogs = JSON.stringify(debug.mock.calls);
|
|
expect(serializedLogs).not.toContain(secret);
|
|
expect(serializedLogs).not.toContain('provider.example');
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'passes authoritative isLive=%s to raw MPEG-TS playback',
|
|
(isLive) => {
|
|
mpegTsIsSupported.mockReturnValue(true);
|
|
|
|
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
|
|
channel: {
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/raw.ts',
|
|
},
|
|
isLive,
|
|
});
|
|
|
|
expect(mpegTsCreatePlayer).toHaveBeenCalledWith({
|
|
type: 'mpegts',
|
|
isLive,
|
|
url: 'https://example.test/raw.ts',
|
|
});
|
|
}
|
|
);
|
|
|
|
it('emits structured HTTP evidence for mpegts.js failures', () => {
|
|
mpegTsIsSupported.mockReturnValue(true);
|
|
const { component } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{
|
|
channel: {
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/missing.ts',
|
|
},
|
|
}
|
|
);
|
|
const issues: unknown[] = [];
|
|
component.playbackIssue.subscribe((issue) => issues.push(issue));
|
|
const secret = 'html-mpegts-secret';
|
|
|
|
mpegTsInstances[0].emit(
|
|
'error',
|
|
'NetworkError',
|
|
'HttpStatusCodeInvalid',
|
|
{
|
|
code: 404,
|
|
msg: `Not Found ${secret}`,
|
|
url: `https://provider.example/error?token=${secret}`,
|
|
}
|
|
);
|
|
|
|
expect(issues).toEqual([
|
|
expect.objectContaining({
|
|
code: 'network-error',
|
|
source: 'mpegts',
|
|
sourceUrl: 'https://example.test/missing.ts',
|
|
player: 'html5',
|
|
httpStatus: 404,
|
|
mpegTs: expect.objectContaining({
|
|
engineType: 'NetworkError',
|
|
engineDetails: 'HttpStatusCodeInvalid',
|
|
failure: 'http',
|
|
httpStatus: 404,
|
|
}),
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(issues)).not.toContain(secret);
|
|
});
|
|
|
|
it('owns one MPEG-TS source between media attachment and loading', () => {
|
|
mpegTsIsSupported.mockReturnValue(true);
|
|
const { component } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{ isLive: false }
|
|
);
|
|
const setSource = observeBridgeSourceBinding(component);
|
|
lifecycle.length = 0;
|
|
|
|
component.playChannel({
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/raw.ts',
|
|
});
|
|
|
|
const internals = readHtmlPlayerInternals(component);
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
expect(setSource).toHaveBeenCalledWith({ kind: 'mpegts' });
|
|
expect(internals.controlsSource).toEqual({ kind: 'mpegts' });
|
|
expect(internals.hls).toBeNull();
|
|
expect(hlsInstances).toHaveLength(0);
|
|
expect(mpegTsInstances).toHaveLength(1);
|
|
expect(internals.mpegtsPlayer).toBe(mpegTsInstances[0]);
|
|
expect(mpegTsInstances[0].attachMediaElement).toHaveBeenCalledTimes(1);
|
|
expect(mpegTsInstances[0].load).toHaveBeenCalledTimes(1);
|
|
expect(lifecycle.indexOf('mpegts:attachMedia')).toBeLessThan(
|
|
lifecycle.indexOf('bridge:mpegts')
|
|
);
|
|
expect(lifecycle.indexOf('bridge:mpegts')).toBeLessThan(
|
|
lifecycle.indexOf('mpegts:load')
|
|
);
|
|
});
|
|
|
|
it('owns one Shaka source for DASH (.mpd) streams', () => {
|
|
const { component } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures
|
|
);
|
|
const setSource = observeBridgeSourceBinding(component);
|
|
|
|
component.playChannel({
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/live.mpd',
|
|
drm: {
|
|
licenseType: 'clearkey',
|
|
supported: true,
|
|
clearKeys: { abc: 'def' },
|
|
},
|
|
});
|
|
|
|
const internals = readHtmlPlayerInternals(component);
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
expect(setSource).toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: 'shaka' })
|
|
);
|
|
expect(internals.controlsSource).toEqual(
|
|
expect.objectContaining({ kind: 'shaka' })
|
|
);
|
|
expect(internals.hls).toBeNull();
|
|
expect(internals.mpegtsPlayer).toBeNull();
|
|
expect(hlsInstances).toHaveLength(0);
|
|
expect(mpegTsInstances).toHaveLength(0);
|
|
});
|
|
|
|
it('owns one native source before loading native media', () => {
|
|
const { component, fixture } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures
|
|
);
|
|
const setSource = observeBridgeSourceBinding(component);
|
|
const video = fixture.debugElement.query(By.css('video'))
|
|
.nativeElement as HTMLVideoElement;
|
|
const load = jest
|
|
.spyOn(video, 'load')
|
|
.mockImplementation(() => lifecycle.push('native:load'));
|
|
lifecycle.length = 0;
|
|
|
|
component.playChannel({
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/movie.mp4',
|
|
});
|
|
|
|
const internals = readHtmlPlayerInternals(component);
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
|
|
expect(internals.controlsSource).toEqual({ kind: 'native' });
|
|
expect(internals.hls).toBeNull();
|
|
expect(internals.mpegtsPlayer).toBeNull();
|
|
expect(hlsInstances).toHaveLength(0);
|
|
expect(mpegTsInstances).toHaveLength(0);
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
expect(lifecycle.indexOf('bridge:native')).toBeLessThan(
|
|
lifecycle.indexOf('native:load')
|
|
);
|
|
});
|
|
|
|
it('refreshes live and caption inputs through authoritative closures', () => {
|
|
const { fixture, adapter } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{
|
|
channel: TEST_CHANNEL,
|
|
isLive: true,
|
|
showCaptions: false,
|
|
}
|
|
);
|
|
const hls = hlsInstances[0];
|
|
|
|
expect(adapter.state().isLive).toBe(true);
|
|
expect(hls.subtitleDisplay).toBe(false);
|
|
|
|
fixture.componentRef.setInput('isLive', false);
|
|
fixture.componentRef.setInput('showCaptions', true);
|
|
fixture.detectChanges();
|
|
|
|
expect(adapter.state().isLive).toBe(false);
|
|
expect(hls.subtitleDisplay).toBe(true);
|
|
});
|
|
|
|
it('clears old HLS listeners and tracks before destroying the engine', () => {
|
|
const { component, adapter } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{
|
|
channel: TEST_CHANNEL,
|
|
isLive: false,
|
|
showCaptions: true,
|
|
}
|
|
);
|
|
const hls = hlsInstances[0];
|
|
const setSource = observeBridgeSourceBinding(component);
|
|
lifecycle.length = 0;
|
|
|
|
component.playChannel({
|
|
...TEST_CHANNEL,
|
|
url: 'https://example.test/movie.mp4',
|
|
});
|
|
|
|
const destroyIndex = lifecycle.indexOf('hls:destroy');
|
|
expect(destroyIndex).toBeGreaterThan(0);
|
|
expect(
|
|
lifecycle
|
|
.slice(0, destroyIndex)
|
|
.filter((event) => event.startsWith('off:'))
|
|
).toHaveLength(7);
|
|
const internals = readHtmlPlayerInternals(component);
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
|
|
expect(internals.controlsSource).toEqual({ kind: 'native' });
|
|
expect(internals.hls).toBeNull();
|
|
expect(internals.mpegtsPlayer).toBeNull();
|
|
expect(hlsInstances).toHaveLength(1);
|
|
expect(mpegTsInstances).toHaveLength(0);
|
|
expect(hls.off).toHaveBeenCalledTimes(7);
|
|
expect(adapter.state().audioTracks).toEqual([]);
|
|
expect(adapter.state().subtitleTracks).toEqual([]);
|
|
});
|
|
|
|
it('destroys the bridge before HLS and detaches the adapter once', () => {
|
|
const { component, adapter } = renderSharedControls(
|
|
HtmlVideoPlayerComponent,
|
|
fixtures,
|
|
{
|
|
channel: TEST_CHANNEL,
|
|
showCaptions: true,
|
|
}
|
|
);
|
|
const originalDetach = adapter.detach.bind(adapter);
|
|
const detach = jest.spyOn(adapter, 'detach').mockImplementation(() => {
|
|
lifecycle.push('adapter:detach');
|
|
originalDetach();
|
|
});
|
|
lifecycle.length = 0;
|
|
|
|
component.ngOnDestroy();
|
|
component.ngOnDestroy();
|
|
|
|
expect(detach).toHaveBeenCalledTimes(1);
|
|
expect(lifecycle.indexOf('adapter:detach')).toBeLessThan(
|
|
lifecycle.indexOf('hls:destroy')
|
|
);
|
|
});
|
|
});
|