mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
1 parent
4c0be3fde5
commit
02969dca26
6 files changed
+221
-22
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -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 () => {
|
||||
|
||||
+30
-1
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user