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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 09:26:33 +02:00
1 parent 4c0be3fde5
commit 02969dca26
6 files changed
+221 -22

No files matched your search

+6 -5
View File
@@ -137,12 +137,13 @@ owner of the scoped override slot: it extracts the full header set from the
resolved playback (including the Stalker mac cookie and Bearer token), and
its `clear()` releases the slot only while the caller's stream still owns it,
so a consumer being destroyed cannot wipe an override a newer consumer just
configured. Two surfaces apply it: `WebPlayerViewComponent` for every
configured. Three surfaces apply it: `WebPlayerViewComponent` for every
built-in video player (configuring the override **before** handing the
source over, clearing on destroy), and the Stalker live layout for the
dedicated radio audio player, which never mounts a `WebPlayerViewComponent`.
Individual player components must not call the bridge themselves — a
narrower call would overwrite the credentialed override.
source over, clearing on destroy), and — for the dedicated radio audio
player, which never mounts a `WebPlayerViewComponent` — the Stalker live
layout and the unified collection tab (global/portal Favorites and Recently
Viewed). Individual player components must not call the bridge themselves —
a narrower call would overwrite the credentialed override.
Rules:
+4
View File
@@ -189,6 +189,10 @@ Every playback kind — ITV, VOD, series episodes, and radio — resolves its
stream and attaches the same portal header set through
`buildStalkerExternalPlaybackHeaders()`
(`libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts`).
The collection routes (Favorites/Recently Viewed) share the contract:
`StreamResolverService.resolveStalker()` builds the identical profile for the
streams it resolves, so a channel opened from a collection carries the same
credentials as one opened from the portal.
The resolved `ResolvedPortalPlayback.headers` feed both the external players
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
scoped Electron request-header override (`ElectronStreamHeadersService`,
@@ -23,7 +23,10 @@ describe('StreamResolverService', () => {
let xtreamApi: { getShortEpg: jest.Mock };
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
let stalkerSession: {
getCachedToken: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
beforeEach(() => {
@@ -40,6 +43,7 @@ describe('StreamResolverService', () => {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
getCachedToken: jest.fn(() => null),
makeAuthenticatedRequest: jest.fn(),
};
epgBridge = {
@@ -670,6 +674,10 @@ describe('StreamResolverService', () => {
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
// A direct radio URL on a host foreign to the portal gets the
// credential-free KSPlayer direct-stream profile — the same rule the
// Stalker live layout applies (the previous playlist-field passthrough
// predated the shared profile classifier).
expect(detail).toEqual(
expect.objectContaining({
epgMode: 'portal',
@@ -680,21 +688,19 @@ describe('StreamResolverService', () => {
radio: 'true',
url: 'https://media.example.com/direct-radio.mp3',
http: expect.objectContaining({
referrer: 'https://ref.example.com',
'user-agent': 'IPTVnator',
origin: 'https://origin.example.com',
'user-agent': 'KSPlayer',
}),
}),
playback: expect.objectContaining({
streamUrl: 'https://media.example.com/direct-radio.mp3',
title: 'Direct Radio',
thumbnail: 'direct-radio.png',
userAgent: 'IPTVnator',
referer: 'https://ref.example.com',
origin: 'https://origin.example.com',
userAgent: 'KSPlayer',
}),
})
);
expect(detail.playback.headers?.['Cookie']).toBeUndefined();
expect(detail.playback.headers?.['Authorization']).toBeUndefined();
});
it('resolves relative Stalker create_link responses against the portal base', async () => {
@@ -730,6 +736,78 @@ describe('StreamResolverService', () => {
);
});
it('attaches the portal header set to Stalker collection playback on the portal host', async () => {
// The collection routes must carry the same credentials as the live
// layout — an auth-gated stream opened from Favorites/Recent 403s
// without the mac cookie and Bearer token (Codex round-3 finding).
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::88',
name: 'Gated Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '88',
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
expect(playback.headers?.['Cookie']).toContain(
'mac=00:11:22:33:44:55'
);
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN77');
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
expect(playback.referer).toBe('https://stalker.example.com');
expect(playback.origin).toBe('https://stalker.example.com');
});
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'ffmpeg http://cdn.other.example/live/88.ts' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::88',
name: 'Direct Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '88',
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
} satisfies UnifiedCollectionItem);
expect(playback.headers?.['Cookie']).toBeUndefined();
expect(playback.headers?.['Authorization']).toBeUndefined();
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
expect(playback.referer).toBeUndefined();
expect(playback.origin).toBeUndefined();
});
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -18,6 +18,9 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
normalizeStalkerPlaybackCommand,
resolveStalkerPlaybackUrl,
StalkerSessionService,
@@ -331,14 +334,11 @@ export class StreamResolverService {
item.stalkerCmd ?? ''
);
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
return {
return this.buildStalkerPlayback(item, playlist, {
macAddress,
portalUrl,
streamUrl: normalizedCmd,
title: item.name,
thumbnail: item.logo ?? null,
userAgent: playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
};
});
}
const contentType = item.radio === 'true' ? 'radio' : 'itv';
@@ -367,7 +367,9 @@ export class StreamResolverService {
const rawCmd = response?.js?.cmd ?? '';
return {
return this.buildStalkerPlayback(item, playlist, {
macAddress,
portalUrl,
// Shared normalizer from the Stalker store: strips the solution
// prefix and resolves relative `/media/...` or `?...` responses
// against the portal base instead of returning them verbatim.
@@ -376,9 +378,60 @@ export class StreamResolverService {
item.stalkerCmd ?? '',
rawCmd
),
isLive: item.radio === 'true' ? undefined : true,
});
}
/**
* The collection routes must hand players the SAME portal header set the
* Stalker live layout builds — an auth-gated stream opened from Favorites
* or Recently Viewed 403s without the mac cookie/Bearer token exactly
* like one opened from the portal itself (the header owner then scopes
* them to the stream origin; foreign hosts get the credential-free
* profile from the shared classifier).
*/
private buildStalkerPlayback(
item: UnifiedCollectionItem,
playlist: Playlist | undefined,
resolved: {
macAddress: string;
portalUrl: string;
streamUrl: string;
isLive?: boolean;
}
): ResolvedPortalPlayback {
// The item may carry portal/mac overrides for playlists that no
// longer exist; the builder only reads header-relevant fields.
const headerPlaylist = {
...(playlist ?? {}),
macAddress: resolved.macAddress,
portalUrl: resolved.portalUrl,
} as Playlist;
const token = this.stalkerSession.getCachedToken(item.playlistId);
const headers = buildStalkerExternalPlaybackHeaders(
headerPlaylist,
token,
resolved.streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
headerPlaylist,
resolved.streamUrl
);
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
return {
streamUrl: resolved.streamUrl,
title: item.name,
thumbnail: item.logo ?? null,
isLive: item.radio === 'true' ? undefined : true,
isLive: resolved.isLive,
headers,
userAgent: headers['User-Agent'] || playlist?.userAgent,
referer: crossOriginStream
? undefined
: playlist?.referrer || portalOrigin,
origin: crossOriginStream
? undefined
: playlist?.origin || portalOrigin,
};
}
@@ -743,6 +743,15 @@ describe('UnifiedLiveTabComponent', () => {
});
it('renders inline audio for Stalker radio items and skips external playback', async () => {
// Radio renders the dedicated audio player, never the shared web
// player wrapper — so this test also pins that the tab itself
// configures the scoped header override (portal cookie/token for
// auth-gated streams) before the audio element gets the URL, and
// clears it again on close.
const setUserAgent = jest.fn().mockResolvedValue(true);
(
window.electron as unknown as Record<string, unknown>
)['setUserAgent'] = setUserAgent;
const item = {
...buildLiveItem('stalker'),
name: 'Jazz Radio',
@@ -754,6 +763,12 @@ describe('UnifiedLiveTabComponent', () => {
streamUrl: 'https://example.com/jazz.mp3',
title: 'Jazz Radio',
thumbnail: 'jazz.png',
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00:1A:79:00:00:01',
Authorization: 'Bearer TOKEN99',
},
},
channel: {
id: '40001',
@@ -802,6 +817,25 @@ describe('UnifiedLiveTabComponent', () => {
expect(audioPlayer.url()).toBe('https://example.com/jazz.mp3');
expect(audioPlayer.icon()).toBe('jazz.png');
expect(audioPlayer.channelName()).toBe('Jazz Radio');
expect(setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
'https://example.com/jazz.mp3',
{
authorization: 'Bearer TOKEN99',
cookie: 'mac=00:1A:79:00:00:01',
}
);
component.onClose();
// Closing the radio player must drop the portal credentials.
expect(setUserAgent).toHaveBeenLastCalledWith(
undefined,
undefined,
'https://example.com/jazz.mp3'
);
});
it('renders shared EPG view for Xtream items and records recent history', async () => {
@@ -55,6 +55,7 @@ import { GlobalFavoritesListComponent } from '../global-favorites-list/global-fa
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
import {
AudioPlayerComponent,
ElectronStreamHeadersService,
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
@@ -103,9 +104,12 @@ export class UnifiedLiveTabComponent {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly streamHeaders = inject(ElectronStreamHeadersService);
private readonly destroyRef = inject(DestroyRef);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
/** Stream URL of the radio playback whose header override this tab configured. */
private radioHeaderScopeUrl: string | null = null;
readonly player = this.settingsStore.player;
readonly supportsEpg = this.runtime.supportsEpg;
@@ -338,7 +342,13 @@ export class UnifiedLiveTabComponent {
() => this.progressTick.update((tick) => tick + 1),
30_000
);
this.destroyRef.onDestroy(() => clearInterval(tickInterval));
this.destroyRef.onDestroy(() => {
clearInterval(tickInterval);
// Invalidate a playback continuation still awaiting its header
// IPC and drop any radio credentials owned by this tab.
this.selectionRequestId += 1;
this.streamHeaders.clear(this.radioHeaderScopeUrl);
});
}
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
@@ -539,6 +549,10 @@ export class UnifiedLiveTabComponent {
this.activeUid.set(null);
this.activeItem.set(null);
this.activeTimeshift.set(null);
// Radio credentials must not outlive the closed player; the service
// no-ops when a newer playback already owns the override slot.
this.streamHeaders.clear(this.radioHeaderScopeUrl);
this.radioHeaderScopeUrl = null;
}
onEpgMappingChanged(): void {
@@ -587,6 +601,21 @@ export class UnifiedLiveTabComponent {
return;
}
if (item.radio === 'true') {
// Radio renders the dedicated audio player, never
// WebPlayerViewComponent, so the scoped Electron header
// override (portal cookie/token for auth-gated streams) is
// configured here BEFORE the audio element gets the URL.
// Ownership is claimed synchronously so a close/destroy
// during the pending IPC can still clear the credentials.
const headerSync = this.streamHeaders.apply(detail.playback);
this.radioHeaderScopeUrl = detail.playback.streamUrl;
const stillCurrent = headerSync ? await headerSync : true;
if (!stillCurrent || requestId !== this.selectionRequestId) {
return;
}
}
this.activeDetail.set(detail);
if (this.supportsEpg && detail.epgMode === 'm3u') {