mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(playback): forward portal Cookie/Authorization to built-in players The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal session cookie or Bearer token played exclusively in external MPV/VLC — the long-running "only VLC works" cluster (#849, #910, #732). - request-header-overrides.service: the scoped override now carries Cookie and Authorization, attached only to requests on the exact stream origin, in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls drop credentials fail-closed; control characters in header values are rejected. Chosen over session.cookies.set(): jar cookies attach only to credentialed requests, which would force withCredentials into every engine and break against the Access-Control-Allow-Origin:* IPTV panels send, and jar scoping is port-blind. - WebPlayerViewComponent is now the single owner of the scoped override for every built-in player: it extracts the full header set from the resolved playback, configures the override BEFORE handing the source over (players render only once the source exists), and clears the scoped layer on destroy. HtmlVideoPlayerComponent's own three-header call is removed — it would overwrite the credentialed override. - Stalker VOD, series episodes and radio now build the same portal header set ITV already had (they previously carried no portal headers at all); same-origin playback sends the real User-Agent alongside X-User-Agent. - Stream classification is host-based via one shared predicate (isStalkerStreamCredentialSafe): same-host port changes and scheme upgrades keep the portal profile (the #1158 class), a foreign host or https->http downgrade keeps the credential-free KSPlayer profile. The main-process fallback context uses the same predicate so isStalkerDirectStreamProfile can no longer discard renderer headers. - setUserAgent bridge gains an optional credentials parameter; preload, ipcMain handler and ElectronBridgeApi updated together. - stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose create_link returns a local /stream/gated/video.mp4 that 403s without the mac cookie + current Bearer token; new Electron e2e proves a built-in player actually plays it (and that the gate refuses bare requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): apply header override to Stalker radio, redact mock cookie log Address Codex review feedback on #1335: - The radio branch of the Stalker live layout renders the dedicated audio player, never WebPlayerViewComponent, so the resolved portal headers were built but never applied — an auth-gated radio stream still 403'd. The override sync is extracted into ElectronStreamHeadersService (single owner of the scoped override slot, with clear-only-while-owning semantics so a destroyed consumer cannot wipe a newer consumer's override), applied by WebPlayerViewComponent for video players and by the radio branch before the audio element gets its URL. The service feature-detects the bridge method so partial bridges behave like the PWA instead of throwing. - The gated-stream mock no longer logs the raw Cookie header on 403 — presence only, matching the Authorization logging. - The gated scenario now serves an audio fixture for radio create_link and the Electron e2e covers the radio path end-to-end (bare request 403s, built-in audio player advances past the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): claim radio header ownership before awaiting the IPC Codex round-2 P2: leaving the radio route while the header IPC was still in flight left the portal cookie/token installed — ngOnDestroy saw a null scope URL (it was recorded only after the await) and could not clear the override. Ownership is now claimed synchronously before awaiting, destroy invalidates the pending playback continuation, and the apply's stillCurrent verdict is honored. Regression test covers destroy-during-pending-IPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): carry portal headers into collection playback Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed resolved through StreamResolverService.resolveStalker(), which returned no portal headers — the video path handed the header owner an empty set and collection radio bypassed it entirely, so auth-gated streams still 403'd from collections. - resolveStalker() now builds the same profile as the live layout via the shared classifier: portal-owned streams get mac cookie/Bearer token/MAG UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer profile (both create_link results and direct radio URLs). - UnifiedLiveTabComponent applies the scoped override for radio before the audio element gets its URL (ownership claimed before awaiting the IPC, round-2 lesson), and clears it on close and destroy. - Regression tests: resolver header profiles for portal-host and foreign streams; unified tab radio apply-then-clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): release the radio override when a new selection mounts no player Codex round-4 P2: after radio installed its credentials, selecting an item that never mounts a player surface (external video playback, failed resolution) left the old Cookie/Authorization installed — no WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both radio hosts (unified collection tab and the Stalker live layout, which has the identical hole) now release the previously owned radio scope at the start of every new selection; the slot-ownership semantics keep this a no-op when another playback already owns the override. Regression test in the live-layout spec pins the failed-selection path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): state the exact override release points Codex round-5 P2 flagged that the media 'ended' event does not clear the scoped override while the player stays mounted. That is deliberate, not a gap: a mounted player still owns the session — replay or a seek into an unbuffered range must keep working against a gated stream, and clearing on 'ended' would 403 exactly the streams this PR fixes. The credentials only ever travel to the exact origin that issued them, and every dismount path (channel/source change, player close/destroy, radio close, playerless selection) releases them. The security doc and the release note now say precisely that instead of the ambiguous "cleared when playback ends". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): fit the release note back under the 400-character cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import {
|
|
Component,
|
|
OnDestroy,
|
|
Signal,
|
|
ViewEncapsulation,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
output,
|
|
signal,
|
|
untracked,
|
|
} from '@angular/core';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
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 { StorageMap } from '@ngx-pwa/local-storage';
|
|
import { TranslatePipe } from '@ngx-translate/core';
|
|
import {
|
|
Channel,
|
|
ResolvedPortalPlayback,
|
|
Settings,
|
|
STORE_KEY,
|
|
VideoPlayer,
|
|
type VodSourceDescriptor,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
|
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
|
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
|
|
|
/** How many recovery options the error screen shows before it stops helping. */
|
|
const ERROR_SCREEN_ALTERNATIVES = 5;
|
|
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
|
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
|
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
|
import {
|
|
type PlayerMediaTitle,
|
|
WEB_PLAYER_SHARED_CONTROLS,
|
|
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
|
|
} from '../player-controls';
|
|
import {
|
|
type PlaybackDiagnostic,
|
|
type PlaybackDiagnosticCode,
|
|
type PlaybackFallbackRequest,
|
|
getPlaybackMediaExtensionFromUrl,
|
|
} from '../playback-diagnostics/playback-diagnostics.util';
|
|
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
|
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
|
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
|
import {
|
|
getDiagnosticCodecHint,
|
|
getDiagnosticDescriptionKey,
|
|
getDiagnosticDetails,
|
|
getDiagnosticMeta,
|
|
getDiagnosticTitleKey,
|
|
} from './web-player-view-diagnostics.utils';
|
|
|
|
function resolveWebPlayerSharedControls(): boolean {
|
|
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
|
|
return typeof storedValue === 'boolean'
|
|
? storedValue
|
|
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-web-player-view',
|
|
templateUrl: './web-player-view.component.html',
|
|
styleUrls: ['./web-player-view.component.scss'],
|
|
host: {
|
|
class: 'web-player-view',
|
|
},
|
|
imports: [
|
|
ArtPlayerComponent,
|
|
ClipboardModule,
|
|
EmbeddedMpvPlayerComponent,
|
|
HtmlVideoPlayerComponent,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatTooltipModule,
|
|
TranslatePipe,
|
|
VjsPlayerComponent,
|
|
VodSourceRowComponent,
|
|
],
|
|
providers: [
|
|
{
|
|
provide: WEB_PLAYER_SHARED_CONTROLS,
|
|
useFactory: resolveWebPlayerSharedControls,
|
|
},
|
|
],
|
|
encapsulation: ViewEncapsulation.None,
|
|
})
|
|
export class WebPlayerViewComponent implements OnDestroy {
|
|
storage = inject(StorageMap);
|
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
|
private readonly settingsStore = inject(SettingsStore);
|
|
|
|
streamUrl = input.required<string>();
|
|
title = input('');
|
|
playback = input<ResolvedPortalPlayback | null>(null);
|
|
startTime = input<number>(0);
|
|
volume = input<number>(1);
|
|
playerOverride = input<VideoPlayer | null>(null);
|
|
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
|
/** Display-ready title lines for the fullscreen overlay; hosts with richer
|
|
* context (e.g. series name + episode label) pass it explicitly. */
|
|
mediaTitle = input<PlayerMediaTitle | null>(null);
|
|
readonly timeUpdate = output<{
|
|
currentTime: number;
|
|
duration: number;
|
|
}>();
|
|
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
|
/**
|
|
* Alternative sources offered on the error screen, turning a dead end into
|
|
* a recovery point. Empty (the default) keeps the overlay exactly as it
|
|
* was for every non-multi-source host.
|
|
*/
|
|
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
|
readonly alternativeSourceRequested = output<string>();
|
|
/** The row's Check action, which has to reach the host that can probe. */
|
|
readonly sourceCheckRequested = output<string>();
|
|
/**
|
|
* A playback failure the host may be able to recover from by switching
|
|
* source. Emitted alongside showing the overlay, never instead of it: if
|
|
* the host does nothing, the user still sees the honest error.
|
|
*/
|
|
readonly playbackFailed = output<PlaybackDiagnosticCode>();
|
|
|
|
/**
|
|
* An error screen is a recovery point, not a catalogue. Offering fifty
|
|
* options here is worse than offering the best few — the full list stays
|
|
* one click away behind the player's own sources button.
|
|
*/
|
|
readonly visibleAlternatives = computed(() =>
|
|
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
|
|
);
|
|
readonly hiddenAlternativeCount = computed(() =>
|
|
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
|
|
);
|
|
readonly playbackEnded = output<void>();
|
|
readonly previousEpisodeRequested = output<void>();
|
|
readonly nextEpisodeRequested = output<void>();
|
|
|
|
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
|
|
Settings | undefined
|
|
>;
|
|
|
|
/**
|
|
* Subtitle preference for the built-in web players. Read from the settings
|
|
* store instead of an input so every host (M3U, Xtream, Stalker, portal
|
|
* detail pages) gets it without having to wire it through — the missing
|
|
* bindings were why the setting looked like a no-op (#1155).
|
|
*/
|
|
readonly showCaptions = computed(
|
|
() => this.settingsStore.showCaptions?.() ?? false
|
|
);
|
|
|
|
channel!: Channel;
|
|
vjsOptions!: {
|
|
isLive: boolean;
|
|
reloadToken: number;
|
|
sources: { src: string; type: string }[];
|
|
};
|
|
readonly reloadToken = signal(0);
|
|
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
|
|
readonly visiblePlaybackDiagnostic = computed(() =>
|
|
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
|
|
? null
|
|
: this.playbackDiagnostic()
|
|
);
|
|
readonly playbackInteractionEnabled = computed(
|
|
() => this.visiblePlaybackDiagnostic() === null
|
|
);
|
|
readonly canShowExternalFallbackActions = computed(
|
|
() =>
|
|
this.runtime.supportsManagedExternalPlayers &&
|
|
!!this.visiblePlaybackDiagnostic()?.externalFallbackRecommended
|
|
);
|
|
readonly diagnosticHeadlineKey = computed(() =>
|
|
this.canShowExternalFallbackActions()
|
|
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
|
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
|
|
);
|
|
|
|
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
|
|
const playback = this.playback();
|
|
if (playback) {
|
|
return playback;
|
|
}
|
|
|
|
return {
|
|
streamUrl: this.streamUrl(),
|
|
title: this.title() || this.streamUrl(),
|
|
startTime: this.startTime(),
|
|
};
|
|
});
|
|
readonly resolvedIsLive = computed(() => {
|
|
const playback = this.resolvedPlayback();
|
|
return typeof playback.isLive === 'boolean'
|
|
? playback.isLive
|
|
: !playback.contentInfo;
|
|
});
|
|
readonly selectedPlayer = computed(
|
|
() =>
|
|
this.playerOverride() ??
|
|
this.settings()?.player ??
|
|
VideoPlayer.VideoJs
|
|
);
|
|
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
|
|
const explicit = this.mediaTitle();
|
|
if (explicit?.primary?.trim()) {
|
|
return explicit;
|
|
}
|
|
const playback = this.resolvedPlayback();
|
|
const title = playback.title?.trim();
|
|
// resolvedPlayback() falls back to the stream URL as title; a raw URL
|
|
// is not a watchable overlay title.
|
|
if (!title || title === playback.streamUrl) {
|
|
return null;
|
|
}
|
|
return { primary: title, secondary: null };
|
|
});
|
|
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
|
|
|
|
/** Stream URL the currently configured Electron header override belongs to. */
|
|
private headerScopeStreamUrl: string | null = null;
|
|
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
// Track player changes so stale browser diagnostics are cleared on switch.
|
|
this.selectedPlayer();
|
|
|
|
const playback = this.resolvedPlayback();
|
|
const isLive = this.resolvedIsLive();
|
|
this.playbackDiagnostic.set(null);
|
|
this.applyPlayback(playback, isLive);
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
// Portal credentials must not outlive the playback session that
|
|
// needed them: dropping the scoped override here keeps only the
|
|
// playlist-level (unscoped) User-Agent/Referer defaults active. The
|
|
// service no-ops if a newer consumer already owns the override slot.
|
|
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
|
}
|
|
|
|
/**
|
|
* Configures the scoped Electron request headers BEFORE the stream source
|
|
* is handed to a player, so the very first media request already carries
|
|
* them — an auth-gated portal stream answers 403 without its
|
|
* Cookie/Authorization, and several engines treat that first failure as
|
|
* fatal. In the PWA there is no header bridge and the source applies
|
|
* synchronously, exactly as before.
|
|
*/
|
|
private applyPlayback(
|
|
playback: ResolvedPortalPlayback,
|
|
isLive: boolean
|
|
): void {
|
|
const headerSync = this.streamHeaders.apply(playback);
|
|
this.headerScopeStreamUrl = playback.streamUrl;
|
|
const handOff = (): void => {
|
|
this.setChannel(playback);
|
|
this.setVjsOptions(playback.streamUrl, isLive);
|
|
};
|
|
|
|
if (!headerSync) {
|
|
handOff();
|
|
return;
|
|
}
|
|
|
|
void headerSync.then((stillCurrent) => {
|
|
if (stillCurrent) {
|
|
handOff();
|
|
}
|
|
});
|
|
}
|
|
|
|
setVjsOptions(streamUrl: string, isLive = true) {
|
|
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
|
|
const mimeType =
|
|
extension === 'm3u' || extension === 'm3u8'
|
|
? 'application/x-mpegURL'
|
|
: extension === 'ts' || !extension
|
|
? 'video/mp2t'
|
|
: extension === 'mkv'
|
|
? 'video/matroska'
|
|
: 'video/mp4';
|
|
|
|
this.vjsOptions = {
|
|
isLive,
|
|
reloadToken: untracked(() => this.reloadToken()),
|
|
sources: [{ src: streamUrl, type: mimeType }],
|
|
};
|
|
}
|
|
|
|
setChannel(playbackOrUrl: ResolvedPortalPlayback | string) {
|
|
const playback =
|
|
typeof playbackOrUrl === 'string'
|
|
? {
|
|
streamUrl: playbackOrUrl,
|
|
title: playbackOrUrl,
|
|
}
|
|
: playbackOrUrl;
|
|
|
|
this.channel = {
|
|
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',
|
|
drm: playback.drm,
|
|
};
|
|
}
|
|
|
|
handlePlaybackIssue(issue: PlaybackDiagnostic | null): void {
|
|
if (this.selectedPlayer() === VideoPlayer.EmbeddedMpv) {
|
|
this.playbackDiagnostic.set(null);
|
|
return;
|
|
}
|
|
|
|
this.playbackDiagnostic.set(issue);
|
|
if (issue) {
|
|
this.playbackFailed.emit(issue.code);
|
|
}
|
|
}
|
|
|
|
requestExternalFallback(player: ExternalPlayerName): void {
|
|
const diagnostic = this.visiblePlaybackDiagnostic();
|
|
if (!diagnostic) {
|
|
return;
|
|
}
|
|
|
|
this.externalFallbackRequested.emit({
|
|
player,
|
|
playback: this.resolvedPlayback(),
|
|
diagnostic,
|
|
});
|
|
}
|
|
|
|
retryPlayback(): void {
|
|
const playback = this.resolvedPlayback();
|
|
|
|
this.playbackDiagnostic.set(null);
|
|
this.reloadToken.update((value) => value + 1);
|
|
this.setChannel(playback);
|
|
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
|
}
|
|
|
|
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
|
|
readonly getDiagnosticMeta = getDiagnosticMeta;
|
|
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
|
|
readonly getDiagnosticDetails = getDiagnosticDetails;
|
|
|
|
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
|
|
return getDiagnosticDescriptionKey(
|
|
issue,
|
|
this.runtime.supportsManagedExternalPlayers
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|