mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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://');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user