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

@@ -140,7 +140,33 @@ describe('main preload DB IPC contract', () => {
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
'https://stream.example/live.m3u8',
undefined
);
});
it('forwards scoped stream credentials alongside the header override', async () => {
const api = getExposedApi();
await api.setUserAgent(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8',
{
authorization: 'Bearer TOKEN',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
}
);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8',
{
authorization: 'Bearer TOKEN',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
}
);
});
@@ -450,8 +450,19 @@ const electronApi: ElectronBridgeApi = {
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl),
scopeUrl?: string | null,
credentials?: {
authorization?: string | null;
cookie?: string | null;
} | null
) =>
ipcRenderer.invoke(
'set-user-agent',
userAgent,
referer,
scopeUrl,
credentials
),
openInMpv: (
url: string,
title: string,
@@ -14,12 +14,16 @@ import type {
DownloadTask,
TransferProgress,
} from './download-task';
import { describeError, TruncatedTransferError } from './download-transfer';
import {
describeError,
InterruptedTransferError,
TruncatedTransferError,
} from './download-transfer';
/**
* Persistence for a failed startDownload() attempt, after its cancel/pause
* checkpoints have been ruled out. Chooses between: retaining a truncated
* transfer for a Range retry, committing an already-finalized file,
* checkpoints have been ruled out. Chooses between: retaining a recoverable
* partial for a Range retry, committing an already-finalized file,
* retaining a completed partial, or the generic delete-partial failure.
*/
export async function handleDownloadFailure(
@@ -28,8 +32,12 @@ export async function handleDownloadFailure(
reservation: ReservedPartialDownloadFile | undefined,
error: unknown
): Promise<void> {
if (error instanceof TruncatedTransferError && reservation) {
// The short response is retained so a retry can continue the
if (
(error instanceof TruncatedTransferError ||
error instanceof InterruptedTransferError) &&
reservation
) {
// The recoverable partial is retained so a retry can continue the
// transfer via Range instead of starting over.
await persistCompletedPartialFailure(
db,
@@ -338,7 +346,9 @@ function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(filePath: string | null | undefined): boolean {
export function removePartialFile(
filePath: string | null | undefined
): boolean {
try {
removePartialDownloadFile(filePath);
return true;
@@ -3,6 +3,7 @@ interface SetupOptions {
cleanupError?: Error;
claim?: { changes: number };
fileReappeared?: boolean;
playlistType?: 'xtream' | 'stalker';
row?: Record<string, unknown> | null;
urlError?: Error;
}
@@ -25,13 +26,18 @@ async function setup(options: SetupOptions = {}) {
url: 'https://example.test/movie.mp4',
...options.row,
};
const limit = jest.fn().mockResolvedValue(row ? [row] : []);
const downloadLimit = jest.fn().mockResolvedValue(row ? [row] : []);
const playlistLimit = jest
.fn()
.mockResolvedValue([{ type: options.playlistType ?? 'stalker' }]);
const where = jest.fn().mockResolvedValue(options.claim ?? { changes: 1 });
const set = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({
select: jest.fn((selection?: unknown) => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
where: jest.fn(() => ({
limit: selection ? playlistLimit : downloadLimit,
})),
})),
})),
update: jest.fn(() => ({ set })),
@@ -118,6 +124,27 @@ describe('redownload missing completed file', () => {
});
});
it('adds the Xtream fallback User-Agent to a legacy row', async () => {
const harness = await setup({
playlistType: 'xtream',
row: {
playlistId: 'playlist-1',
requestHeaders: null,
},
});
await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({
success: true,
});
expect(harness.enqueueDownload).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
},
})
);
});
it('recovers a file that reappeared without updating or enqueueing', async () => {
const harness = await setup({ fileReappeared: true });
@@ -6,7 +6,7 @@ import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
import { isAvailableDownloadFile } from './download-file-availability';
import { removePartialDownloadFile } from './download-file-path';
import { parseStoredHeaders } from './download-requests';
import { resolveStoredDownloadHeaders } from './download-request-headers';
import { enqueueDownload } from './download-runtime';
export interface RedownloadMissingResult {
@@ -61,6 +61,7 @@ export async function redownloadMissingRequest(
}
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
const headers = await resolveStoredDownloadHeaders(db, item);
try {
removePartialDownloadFile(item.filePath);
@@ -102,7 +103,7 @@ export async function redownloadMissingRequest(
directory: dirname(item.filePath),
fileName: basename(item.filePath),
filePath: item.filePath,
headers: parseStoredHeaders(item.requestHeaders),
headers,
id: item.id,
resumeValidator: null,
totalBytes: null,
@@ -0,0 +1,80 @@
import type { DownloadsDatabase } from './download-task';
function createDatabase(playlistType?: 'xtream' | 'stalker') {
const limit = jest
.fn()
.mockResolvedValue(playlistType ? [{ type: playlistType }] : []);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
} as unknown as DownloadsDatabase;
return { db, select: db.select };
}
describe('stored download request headers', () => {
it('adds the provider-compatible fallback for a legacy Xtream row', async () => {
const { db } = createDatabase('xtream');
const { resolveStoredDownloadHeaders } =
await import('./download-request-headers');
await expect(
resolveStoredDownloadHeaders(db, {
playlistId: 'playlist-1',
requestHeaders: JSON.stringify({
Authorization: 'not-allowed',
Referer: 'https://example.test/',
}),
})
).resolves.toEqual({
Referer: 'https://example.test/',
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
});
});
it('does not add the Xtream fallback to a Stalker row', async () => {
const { db } = createDatabase('stalker');
const { resolveStoredDownloadHeaders } =
await import('./download-request-headers');
await expect(
resolveStoredDownloadHeaders(db, {
playlistId: 'playlist-1',
requestHeaders: null,
})
).resolves.toBeUndefined();
});
it('adds the player fallback when a legacy row outlives its deleted source', async () => {
const { db } = createDatabase();
const { resolveStoredDownloadHeaders } =
await import('./download-request-headers');
await expect(
resolveStoredDownloadHeaders(db, {
playlistId: 'deleted-playlist',
requestHeaders: null,
})
).resolves.toEqual({
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
});
});
it('preserves an explicit stored User-Agent without querying the playlist', async () => {
const { db, select } = createDatabase('xtream');
const { resolveStoredDownloadHeaders } =
await import('./download-request-headers');
await expect(
resolveStoredDownloadHeaders(db, {
playlistId: 'playlist-1',
requestHeaders: JSON.stringify({
'User-Agent': 'Custom/1.0',
}),
})
).resolves.toEqual({ 'User-Agent': 'Custom/1.0' });
expect(select).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces';
import { eq } from 'drizzle-orm';
import * as schema from '../../database/schema';
import type { DownloadsDatabase } from './download-task';
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
export function parseStoredHeaders(
value: string | null | undefined
): Record<string, string> | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
// Re-apply the write-time allowlist so a tampered or imported
// database row cannot smuggle arbitrary headers into requests.
const entries = parsed as Record<string, unknown>;
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
(acc, key) => {
const headerValue = entries[key];
if (typeof headerValue === 'string') {
acc[key] = headerValue;
}
return acc;
},
{}
);
return Object.keys(headers).length > 0 ? headers : undefined;
} catch {
return undefined;
}
}
export async function resolveStoredDownloadHeaders(
db: DownloadsDatabase,
item: { playlistId: string; requestHeaders: string | null | undefined }
): Promise<Record<string, string> | undefined> {
const headers = parseStoredHeaders(item.requestHeaders);
if (headers?.['User-Agent']?.trim()) {
return headers;
}
const playlists = await db
.select({ type: schema.playlists.type })
.from(schema.playlists)
.where(eq(schema.playlists.id, item.playlistId))
.limit(1);
const playlistType = playlists[0]?.type;
if (playlistType !== undefined && playlistType !== 'xtream') {
return headers;
}
// Download rows intentionally survive individual playlist deletion. Older
// rows have no stored provider marker, so a missing source must use the
// IPTV-player fallback as the only recoverable identity-compatible default.
return {
...headers,
'User-Agent': XTREAM_CLIENT_USER_AGENT,
};
}
@@ -562,6 +562,66 @@ describe('download request identity resolution', () => {
});
describe('download requests resume', () => {
it('adds the Xtream fallback User-Agent to a legacy paused row', async () => {
jest.resetModules();
const schema = await import('../../database/schema');
const row = {
filePath: '/downloads/movie.mp4',
id: 42,
playlistId: 'playlist-1',
requestHeaders: null,
resumeValidator: '"etag-9"',
status: 'paused',
title: 'Movie',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
};
const downloadLimit = jest.fn().mockResolvedValue([row]);
const playlistLimit = jest.fn().mockResolvedValue([{ type: 'xtream' }]);
const db = {
select: jest.fn(() => ({
from: jest.fn((table: unknown) => ({
where: jest.fn(() => ({
limit:
table === schema.playlists
? playlistLimit
: downloadLimit,
})),
})),
})),
update: jest.fn(() => ({
set: jest.fn(() => ({
where: jest.fn().mockResolvedValue({ changes: 1 }),
})),
})),
};
const enqueueDownload = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-runtime', () => ({ enqueueDownload }));
const { resumeDownloadRequest } = await import('./download-requests');
await expect(
resumeDownloadRequest(42, '/unused', authorizer)
).resolves.toEqual({ success: true });
expect(enqueueDownload).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
},
})
);
});
it('enqueues a paused download with stored headers and original target path', async () => {
jest.resetModules();
@@ -10,6 +10,7 @@ import { assertRemoteUrlAllowed } from '../url-safety';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { removePartialDownloadFile } from './download-file-path';
import { resolveExistingDownloadIdentity } from './download-request-identity';
import { resolveStoredDownloadHeaders } from './download-request-headers';
import {
assertDownloadMetadataArtworkDiffersFromStream,
assertDownloadMetadataMatchesContentType,
@@ -84,40 +85,6 @@ function serializeHeaders(
return headers ? JSON.stringify(headers) : null;
}
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
export function parseStoredHeaders(
value: string | null
): Record<string, string> | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
// Re-apply the write-time allowlist so a tampered or imported
// database row cannot smuggle arbitrary headers into requests.
const entries = parsed as Record<string, unknown>;
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
(acc, key) => {
const headerValue = entries[key];
if (typeof headerValue === 'string') {
acc[key] = headerValue;
}
return acc;
},
{}
);
return Object.keys(headers).length > 0 ? headers : undefined;
} catch {
return undefined;
}
}
export async function startDownloadRequest(
data: StartDownloadRequest,
authorizer: DownloadDirectoryAuthorizer
@@ -324,6 +291,7 @@ export async function retryDownloadRequest(
const fileName = retainedFilePath
? basename(retainedFilePath)
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
const queuedUpdate = retainedFilePath
? {
errorMessage: null,
@@ -349,7 +317,7 @@ export async function retryDownloadRequest(
directory,
fileName,
filePath: retainedFilePath,
headers: parseStoredHeaders(item.requestHeaders),
headers,
id: item.id,
resumeValidator: retainedFilePath ? item.resumeValidator : null,
totalBytes: retainedFilePath ? item.totalBytes : null,
@@ -392,6 +360,7 @@ export async function resumeDownloadRequest(
const fileName = item.filePath
? basename(item.filePath)
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
// Claim the row atomically: a concurrent resume for the same id loses
// this conditional update and must not enqueue a second task.
@@ -420,7 +389,7 @@ export async function resumeDownloadRequest(
directory,
fileName,
filePath: item.filePath,
headers: parseStoredHeaders(item.requestHeaders),
headers,
id: item.id,
resumeValidator: item.resumeValidator,
totalBytes: item.totalBytes,
@@ -62,6 +62,7 @@ describe('destination collision handling', () => {
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 100,
});
await waitForStatus(set, 'completed');
@@ -11,6 +11,7 @@ interface ResumeHarness {
interface ResumeHarnessOptions {
partialSize: number;
partialSizeAfterTransferError?: number;
response: {
data: Readable;
headers: Record<string, string>;
@@ -82,7 +83,12 @@ async function setupResumeHarness(
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => options.partialSize),
getPartialDownloadSize: jest
.fn()
.mockReturnValueOnce(options.partialSize)
.mockReturnValue(
options.partialSizeAfterTransferError ?? options.partialSize
),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
@@ -145,6 +151,34 @@ describe('download resume validation', () => {
);
});
it('restarts without Range when a retained partial has no validator', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 50,
response: {
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4', etag: '"etag-new"' },
status: 200,
},
});
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
totalBytes: 54,
})
);
await waitForStatus(harness.set, 'completed');
const requestOptions =
harness.requestWithValidatedRedirects.mock.calls[0][1];
expect(requestOptions.headers).toEqual({});
expect(harness.createWriteStream).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
{ flags: 'w' }
);
});
it('restarts from byte zero when a resume request is answered with 200', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
@@ -209,6 +243,7 @@ describe('download resume validation', () => {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 54,
})
);
@@ -245,6 +280,7 @@ describe('download resume validation', () => {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 100,
})
);
@@ -264,6 +300,191 @@ describe('download resume validation', () => {
}
});
it('retains received bytes when the connection resets mid-transfer', async () => {
const body = new PassThrough();
const harness = await setupResumeHarness({
finalSize: 'enoent',
partialSize: 0,
partialSizeAfterTransferError: 20,
response: {
data: body,
headers: {
'content-length': '100',
etag: '"etag-reset"',
},
status: 200,
},
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(createTask());
while (
harness.requestWithValidatedRedirects.mock.calls.length < 1
) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
body.write(Buffer.alloc(20, 'r'));
const resetError = new Error(
'socket hang up'
) as NodeJS.ErrnoException;
resetError.code = 'ECONNRESET';
body.destroy(resetError);
await waitForStatus(harness.set, 'failed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 20,
errorMessage:
'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file',
filePath: '/downloads/movie.mp4',
status: 'failed',
totalBytes: 100,
})
);
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
resumeValidator: '"etag-reset"',
})
);
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
});
it('retains an existing partial when a resumed response resets before another byte', async () => {
const body = new PassThrough();
const harness = await setupResumeHarness({
finalSize: 'enoent',
partialSize: 40,
partialSizeAfterTransferError: 40,
response: {
data: body,
headers: { 'content-range': 'bytes 40-99/100' },
status: 206,
},
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-reset"',
totalBytes: 100,
})
);
while (
harness.requestWithValidatedRedirects.mock.calls.length < 1
) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const resetError = new Error(
'socket hang up'
) as NodeJS.ErrnoException;
resetError.code = 'ECONNRESET';
body.destroy(resetError);
await waitForStatus(harness.set, 'failed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 40,
errorMessage:
'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file',
filePath: '/downloads/movie.mp4',
status: 'failed',
totalBytes: 100,
})
);
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
});
it.each([
{
code: 'EUNKNOWN',
headers: { 'content-length': '100' },
label: 'unknown stream error',
partialSizeAfterTransferError: 20,
},
{
code: 'ECONNRESET',
headers: {},
label: 'response without an advertised total',
partialSizeAfterTransferError: 20,
},
{
code: 'ECONNRESET',
headers: { 'content-length': '100' },
label: 'response without a representation validator',
partialSizeAfterTransferError: 20,
},
{
code: 'ECONNRESET',
headers: { 'content-length': '100' },
label: 'fresh zero-byte failure',
partialSizeAfterTransferError: 0,
},
{
code: 'ECONNRESET',
headers: { 'content-length': '100' },
label: 'partial larger than the advertised total',
partialSizeAfterTransferError: 101,
},
])(
'uses generic cleanup for $label',
async ({ code, headers, partialSizeAfterTransferError }) => {
const body = new PassThrough();
const harness = await setupResumeHarness({
finalSize: 'enoent',
partialSize: 0,
partialSizeAfterTransferError,
response: { data: body, headers, status: 200 },
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(createTask());
while (
harness.requestWithValidatedRedirects.mock.calls.length < 1
) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const streamError = new Error(
'socket hang up'
) as NodeJS.ErrnoException;
streamError.code = code;
body.destroy(streamError);
await waitForStatus(harness.set, 'failed');
expect(harness.removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
errorMessage: 'socket hang up',
filePath: null,
status: 'failed',
})
);
} finally {
consoleError.mockRestore();
}
}
);
it('captures a strong ETag from the first response for later resumes', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
@@ -42,17 +42,16 @@ describe('download runtime pause and resume', () => {
it('persists active pause without deleting the partial file', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
const stream = new PassThrough();
const requestWithValidatedRedirects = jest.fn(
async (
_url: string,
options: { signal?: AbortSignal }
) => {
async (_url: string, options: { signal?: AbortSignal }) => {
options.signal?.addEventListener('abort', () => {
stream.destroy(new Error('aborted'));
});
@@ -119,10 +118,12 @@ describe('download runtime pause and resume', () => {
}
});
it('uses an HTTP Range header when a partial file already exists', async () => {
it('uses an HTTP Range header when a validated partial file already exists', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
@@ -168,13 +169,17 @@ describe('download runtime pause and resume', () => {
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
});
await waitForCallCount(requestWithValidatedRedirects, 1);
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
'https://example.test/movie.mp4',
expect.objectContaining({
headers: expect.objectContaining({ Range: 'bytes=50-' }),
headers: expect.objectContaining({
'If-Range': '"etag-1"',
Range: 'bytes=50-',
}),
}),
{ allowPrivateNetworks: true }
);
@@ -184,7 +189,9 @@ describe('download runtime pause and resume', () => {
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
update: jest.fn(() => ({ set })),
};
@@ -271,7 +278,9 @@ describe('download runtime pause and resume', () => {
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = { update: jest.fn(() => ({ set })) };
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
@@ -338,7 +347,9 @@ describe('download runtime pause and resume', () => {
it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
@@ -34,12 +34,39 @@ export class TruncatedTransferError extends Error {
}
}
const RETAINABLE_NETWORK_ERROR_CODES = new Set([
'ECONNABORTED',
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
'ERR_HTTP2_STREAM_CANCEL',
'ERR_HTTP2_STREAM_ERROR',
'ERR_STREAM_PREMATURE_CLOSE',
]);
export class InterruptedTransferError extends Error {
constructor(
readonly progress: TransferProgress,
networkCode: string
) {
super(
`DOWNLOAD_NETWORK_INTERRUPTED (${networkCode}): Retry to continue from the saved partial file`
);
}
}
export async function transferToPartialFile(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): Promise<TransferProgress> {
const resumeOffset = getResumeOffset(task, reservation);
const retainedOffset = getResumeOffset(task, reservation);
const resumeOffset = task.resumeValidator ? retainedOffset : 0;
if (retainedOffset > 0 && resumeOffset === 0) {
console.warn(
`[Downloads] Restarting ${reservation.filename} from the beginning (saved partial has no ETag or Last-Modified validator)`
);
}
const headers = {
...(task.headers ?? {}),
@@ -130,6 +157,22 @@ export async function transferToPartialFile(
try {
await pipeline(readable, output);
} catch (error) {
const interruptedProgress = getInterruptedTransferProgress(
error,
reservation,
effectiveOffset,
totalBytes,
task.resumeValidator
);
if (interruptedProgress) {
await persistProgress(db, task, interruptedProgress.progress);
throw new InterruptedTransferError(
interruptedProgress.progress,
interruptedProgress.networkCode
);
}
throw error;
} finally {
abortController.signal.removeEventListener('abort', abortStream);
}
@@ -141,6 +184,40 @@ export async function transferToPartialFile(
return { bytesDownloaded, totalBytes };
}
function getInterruptedTransferProgress(
error: unknown,
reservation: ReservedPartialDownloadFile,
initialBytes: number,
totalBytes: number | null,
resumeValidator: string | null | undefined
): { networkCode: string; progress: TransferProgress } | null {
const networkCode =
error && typeof error === 'object' && 'code' in error
? String(error.code)
: '';
if (
!RETAINABLE_NETWORK_ERROR_CODES.has(networkCode) ||
totalBytes === null ||
!resumeValidator
) {
return null;
}
const bytesDownloaded = getPartialDownloadSize(reservation.path);
if (
bytesDownloaded === 0 ||
bytesDownloaded < initialBytes ||
bytesDownloaded >= totalBytes
) {
return null;
}
return {
networkCode,
progress: { bytesDownloaded, totalBytes },
};
}
function getResumeOffset(
task: DownloadTask,
reservation: ReservedPartialDownloadFile
@@ -1,5 +1,8 @@
import { ipcMain } from 'electron';
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
import {
configureRequestHeaderOverride,
type StreamCredentialHeaders,
} from '../services/request-header-overrides.service';
export default class SharedEvents {
static bootstrapSharedEvents(): Electron.IpcMain {
@@ -7,21 +10,46 @@ export default class SharedEvents {
}
}
ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => {
setUserAgent(userAgent, referer, scopeUrl);
return true;
});
function sanitizeCredentials(value: unknown): StreamCredentialHeaders | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const { cookie, authorization } = value as Record<string, unknown>;
return {
authorization:
typeof authorization === 'string' ? authorization : undefined,
cookie: typeof cookie === 'string' ? cookie : undefined,
};
}
ipcMain.handle(
'set-user-agent',
(_event, userAgent, referer, scopeUrl, credentials) => {
setUserAgent(
userAgent,
referer,
scopeUrl,
sanitizeCredentials(credentials)
);
return true;
}
);
/**
* Sets scoped request headers for the currently selected stream.
* @param userAgent user agent to use
* @param referer referer to use
* @param scopeUrl stream URL used to limit the override to the active origin
* @param credentials portal Cookie/Authorization for auth-gated streams;
* applied only to the exact origin of `scopeUrl` and only while the
* scoped override is active
*/
export function setUserAgent(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
scopeUrl?: string | null,
credentials?: StreamCredentialHeaders | null
): void {
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
configureRequestHeaderOverride(userAgent, referer, scopeUrl, credentials);
}
@@ -1,95 +0,0 @@
import {
LEGACY_DEFAULT_STALKER_SERIAL,
buildStalkerIdentityRequestContext,
} from './stalker-identity';
describe('buildStalkerIdentityRequestContext', () => {
const macAddress = '00:1A:79:AA:BB:CC';
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'stb',
action: 'get_profile',
sn: 'STALE-SN',
metrics: JSON.stringify({
mac: macAddress,
sn: 'STALE-SN',
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams.metrics))
).not.toHaveProperty('sn');
});
it('preserves a provided serial exactly in headers, params, and metrics', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: 'CustomSn123',
params: {
type: 'stb',
action: 'get_profile',
metrics: JSON.stringify({
mac: macAddress,
}),
},
});
expect(context.effectiveSerialNumber).toBe('CustomSn123');
expect(context.headers.SN).toBe('CustomSn123');
expect(context.cookieString).toContain('__cfduid=');
expect(
context.cookieString.match(/__cfduid=([^;]+)/)?.[1]
).toHaveLength(32);
expect(context.requestParams.sn).toBe('CustomSn123');
expect(JSON.parse(String(context.requestParams.metrics))).toEqual(
expect.objectContaining({
sn: 'CustomSn123',
})
);
});
it('treats the legacy default serial as absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
params: {
type: 'stb',
action: 'get_profile',
sn: LEGACY_DEFAULT_STALKER_SERIAL,
metrics: JSON.stringify({
mac: macAddress,
sn: LEGACY_DEFAULT_STALKER_SERIAL,
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams.metrics))
).not.toHaveProperty('sn');
});
it('removes stale SN params from non-profile requests', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'itv',
action: 'get_ordered_list',
sn: 'STALE-SN',
},
});
expect(context.requestParams).not.toHaveProperty('sn');
});
});
@@ -1,117 +0,0 @@
import {
buildStalkerSerialCfduid,
LEGACY_DEFAULT_STALKER_SERIAL,
normalizeStalkerSerialNumber,
} from '@iptvnator/shared/interfaces';
export { LEGACY_DEFAULT_STALKER_SERIAL };
interface StalkerIdentityRequestInput {
macAddress: string;
params: Record<string, string | number>;
token?: string;
serialNumber?: string;
}
interface StalkerIdentityRequestContext {
requestParams: Record<string, string | number>;
headers: Record<string, string>;
cookieString: string;
effectiveSerialNumber?: string;
}
export function buildStalkerIdentityRequestContext({
macAddress,
params,
token,
serialNumber,
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
const requestParams = buildRequestParams(params, effectiveSerialNumber);
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
const headers: Record<string, string> = {
Cookie: cookieString,
// Use MAG250 User-Agent matching stalker-to-m3u implementation
'User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
'X-User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
Accept: '*/*',
Connection: 'keep-alive',
'Accept-Language': 'en-US,en;q=0.9',
};
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
requestParams,
headers,
cookieString,
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
};
}
function buildRequestParams(
params: Record<string, string | number>,
effectiveSerialNumber: string | undefined
): Record<string, string | number> {
const requestParams = { ...params };
const isProfileRequest =
requestParams.type === 'stb' &&
requestParams.action === 'get_profile';
if (!isProfileRequest) {
delete requestParams.sn;
return requestParams;
}
if (effectiveSerialNumber) {
requestParams.sn = effectiveSerialNumber;
} else {
delete requestParams.sn;
}
if (typeof requestParams.metrics === 'string') {
try {
const parsedMetrics = JSON.parse(requestParams.metrics);
const nextMetrics = { ...(parsedMetrics ?? {}) };
if (effectiveSerialNumber) {
nextMetrics.sn = effectiveSerialNumber;
} else {
delete nextMetrics.sn;
}
requestParams.metrics = JSON.stringify(nextMetrics);
} catch {
// Keep original metrics payload when malformed.
}
}
return requestParams;
}
function buildCookieString(
macAddress: string,
effectiveSerialNumber: string | undefined
): string {
const cookieParts = [
`mac=${macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (effectiveSerialNumber) {
cookieParts.push(
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
);
}
return cookieParts.join('; ');
}
@@ -8,11 +8,12 @@ import { ipcMain } from 'electron';
import {
PortalDebugEvent,
STALKER_REQUEST,
buildStalkerIdentityRequestContext,
buildStalkerRequestUrl,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
import { buildStalkerIdentityRequestContext } from './stalker-identity';
import { assertRemoteUrlAllowed } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
@@ -58,42 +59,11 @@ ipcMain.handle(
serialNumber,
});
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
// to preserve forward slashes, matching stalker-to-m3u implementation
// SSRF/LFI guard: block non-http(s)/credentialed portal URLs.
// Private/LAN targets remain allowed (users run local Stalker servers).
await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true });
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
// Encode cmd but preserve forward slashes so the path format
// (e.g. /media/12345.mpg) the server expects still survives.
// Encoding the remaining characters prevents a malicious portal
// from injecting extra query parameters (&, =, #) into the URL.
queryParts.push(
`${key}=${encodeURIComponent(String(value)).replace(
/%2F/gi,
'/'
)}`
);
} else {
// Use encodeURIComponent for other params
queryParts.push(
`${key}=${encodeURIComponent(String(value))}`
);
}
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
// Build final URL with manually constructed query string
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
const fullUrl = buildStalkerRequestUrl(url, requestParams);
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
@@ -13,13 +13,10 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
const PROBE_TIMEOUT_MS = 10000;
export interface StreamProbePayload {
@@ -64,7 +61,7 @@ export async function runStreamProbe(
// The playlist's own User-Agent wins when it has one: the default
// below is a guess that merely gets past most WAFs, while that
// one is what the server was configured to expect.
'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT,
'User-Agent': payload.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT,
...(payload.referer?.trim()
? { Referer: payload.referer.trim() }
: {}),
@@ -95,8 +92,7 @@ export async function runStreamProbe(
);
// A ranged GET opens a stream we never read — release it immediately.
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
{ destroy?: () => void } | undefined;
responseBody?.destroy?.();
return {
status: response.status,
@@ -8,6 +8,7 @@ import { ipcMain } from 'electron';
import {
PortalDebugEvent,
XTREAM_CANCEL_SESSION,
XTREAM_CLIENT_USER_AGENT,
XTREAM_MAIN_PERFORMANCE_PHASE,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
@@ -20,11 +21,6 @@ import {
} from './xtream-performance';
import { cancelXtreamSessionRequests } from './xtream-session-cancellation';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
export default class XtreamEvents {
static bootstrapXtreamEvents(): Electron.IpcMain {
return ipcMain;
@@ -339,3 +339,220 @@ describe('request header overrides', () => {
});
});
});
/**
* Portal credentials (Cookie/Authorization) in the scoped override — the
* mechanism that lets built-in players play auth-gated portal streams. The
* security contract pinned here: credentials apply ONLY to the exact stream
* origin, never ride on the broader UA/Referer scope, never enter the
* unscoped (playlist-level) layer, and are dropped when the scoped override
* is cleared or replaced.
*/
describe('request header override credentials', () => {
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const SEGMENT_URL = 'http://portal.example:8080/live/segment-1.ts';
const CREDENTIALS = {
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
};
beforeEach(() => {
jest.resetModules();
mockOnBeforeSendHeaders.mockClear();
});
it('attaches cookie and authorization to requests on the stream origin', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
expect(headers['User-Agent']).toBe('MAG250');
expect(headers['Referer']).toBe('http://portal.example');
});
it('does not attach credentials to the referer origin', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
// The UA/Referer scope includes the referer origin (port 80), but
// the credentials belong to the stream origin (:8080) only.
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'http://portal.example/some/page'
);
expect(headers['User-Agent']).toBe('MAG250');
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('never attaches credentials to a third-party host', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'http://cdn.other-host.example/seg.ts',
{ Accept: '*/*' }
);
expect(headers).toEqual({ Accept: '*/*' });
});
it('ignores credentials passed without a scope URL', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
undefined,
undefined,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['User-Agent']).toBe('PlaylistAgent/1.0');
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('drops credentials when the next stream replaces the scoped override', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
configureRequestHeaderOverride(
'OtherAgent/1.0',
'http://other.example',
'http://other.example/stream.m3u8'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('clears the credentialed override when playback ends', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
// The renderer's playback-end clear: empty values with a scope URL.
configureRequestHeaderOverride(undefined, undefined, STREAM_URL);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL, {
Accept: '*/*',
});
expect(headers).toEqual({ Accept: '*/*' });
});
it('keeps a credentials-only override active without UA or referer', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
undefined,
undefined,
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
});
it('replaces existing credential headers case-insensitively', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL, {
authorization: 'Bearer STALE',
cookie: 'stale=1',
});
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['cookie']).toBeUndefined();
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
expect(headers['authorization']).toBeUndefined();
});
it('rejects credential values containing control characters', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride('MAG250', undefined, STREAM_URL, {
authorization: 'Bearer TOKEN\r\nX-Injected: 1',
cookie: 'mac=00\r\nX-Injected: 1',
});
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
expect(headers['X-Injected']).toBeUndefined();
});
});
@@ -1,6 +1,19 @@
import { session } from 'electron';
export type StreamCredentialHeaders = {
authorization?: string | null;
cookie?: string | null;
};
type HeaderOverride = {
authorization?: string;
cookie?: string;
/**
* Origin the credentials belong to — always the stream URL's own origin.
* Cookie/Authorization are attached only on an exact match, never on the
* broader `scopeOrigins` set that User-Agent/Referer/Origin use.
*/
credentialOrigin?: string;
origin?: string;
referer?: string;
scopeOrigins?: Set<string>;
@@ -29,8 +42,19 @@ let activeScopedHeaderOverride: HeaderOverride | null = null;
let listenerRegistered = false;
function normalizeHeaderValue(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
// A control character in a header value is never legitimate and could
// otherwise smuggle extra headers into the raw request.
// eslint-disable-next-line no-control-regex
if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) {
return undefined;
}
return trimmed;
}
function getOrigin(value?: string | null): string | undefined {
@@ -116,6 +140,8 @@ function handleBeforeSendHeaders(
return;
}
const requestOrigin = getOrigin(details.url);
for (const override of overrides) {
if (override.userAgent) {
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
@@ -128,6 +154,25 @@ function handleBeforeSendHeaders(
if (override.origin) {
setRequestHeader(requestHeaders, 'Origin', override.origin);
}
// Portal credentials are attached only to requests going to the
// stream's own origin — never to a referer-origin sibling and never
// to third-party hosts a manifest may point at.
const credentialsApply =
Boolean(override.credentialOrigin) &&
requestOrigin === override.credentialOrigin;
if (credentialsApply && override.cookie) {
setRequestHeader(requestHeaders, 'Cookie', override.cookie);
}
if (credentialsApply && override.authorization) {
setRequestHeader(
requestHeaders,
'Authorization',
override.authorization
);
}
}
callback({ requestHeaders });
@@ -148,13 +193,33 @@ function ensureHeaderOverrideListener(): void {
export function configureRequestHeaderOverride(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
scopeUrl?: string | null,
credentials?: StreamCredentialHeaders | null
): void {
const normalizedUserAgent = normalizeHeaderValue(userAgent);
const normalizedReferer = normalizeHeaderValue(referer);
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
const scopeOrigin = getOrigin(scopeUrl);
// Credentials are portal secrets: they require a scoped override whose
// stream URL yields a concrete origin to pin them to. Anything else is
// dropped rather than applied broadly (fail closed). They live only in
// this in-memory override — never in the session cookie jar and never on
// disk — so they cannot outlive the app process.
const normalizedCookie =
isScopedOverride && scopeOrigin
? normalizeHeaderValue(credentials?.cookie)
: undefined;
const normalizedAuthorization =
isScopedOverride && scopeOrigin
? normalizeHeaderValue(credentials?.authorization)
: undefined;
if (!normalizedUserAgent && !normalizedReferer) {
if (
!normalizedUserAgent &&
!normalizedReferer &&
!normalizedCookie &&
!normalizedAuthorization
) {
if (isScopedOverride) {
clearScopedRequestHeaderOverride();
} else {
@@ -164,7 +229,6 @@ export function configureRequestHeaderOverride(
}
const refererOrigin = getOrigin(normalizedReferer);
const scopeOrigin = getOrigin(scopeUrl);
const override: HeaderOverride = {
origin: refererOrigin,
referer: normalizedReferer,
@@ -177,6 +241,11 @@ export function configureRequestHeaderOverride(
Boolean(origin)
)
);
if (normalizedCookie || normalizedAuthorization) {
override.credentialOrigin = scopeOrigin;
override.cookie = normalizedCookie;
override.authorization = normalizedAuthorization;
}
activeScopedHeaderOverride = override;
} else {
activeHeaderOverride = override;
@@ -52,4 +52,58 @@ describe('stalker playback context', () => {
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
it('keeps the portal profile for a same-host stream on another port', () => {
// Must match the renderer's classification: a same-host stream on a
// different port stays portal-owned, or isStalkerDirectStreamProfile
// would discard the renderer's credentialed headers for it.
const streamUrl = 'http://same-host.example.test:8080/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'http://same-host.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['Cookie']).toContain(`mac=${macAddress}`);
expect(headers['Authorization']).toBe('Bearer token-1');
expect(headers['User-Agent']).not.toBe('KSPlayer');
});
it('uses the credential-free direct profile for foreign hosts', () => {
const streamUrl = 'http://cdn.foreign.example.test/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'http://portal.foreign-case.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['User-Agent']).toBe('KSPlayer');
expect(headers).not.toHaveProperty('Cookie');
expect(headers).not.toHaveProperty('Authorization');
});
it('uses the credential-free profile on an https→http downgrade', () => {
const streamUrl = 'http://downgrade.example.test/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'https://downgrade.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['User-Agent']).toBe('KSPlayer');
expect(headers).not.toHaveProperty('Cookie');
expect(headers).not.toHaveProperty('Authorization');
});
});
@@ -1,10 +1,10 @@
import {
STALKER_MAG_USER_AGENT,
buildStalkerSerialCfduid,
isStalkerStreamCredentialSafe,
normalizeStalkerSerialNumber,
} from '@iptvnator/shared/interfaces';
const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
@@ -34,14 +34,20 @@ function normalizeStreamUrl(streamUrl: string): string {
}
}
function getOrigin(streamUrl: string): string {
function unwrapStreamUrl(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
return spaceIndex > 0
? normalized.slice(spaceIndex + 1).trim()
: normalized;
}
function getOrigin(streamUrl: string): string {
const unwrapped = unwrapStreamUrl(streamUrl);
if (!unwrapped) return '';
try {
return new URL(maybeWrapped).origin;
return new URL(unwrapped).origin;
} catch {
return '';
}
@@ -91,10 +97,17 @@ export function rememberStalkerPlaybackContext(input: {
portalOrigin = undefined;
}
// Classified by the shared predicate so this fallback context can never
// disagree with the renderer's header builder: same host (port change or
// scheme upgrade included) keeps the portal profile, a foreign host or
// an https→http downgrade gets the credential-free direct profile.
const crossOriginStream =
Boolean(streamOrigin) &&
Boolean(portalOrigin) &&
streamOrigin !== portalOrigin;
!isStalkerStreamCredentialSafe(
input.portalUrl,
unwrapStreamUrl(input.streamUrl)
);
const headers: Record<string, string> = crossOriginStream
? {