Merge remote-tracking branch 'origin/master' into agent/download-season-queue

# Conflicts:
#	apps/electron-backend/src/app/events/database/download-requests.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
#	libs/ui/components/src/lib/season-container/episode-download.util.ts
#	libs/ui/components/src/lib/season-container/episode-utils.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
4gray committed 2026-08-02 18:06:37 +02:00
commit 31ac306bb0
243 files changed
+12935 -1106

No files matched your search

@@ -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,20 +688,152 @@ 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 () => {
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>)
);
// Portals frequently answer create_link with a solution-prefixed
// relative path; the shared normalizer must resolve it instead of
// handing the bare path to the player (the old weak normalizer did).
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'ffmpeg /media/file_123.mpg' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::88',
name: 'Relative Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '88',
stalkerCmd: 'ffrt3 http://stalker.example.com/media/999.mpg',
} satisfies UnifiedCollectionItem);
expect(playback.streamUrl).toBe(
'https://stalker.example.com/stalker_portal/media/file_123.mpg'
);
});
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({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '?token=xyz' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::89',
name: 'Token Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '89',
stalkerCmd: 'auto http://cdn.example.com/live/89.m3u8',
} satisfies UnifiedCollectionItem);
expect(playback.streamUrl).toBe(
'http://cdn.example.com/live/89.m3u8?token=xyz'
);
});
});
@@ -17,7 +17,14 @@ import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
normalizeStalkerPlaybackCommand,
resolveStalkerPlaybackUrl,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
type PlaylistWithChannels = Playlist & {
@@ -323,16 +330,15 @@ export class StreamResolverService {
const portalUrl =
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? '');
const normalizedCmd = normalizeStalkerPlaybackCommand(
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';
@@ -361,11 +367,71 @@ export class StreamResolverService {
const rawCmd = response?.js?.cmd ?? '';
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.
streamUrl: resolveStalkerPlaybackUrl(
portalUrl,
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: this.normalizeStalkerCmd(rawCmd),
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,
};
}
@@ -1083,26 +1149,6 @@ export class StreamResolverService {
}));
}
private normalizeStalkerCmd(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return '';
}
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://')
) {
return candidate;
}
}
return trimmed;
}
private isHttpUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}