mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -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
@@ -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
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user