mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts. S2: write-file IPC restricted to save-dialog-authorized paths. S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests). S4: EPG titles rendered via interpolation, not [innerHTML]. S5: downloads reveal/play limited to recorded download paths. S6: Stalker cmd encoded (slash-preserving) to block query injection. EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed. * perf(player): lazy-load web video players via @defer Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js, artplayer and mpegts.js split into a deferred chunk loaded on first playback instead of eagerly on the player route. Embedded MPV (native) stays eager. Spec uses DeferBlockBehavior.Playthrough. * fix(player): remove leaked HTML video listeners on destroy volumechange used a mismatched removeEventListener reference, while loadedmetadata and timeupdate were never removed at all. Bind all three to stable handler fields used for both add and remove, and add a teardown regression test asserting each listener is detached on destroy. * refactor(dashboard): extract pure navigation helpers from DashboardDataService Move the 8 stateless link/navigation-state/type-kind helpers into a new dashboard-navigation.util.ts so the routing logic is independently testable and the 1260-line god-service shrinks. DashboardDataService keeps the public methods as thin delegators (facade) so the public API and the single consumer (workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService decomposition; verified by the existing service spec (33/33) and the app typecheck. * fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder) - url-safety: broaden IPv6 link-local detection to the full fe80::/10 range (fe80:: through febf::), not just the fe80:: prefix (+ regression tests). - playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save dialog opened without a following write cannot accumulate entries until restart. - web-player-view: add a @placeholder to each @defer (on immediate) player block to avoid the one-frame blank/layout-shift before the chunk resolves. * fix(security): close Electron network and download gaps * test(downloads): cover cancellation and restart cleanup * fix(downloads): address Greptile review gaps * test(security): reproduce remaining Greptile findings * fix(security): close remaining Greptile findings * test(downloads): reproduce early database queue stall * fix(downloads): release queue after setup failures * test(downloads): reproduce completion queue stall * fix(downloads): release queue after completion failures
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import { Component, input, output } from '@angular/core';
|
|
import {
|
|
ComponentFixture,
|
|
DeferBlockBehavior,
|
|
TestBed,
|
|
} from '@angular/core/testing';
|
|
import { ClipboardModule } from '@angular/cdk/clipboard';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { By } from '@angular/platform-browser';
|
|
import { StorageMap } from '@ngx-pwa/local-storage';
|
|
import { TranslateModule } from '@ngx-translate/core';
|
|
import { of, Subject } from 'rxjs';
|
|
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
|
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
|
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
|
import {
|
|
PlaybackDiagnostic,
|
|
PlaybackDiagnosticCode,
|
|
PlaybackDiagnosticSource,
|
|
} from '../playback-diagnostics/playback-diagnostics.util';
|
|
|
|
jest.unstable_mockModule('video.js', () => ({
|
|
default: jest.fn(),
|
|
}));
|
|
|
|
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
|
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
|
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
|
|
|
@Component({
|
|
selector: 'app-vjs-player',
|
|
template: '<div data-test-id="stub-vjs-player"></div>',
|
|
})
|
|
class StubVjsPlayerComponent {
|
|
readonly options = input<unknown>();
|
|
readonly volume = input(1);
|
|
readonly startTime = input(0);
|
|
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
|
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-html-video-player',
|
|
template: '<div data-test-id="stub-html-player"></div>',
|
|
})
|
|
class StubHtmlVideoPlayerComponent {
|
|
readonly channel = input<unknown>();
|
|
readonly volume = input(1);
|
|
readonly showCaptions = input(false);
|
|
readonly startTime = input(0);
|
|
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
|
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-art-player',
|
|
template: '<div data-test-id="stub-art-player"></div>',
|
|
})
|
|
class StubArtPlayerComponent {
|
|
readonly channel = input<unknown>();
|
|
readonly volume = input(1);
|
|
readonly showCaptions = input(false);
|
|
readonly startTime = input(0);
|
|
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
|
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-embedded-mpv-player',
|
|
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
|
})
|
|
class StubEmbeddedMpvPlayerComponent {
|
|
readonly playback = input.required<unknown>();
|
|
readonly recordingFolder = input('');
|
|
readonly seriesNavigation = input<unknown>(null);
|
|
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
|
readonly playbackEnded = output<void>();
|
|
readonly previousEpisodeRequested = output<void>();
|
|
readonly nextEpisodeRequested = output<void>();
|
|
}
|
|
|
|
describe('WebPlayerViewComponent', () => {
|
|
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
|
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
|
let component: WebPlayerViewComponentInstance;
|
|
const storageMap = {
|
|
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
|
};
|
|
let runtimeCapabilities: { supportsManagedExternalPlayers: boolean };
|
|
|
|
beforeAll(async () => {
|
|
({ WebPlayerViewComponent } =
|
|
await import('./web-player-view.component'));
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
runtimeCapabilities = { supportsManagedExternalPlayers: false };
|
|
|
|
await TestBed.configureTestingModule({
|
|
// @defer blocks render their main content synchronously in tests.
|
|
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
|
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
|
providers: [
|
|
{ provide: StorageMap, useValue: storageMap },
|
|
{
|
|
provide: RuntimeCapabilitiesService,
|
|
useValue: runtimeCapabilities,
|
|
},
|
|
],
|
|
})
|
|
.overrideComponent(WebPlayerViewComponent, {
|
|
set: {
|
|
imports: [
|
|
StubArtPlayerComponent,
|
|
StubEmbeddedMpvPlayerComponent,
|
|
StubHtmlVideoPlayerComponent,
|
|
StubVjsPlayerComponent,
|
|
ClipboardModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatTooltipModule,
|
|
TranslateModule,
|
|
],
|
|
},
|
|
})
|
|
.compileComponents();
|
|
|
|
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
|
|
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
|
component = fixture.componentInstance;
|
|
fixture.componentRef.setInput(
|
|
'streamUrl',
|
|
'https://example.com/archive/movie.mkv'
|
|
);
|
|
fixture.componentRef.setInput('title', 'Example Movie');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fixture.destroy();
|
|
});
|
|
|
|
it('anchors overlay UI to the player view host', () => {
|
|
fixture.detectChanges();
|
|
|
|
expect(fixture.nativeElement.classList).toContain('web-player-view');
|
|
});
|
|
|
|
it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => {
|
|
const requests: unknown[] = [];
|
|
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
|
component.externalFallbackRequested.subscribe((request) =>
|
|
requests.push(request)
|
|
);
|
|
|
|
fixture.detectChanges();
|
|
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
|
fixture.detectChanges();
|
|
|
|
const banner = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-diagnostic-banner"]')
|
|
);
|
|
const mpvButton = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-fallback-mpv"]')
|
|
);
|
|
|
|
expect(banner.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.TITLE'
|
|
);
|
|
expect(banner.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
|
);
|
|
mpvButton.nativeElement.click();
|
|
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({
|
|
player: 'mpv',
|
|
playback: expect.objectContaining({
|
|
streamUrl: 'https://example.com/archive/movie.mkv',
|
|
title: 'Example Movie',
|
|
}),
|
|
diagnostic: expect.objectContaining({
|
|
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
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('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', {
|
|
streamUrl,
|
|
title: 'Example Movie',
|
|
contentInfo: {
|
|
playlistId: 'playlist-1',
|
|
contentXtreamId: 123,
|
|
contentType: 'vod',
|
|
},
|
|
});
|
|
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
fixture.detectChanges();
|
|
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubVjsPlayerComponent)
|
|
).componentInstance as StubVjsPlayerComponent;
|
|
expect(player.options()).toEqual(
|
|
expect.objectContaining({
|
|
isLive: false,
|
|
sources: [
|
|
{
|
|
src: streamUrl,
|
|
type: 'video/mp2t',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('preserves playback HTTP metadata for channel-based players', async () => {
|
|
const streamUrl = 'https://example.com/live/channel.m3u8';
|
|
fixture.componentRef.setInput(
|
|
'playerOverride',
|
|
VideoPlayer.Html5Player
|
|
);
|
|
fixture.componentRef.setInput('playback', {
|
|
streamUrl,
|
|
title: 'Header Locked Channel',
|
|
userAgent: 'ProviderAgent/1.0',
|
|
referer: 'https://provider.example/ref',
|
|
origin: 'https://provider.example',
|
|
headers: {
|
|
'User-Agent': 'IgnoredFallbackAgent/1.0',
|
|
Referer: 'https://ignored.example/ref',
|
|
Origin: 'https://ignored.example',
|
|
},
|
|
});
|
|
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
fixture.detectChanges();
|
|
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubHtmlVideoPlayerComponent)
|
|
).componentInstance as StubHtmlVideoPlayerComponent;
|
|
expect(player.channel()).toEqual(
|
|
expect.objectContaining({
|
|
url: streamUrl,
|
|
name: 'Header Locked Channel',
|
|
http: {
|
|
'user-agent': 'ProviderAgent/1.0',
|
|
referrer: 'https://provider.example/ref',
|
|
origin: 'https://provider.example',
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('falls back to playback headers when explicit HTTP metadata is absent', async () => {
|
|
const streamUrl = 'https://example.com/live/channel.m3u8';
|
|
fixture.componentRef.setInput(
|
|
'playerOverride',
|
|
VideoPlayer.Html5Player
|
|
);
|
|
fixture.componentRef.setInput('playback', {
|
|
streamUrl,
|
|
title: 'Header Fallback Channel',
|
|
headers: {
|
|
'user-agent': 'HeaderAgent/1.0',
|
|
referer: 'https://headers.example/ref',
|
|
origin: 'https://headers.example',
|
|
},
|
|
});
|
|
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
fixture.detectChanges();
|
|
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubHtmlVideoPlayerComponent)
|
|
).componentInstance as StubHtmlVideoPlayerComponent;
|
|
expect(player.channel()).toEqual(
|
|
expect.objectContaining({
|
|
http: {
|
|
'user-agent': 'HeaderAgent/1.0',
|
|
referrer: 'https://headers.example/ref',
|
|
origin: 'https://headers.example',
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('renders embedded MPV before settings storage emits', () => {
|
|
fixture.destroy();
|
|
|
|
const pendingSettings = new Subject<unknown>();
|
|
storageMap.get.mockReturnValue(pendingSettings.asObservable());
|
|
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
|
component = fixture.componentInstance;
|
|
fixture.componentRef.setInput(
|
|
'streamUrl',
|
|
'https://example.com/archive/movie.mkv'
|
|
);
|
|
fixture.componentRef.setInput('title', 'Example Movie');
|
|
fixture.componentRef.setInput(
|
|
'playerOverride',
|
|
VideoPlayer.EmbeddedMpv
|
|
);
|
|
|
|
expect(() => fixture.detectChanges()).not.toThrow();
|
|
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubEmbeddedMpvPlayerComponent)
|
|
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
|
expect(player.recordingFolder()).toBe('');
|
|
});
|
|
|
|
it('suppresses browser diagnostics while embedded MPV is selected', () => {
|
|
const requests: unknown[] = [];
|
|
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
|
fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv);
|
|
component.externalFallbackRequested.subscribe((request) =>
|
|
requests.push(request)
|
|
);
|
|
|
|
fixture.detectChanges();
|
|
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
|
fixture.detectChanges();
|
|
component.requestExternalFallback('mpv');
|
|
|
|
expect(component.playbackDiagnostic()).toBeNull();
|
|
expect(
|
|
fixture.debugElement.query(
|
|
By.directive(StubEmbeddedMpvPlayerComponent)
|
|
)
|
|
).not.toBeNull();
|
|
expect(
|
|
fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-diagnostic-banner"]')
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-fallback-mpv"]')
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-fallback-vlc"]')
|
|
)
|
|
).toBeNull();
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
it('passes series navigation to embedded MPV and forwards episode navigation events', () => {
|
|
const events: string[] = [];
|
|
const seriesNavigation = {
|
|
canPrevious: true,
|
|
canNext: false,
|
|
autoplayEnabled: true,
|
|
};
|
|
fixture.componentRef.setInput(
|
|
'playerOverride',
|
|
VideoPlayer.EmbeddedMpv
|
|
);
|
|
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
|
|
(
|
|
component as unknown as {
|
|
playbackEnded: { subscribe: (fn: () => void) => void };
|
|
previousEpisodeRequested: {
|
|
subscribe: (fn: () => void) => void;
|
|
};
|
|
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
|
}
|
|
).playbackEnded.subscribe(() => events.push('ended'));
|
|
(
|
|
component as unknown as {
|
|
previousEpisodeRequested: {
|
|
subscribe: (fn: () => void) => void;
|
|
};
|
|
}
|
|
).previousEpisodeRequested.subscribe(() => events.push('previous'));
|
|
(
|
|
component as unknown as {
|
|
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
|
}
|
|
).nextEpisodeRequested.subscribe(() => events.push('next'));
|
|
|
|
fixture.detectChanges();
|
|
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubEmbeddedMpvPlayerComponent)
|
|
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
|
expect(player.seriesNavigation()).toBe(seriesNavigation);
|
|
|
|
player.playbackEnded.emit();
|
|
player.previousEpisodeRequested.emit();
|
|
player.nextEpisodeRequested.emit();
|
|
|
|
expect(events).toEqual(['ended', 'previous', 'next']);
|
|
});
|
|
|
|
it('uses the PWA browser access diagnostic description key outside desktop', () => {
|
|
const issue = createBrowserAccessDiagnostic();
|
|
|
|
expect(component.getDiagnosticTitleKey(issue)).toBe(
|
|
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
|
|
);
|
|
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
|
|
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
|
|
);
|
|
});
|
|
|
|
it('keeps the desktop browser access diagnostic description key', () => {
|
|
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
|
const issue = createBrowserAccessDiagnostic();
|
|
|
|
expect(component.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());
|
|
fixture.detectChanges();
|
|
|
|
const banner = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-diagnostic-banner"]')
|
|
);
|
|
const mpvButton = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-fallback-mpv"]')
|
|
);
|
|
|
|
expect(mpvButton).toBeNull();
|
|
expect(banner.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.TITLE'
|
|
);
|
|
expect(banner.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
|
|
);
|
|
});
|
|
|
|
it('renders technical details and codec-specific hints in the diagnostic banner', () => {
|
|
fixture.detectChanges();
|
|
const issue = createUnsupportedCodecDiagnostic();
|
|
|
|
component.handlePlaybackIssue(issue);
|
|
fixture.detectChanges();
|
|
|
|
const details = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-diagnostic-details"]')
|
|
);
|
|
const codecHint = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-codec-hint"]')
|
|
);
|
|
|
|
expect(details.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
|
|
);
|
|
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
|
|
expect(component.getDiagnosticDetails(issue)).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
|
|
value: 'unsupported-codec',
|
|
},
|
|
{
|
|
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
|
|
value: 'Video.js',
|
|
},
|
|
{
|
|
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
|
|
value: 'hvc1.1.6.L93.B0',
|
|
},
|
|
{
|
|
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
|
|
value: 'ac-3',
|
|
},
|
|
])
|
|
);
|
|
expect(codecHint.nativeElement.textContent).toContain(
|
|
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
|
|
);
|
|
});
|
|
|
|
it('clears playback diagnostics when retrying inline playback', async () => {
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
fixture.detectChanges();
|
|
const player = fixture.debugElement.query(
|
|
By.directive(StubVjsPlayerComponent)
|
|
).componentInstance as StubVjsPlayerComponent;
|
|
expect(player.options()).toEqual(
|
|
expect.objectContaining({ reloadToken: 0 })
|
|
);
|
|
|
|
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
|
fixture.detectChanges();
|
|
|
|
const retryButton = fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-retry"]')
|
|
);
|
|
const utilityControls = fixture.nativeElement.querySelectorAll(
|
|
'[data-test-id="playback-retry"], [data-test-id="playback-diagnostic-details"]'
|
|
);
|
|
|
|
expect(utilityControls[0]).toBe(retryButton.nativeElement);
|
|
|
|
retryButton.nativeElement.click();
|
|
fixture.detectChanges();
|
|
|
|
expect(component.playbackDiagnostic()).toBeNull();
|
|
expect(player.options()).toEqual(
|
|
expect.objectContaining({ reloadToken: 1 })
|
|
);
|
|
expect(
|
|
fixture.debugElement.query(
|
|
By.css('[data-test-id="playback-diagnostic-banner"]')
|
|
)
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
|
return {
|
|
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
|
source: PlaybackDiagnosticSource.Native,
|
|
sourceUrl: 'https://example.com/archive/movie.mkv',
|
|
container: 'mkv',
|
|
mimeType: 'video/matroska',
|
|
player: 'videojs',
|
|
audioCodecs: [],
|
|
videoCodecs: [],
|
|
externalFallbackRecommended: true,
|
|
};
|
|
}
|
|
|
|
function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
|
|
return {
|
|
code: PlaybackDiagnosticCode.BrowserAccessError,
|
|
source: PlaybackDiagnosticSource.Hls,
|
|
sourceUrl: 'https://provider.example/live.m3u8',
|
|
container: 'm3u8',
|
|
mimeType: 'application/x-mpegURL',
|
|
player: 'videojs',
|
|
audioCodecs: [],
|
|
videoCodecs: [],
|
|
details: 'blocked by CORS policy',
|
|
externalFallbackRecommended: true,
|
|
};
|
|
}
|
|
|
|
function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
|
|
return {
|
|
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
|
source: PlaybackDiagnosticSource.Hls,
|
|
sourceUrl: 'https://example.com/live/index.m3u8',
|
|
container: 'm3u8',
|
|
mimeType: 'application/x-mpegURL',
|
|
player: 'videojs',
|
|
audioCodecs: ['ac-3'],
|
|
videoCodecs: ['hvc1.1.6.L93.B0'],
|
|
details: 'manifestIncompatibleCodecsError',
|
|
externalFallbackRecommended: true,
|
|
};
|
|
}
|
|
|
|
function createNetworkDiagnostic(): PlaybackDiagnostic {
|
|
return {
|
|
code: PlaybackDiagnosticCode.NetworkError,
|
|
source: PlaybackDiagnosticSource.MpegTs,
|
|
sourceUrl: 'https://example.com/live/channel.ts',
|
|
container: 'ts',
|
|
mimeType: 'video/mp2t',
|
|
player: 'videojs',
|
|
audioCodecs: [],
|
|
videoCodecs: [],
|
|
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
|
|
externalFallbackRecommended: false,
|
|
};
|
|
}
|