From 2ae62f5a128d32dc776c5b35d71c2887c6ae34ee Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 18 May 2026 23:15:31 +0200 Subject: [PATCH] fix(playback): preserve headers in diagnostics wrapper --- .../lib/vjs-player/vjs-player.component.ts | 15 +++- .../web-player-view.component.html | 2 +- .../web-player-view.component.spec.ts | 73 ++++++++++++++++++ .../web-player-view.component.ts | 74 ++++++++++++++++--- 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 8a5eba63a..cc3e2a5c1 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -37,6 +37,7 @@ type VideoPlayerSource = { type VideoPlayerOptions = Record & { autoplay?: boolean; isLive?: boolean; + reloadToken?: number; sources?: VideoPlayerSource[]; }; @@ -235,7 +236,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { const previousSource = changes['options'].previousValue.sources?.[0]; const newSource = changes['options'].currentValue.sources?.[0]; - if (this.hasSourceChanged(previousSource, newSource)) { + if ( + this.hasPlaybackInputChanged( + changes['options'].previousValue, + changes['options'].currentValue, + previousSource, + newSource + ) + ) { this.playbackIssue.emit(null); this.destroyMpegTs(); if (!newSource) { @@ -274,11 +282,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return (extension === 'ts' || !extension) && mpegts.isSupported(); } - private hasSourceChanged( + private hasPlaybackInputChanged( + previousOptions: VideoPlayerOptions, + newOptions: VideoPlayerOptions, previousSource: VideoPlayerSource | undefined, newSource: VideoPlayerSource | undefined ): boolean { return ( + previousOptions.reloadToken !== newOptions.reloadToken || previousSource?.src !== newSource?.src || previousSource?.type !== newSource?.type ); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 198e394fb..a7d7ae99a 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -53,7 +53,7 @@

- {{ getDiagnosticHeadlineKey() | translate }} + {{ diagnosticHeadlineKey() | translate }}

diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 7f9ef4565..7e70e8642 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -234,6 +234,69 @@ describe('WebPlayerViewComponent', () => { ); }); + it('preserves playback HTTP metadata for channel-based players', () => { + 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(); + + 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', () => { + 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(); + + 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('uses browser access diagnostic translation keys', () => { const issue = createBrowserAccessDiagnostic(); @@ -311,6 +374,13 @@ describe('WebPlayerViewComponent', () => { it('clears playback diagnostics when retrying inline playback', () => { 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(); @@ -327,6 +397,9 @@ describe('WebPlayerViewComponent', () => { 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"]') diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 04a92a59b..2477f5403 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -8,6 +8,7 @@ import { input, output, signal, + untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ClipboardModule } from '@angular/cdk/clipboard'; @@ -17,6 +18,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe } from '@ngx-translate/core'; import { + Channel, ResolvedPortalPlayback, Settings, STORE_KEY, @@ -80,19 +82,26 @@ export class WebPlayerViewComponent { this.storage.get(STORE_KEY.Settings) ) as Signal; - channel!: { url: string }; + channel!: Channel; player!: VideoPlayer; vjsOptions!: { isLive: boolean; + reloadToken: number; sources: { src: string; type: string }[]; }; readonly isDesktop = signal(this.detectDesktop()); + readonly reloadToken = signal(0); readonly playbackDiagnostic = signal(null); readonly canShowExternalFallbackActions = computed( () => this.isDesktop() && !!this.playbackDiagnostic()?.externalFallbackRecommended ); + readonly diagnosticHeadlineKey = computed(() => + this.canShowExternalFallbackActions() + ? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE' + : 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + ); readonly resolvedPlayback = computed(() => { const playback = this.playback(); @@ -119,7 +128,7 @@ export class WebPlayerViewComponent { const playback = this.resolvedPlayback(); this.playbackDiagnostic.set(null); - this.setChannel(playback.streamUrl); + this.setChannel(playback); this.setVjsOptions( playback.streamUrl, this.isLivePlayback(playback) @@ -138,13 +147,47 @@ export class WebPlayerViewComponent { this.vjsOptions = { isLive, + reloadToken: untracked(() => this.reloadToken()), sources: [{ src: streamUrl, type: mimeType }], }; } - setChannel(streamUrl: string) { + setChannel(playbackOrUrl: ResolvedPortalPlayback | string) { + const playback = + typeof playbackOrUrl === 'string' + ? { + streamUrl: playbackOrUrl, + title: playbackOrUrl, + } + : playbackOrUrl; + this.channel = { - url: streamUrl, + id: playback.streamUrl, + url: playback.streamUrl, + name: playback.title || playback.streamUrl, + group: { title: '' }, + tvg: { + id: '', + name: playback.title || playback.streamUrl, + url: '', + logo: playback.thumbnail ?? '', + rec: '', + }, + http: { + referrer: + playback.referer ?? + this.getHeaderValue(playback.headers, 'Referer') ?? + '', + 'user-agent': + playback.userAgent ?? + this.getHeaderValue(playback.headers, 'User-Agent') ?? + '', + origin: + playback.origin ?? + this.getHeaderValue(playback.headers, 'Origin') ?? + '', + }, + radio: 'false', }; } @@ -169,7 +212,8 @@ export class WebPlayerViewComponent { const playback = this.resolvedPlayback(); this.playbackDiagnostic.set(null); - this.setChannel(playback.streamUrl); + this.reloadToken.update((value) => value + 1); + this.setChannel(playback); this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback)); } @@ -181,12 +225,6 @@ export class WebPlayerViewComponent { return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`; } - getDiagnosticHeadlineKey(): string { - return this.canShowExternalFallbackActions() - ? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE' - : 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'; - } - getDiagnosticMeta(issue: PlaybackDiagnostic): string { const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', '); if (codecs) { @@ -279,6 +317,20 @@ export class WebPlayerViewComponent { return !playback.contentInfo; } + private getHeaderValue( + headers: ResolvedPortalPlayback['headers'] | undefined, + name: string + ): string | undefined { + if (!headers) { + return undefined; + } + + const matchingKey = Object.keys(headers).find( + (key) => key.toLowerCase() === name.toLowerCase() + ); + return matchingKey ? headers[matchingKey] : undefined; + } + private formatPlayer(player: PlaybackDiagnostic['player']): string { switch (player) { case 'videojs':