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 { VodSourceRowComponent } from '@iptvnator/ui/components'; 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 { ErrorDetails, ErrorTypes } from 'hls.js'; 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: '
', }) class StubVjsPlayerComponent { readonly options = input(); readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); } @Component({ selector: 'app-html-video-player', template: '
', }) class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); } @Component({ selector: 'app-art-player', template: '
', }) class StubArtPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); } @Component({ selector: 'app-embedded-mpv-player', template: '
', }) class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly mediaTitle = input(null); readonly recordingFolder = input(''); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); } describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; 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, // Real, not stubbed: the point of the test below is // that this row's Check action reaches the host. VodSourceRowComponent, 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'); }); describe('resolvedMediaTitle', () => { it('prefers an explicit media title over the playback title', async () => { fixture.componentRef.setInput('mediaTitle', { primary: 'Breaking Code', secondary: 'S01E02', }); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(component.resolvedMediaTitle()).toEqual({ primary: 'Breaking Code', secondary: 'S01E02', }); const player = fixture.debugElement.query( By.directive(StubVjsPlayerComponent) ).componentInstance as StubVjsPlayerComponent; expect(player.mediaTitle()).toEqual({ primary: 'Breaking Code', secondary: 'S01E02', }); }); it('falls back to the playback title as a single line', () => { fixture.detectChanges(); expect(component.resolvedMediaTitle()).toEqual({ primary: 'Example Movie', secondary: null, }); }); it('is null when the title falls back to the raw stream URL', () => { fixture.componentRef.setInput('title', ''); fixture.detectChanges(); expect(component.resolvedMediaTitle()).toBeNull(); }); }); it('relays the alternative row’s Check action out of the error screen', () => { const checked: string[] = []; component.sourceCheckRequested.subscribe((id) => checked.push(id)); fixture.componentRef.setInput('alternativeSources', [ { id: 'playlist-2:xtream:991', playlistId: 'playlist-2', playlistName: 'Portal Two', portalType: 'xtream', contentId: 991, rawTitle: 'Example Movie', matchConfidence: 'exact', year: null, isActive: false, isPinned: false, isTried: false, probe: { status: 'idle' }, }, ]); fixture.detectChanges(); component.handlePlaybackIssue(createUnsupportedContainerDiagnostic()); fixture.detectChanges(); const check = fixture.debugElement.query( By.css('app-vod-source-row .source-tag--action') ); expect(check).not.toBeNull(); check.nativeElement.click(); // Without the relay the button is visibly actionable and inert: the // row emits into an output nobody observes, so no probe ever runs. expect(checked).toEqual(['playlist-2:xtream:991']); }); 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('renders explicit HTTP evidence without recommending an external fallback', () => { runtimeCapabilities.supportsManagedExternalPlayers = true; const issue = createHttpDiagnostic(); fixture.detectChanges(); component.handlePlaybackIssue(issue); 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('HTTP 404'); expect(mpvButton).toBeNull(); expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404'); expect(component.getDiagnosticDetails(issue)).toEqual( expect.arrayContaining([ { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', value: 'HTTP 404 · networkrequestfailed', }, ]) ); }); it('renders only sanitized structured HLS evidence in technical details', () => { const issue = createStructuredHlsDiagnostic(); component.handlePlaybackIssue(issue); fixture.detectChanges(); const details = component.getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); expect(details).toEqual( expect.arrayContaining([ { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', value: 'stage=manifest · failure=http · ' + 'type=networkError · details=manifestLoadError · ' + 'disposition=fatal · HTTP 404', }, ]) ); expect(renderedDetails).not.toContain('diagnostic-url-secret'); expect(renderedDetails).not.toContain('provider.example'); }); it('renders only sanitized structured VHS evidence in technical details', () => { const issue = createStructuredVhsDiagnostic(); component.handlePlaybackIssue(issue); fixture.detectChanges(); const details = component.getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); expect(details).toEqual( expect.arrayContaining([ { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE', value: 'Video.js / VHS', }, { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', value: 'stage=unknown · type=networkbadstatus · code=4 · ' + 'disposition=terminal · HTTP 503', }, ]) ); expect(renderedDetails).not.toContain('vhs-render-secret'); expect(renderedDetails).not.toContain('provider.example'); expect(renderedDetails).not.toContain('Authorization'); 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', { 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(); 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.each([ { player: VideoPlayer.VideoJs, directive: StubVjsPlayerComponent, }, { player: VideoPlayer.Html5Player, directive: StubHtmlVideoPlayerComponent, }, { player: VideoPlayer.ArtPlayer, directive: StubArtPlayerComponent, }, ])( 'passes volume to $player inline player', async ({ player, directive }) => { fixture.componentRef.setInput('playerOverride', player); fixture.componentRef.setInput('volume', 0.42); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const playerElement = fixture.debugElement.query( By.directive(directive) ); expect(playerElement).not.toBeNull(); const playerInstance = playerElement.componentInstance as { volume: () => number; }; expect(playerInstance.volume()).toBe(0.42); } ); it.each([ { player: VideoPlayer.VideoJs, directive: StubVjsPlayerComponent, }, { player: VideoPlayer.Html5Player, directive: StubHtmlVideoPlayerComponent, }, { player: VideoPlayer.ArtPlayer, directive: StubArtPlayerComponent, }, ])( 'passes series navigation to $player and forwards episode navigation events', async ({ player: selectedPlayer, directive }) => { const events: string[] = []; const seriesNavigation = { canPrevious: true, canNext: false, autoplayEnabled: true, }; fixture.componentRef.setInput('playerOverride', selectedPlayer); fixture.componentRef.setInput('seriesNavigation', seriesNavigation); component.playbackEnded.subscribe(() => events.push('ended')); component.previousEpisodeRequested.subscribe(() => events.push('previous') ); component.nextEpisodeRequested.subscribe(() => events.push('next')); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const playerElement = fixture.debugElement.query( By.directive(directive) ); expect(playerElement).not.toBeNull(); const player = playerElement.componentInstance as { seriesNavigation: () => unknown; playbackEnded: { emit: () => void }; previousEpisodeRequested: { emit: () => void }; nextEpisodeRequested: { emit: () => void }; }; 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":""}', externalFallbackRecommended: false, }; } function createHttpDiagnostic(): PlaybackDiagnostic { return { code: PlaybackDiagnosticCode.NetworkError, source: PlaybackDiagnosticSource.Native, sourceUrl: 'https://example.com/live/missing.m3u8', container: 'm3u8', mimeType: 'application/x-mpegURL', player: 'videojs', audioCodecs: [], videoCodecs: [], nativeErrorCode: 4, nativeErrorMessage: 'source not supported', httpStatus: 404, nativeErrorType: 'networkrequestfailed', externalFallbackRecommended: false, }; } function createStructuredHlsDiagnostic(): PlaybackDiagnostic { return { code: PlaybackDiagnosticCode.NetworkError, source: PlaybackDiagnosticSource.Hls, sourceUrl: 'https://provider.example/live.m3u8?token=diagnostic-url-secret', container: 'm3u8', mimeType: 'application/x-mpegURL', player: 'html5', audioCodecs: [], videoCodecs: [], httpStatus: 404, hls: { engineType: ErrorTypes.NETWORK_ERROR, engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR, disposition: 'fatal', stage: 'manifest', failure: 'http', httpStatus: 404, }, externalFallbackRecommended: false, }; } function createStructuredVhsDiagnostic(): PlaybackDiagnostic { return { code: PlaybackDiagnosticCode.NetworkError, source: PlaybackDiagnosticSource.Vhs, sourceUrl: 'https://provider.example/live.m3u8?token=vhs-render-secret', container: 'm3u8', mimeType: 'application/x-mpegURL', player: 'videojs', audioCodecs: [], videoCodecs: [], details: 'Authorization response body vhs-render-secret', nativeErrorCode: 4, nativeErrorMessage: 'https://provider.example/error?token=vhs-render-secret', httpStatus: 503, vhs: { engineType: 'networkbadstatus', mediaErrorCode: 4, disposition: 'terminal', stage: 'unknown', httpStatus: 503, }, externalFallbackRecommended: false, }; }