diff --git a/.changes/downloads-vod-reliability.md b/.changes/downloads-vod-reliability.md new file mode 100644 index 000000000..899f97e5a --- /dev/null +++ b/.changes/downloads-vod-reliability.md @@ -0,0 +1,7 @@ +--- +type: fix +area: downloads +issues: [897, 1289] +--- + +Xtream movie and episode downloads now keep their provider-compatible identity from the first request through legacy retries after source removal. Recoverable connection drops retain validated partials and show a credential-safe code; Retry resumes only with ETag or Last-Modified, otherwise it safely restarts. diff --git a/CLAUDE.md b/CLAUDE.md index 120fb100e..32868bb61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -911,6 +911,19 @@ engine` (restart required) or **Download Manager**: +- Fresh Xtream movie and series-episode downloads propagate the playlist's + User-Agent, Referer, and Origin, defaulting User-Agent to the same + provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and + stream probes. Retry, resume, and missing-file + recovery also add the fallback to legacy Xtream rows that have no stored + User-Agent. Because download rows survive source deletion, a headerless + legacy row whose playlist is already absent receives the same IPTV-player + fallback; a known Stalker row remains unchanged. Allowlisted connection + resets after bytes reach disk retain the partial and show a credential-safe + `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong + ETag or Last-Modified validator. Retry then continues with Range/If-Range; + without a validator it starts from byte zero and overwrites the unverified + partial instead of risking mixed-representation corruption. - The desktop-only manager shares one global download store across the global, Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series cards use the global Small/Medium/Large cover-grid tokens; missing completed diff --git a/apps/electron-backend-e2e/src/download-reliability.e2e.ts b/apps/electron-backend-e2e/src/download-reliability.e2e.ts new file mode 100644 index 000000000..13b69fe11 --- /dev/null +++ b/apps/electron-backend-e2e/src/download-reliability.e2e.ts @@ -0,0 +1,117 @@ +import { mkdirSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + createInterruptedRangeServer, + INTERRUPTED_RANGE_SERVER_ETAG, + startDownload, +} from './downloads.e2e-support'; + +async function openDownloadsPage(page: Page): Promise { + await page.getByRole('button', { name: 'Open downloads' }).click(); + await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); +} + +async function getPlaylistId(page: Page, title: string): Promise { + const playlists = await page.evaluate( + async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? [] + ); + const playlist = playlists.find((entry) => entry.title === title); + expect(playlist, `playlist "${title}" should exist`).toBeDefined(); + return playlist?._id ?? ''; +} + +test.describe('Electron download reliability', () => { + test('@downloads @electron retains a network-interrupted partial and retries it with HTTP Range', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const rangeServer = await createInterruptedRangeServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Reset Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-reset-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + + const playlistId = await getPlaylistId( + app.mainWindow, + 'Reset Portal' + ); + const downloadId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9801, + contentType: 'vod', + title: 'E2E Reset Movie', + url: rangeServer.url, + downloadFolder: downloadsDir, + }); + const item = app.mainWindow.getByTestId( + `download-queue-item-${downloadId}` + ); + await expect(item.locator('.download-queue__status')).toContainText( + 'Failed', + { timeout: 30000 } + ); + await expect(item.locator('.download-queue__error')).toContainText( + 'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET)' + ); + + const partialPath = join(downloadsDir, 'E2E Reset Movie.mp4.part'); + expect(statSync(partialPath).size).toBe( + rangeServer.interruptedBytes + ); + + await item + .getByRole('button', { name: 'Retry E2E Reset Movie' }) + .click(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${downloadId}` + ) + ).toBeVisible({ timeout: 30000 }); + + const resumeRequest = rangeServer.requests.find( + (entry) => entry.range + ); + expect(resumeRequest?.range).toBe( + `bytes=${rangeServer.interruptedBytes}-` + ); + expect(resumeRequest?.ifRange).toBe(INTERRUPTED_RANGE_SERVER_ETAG); + const finalFile = readFileSync( + join(downloadsDir, 'E2E Reset Movie.mp4') + ); + expect(finalFile.equals(rangeServer.payload)).toBe(true); + } finally { + await closeElectronApp(app); + await rangeServer.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/downloads.e2e-support.ts b/apps/electron-backend-e2e/src/downloads.e2e-support.ts index fc7997f78..2ec21c4ab 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e-support.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e-support.ts @@ -31,6 +31,10 @@ interface ThrottledRangeServer { url: string; } +interface InterruptedRangeServer extends ThrottledRangeServer { + interruptedBytes: number; +} + interface TruncatedDownloadServer { close: () => Promise; payload: Buffer; @@ -39,6 +43,7 @@ interface TruncatedDownloadServer { } export const RANGE_SERVER_ETAG = '"e2e-range-etag"'; +export const INTERRUPTED_RANGE_SERVER_ETAG = '"e2e-reset-etag"'; const downloadPlayCaptureKey = '__iptvnatorE2eDownloadPlayPaths'; export function getStalkerSeriesDownloadTarget( @@ -172,6 +177,63 @@ export async function createThrottledRangeServer( }; } +/** + * Resets the first full response after writing a valid prefix, then serves the + * remainder to a Range retry. This matches a provider/proxy connection drop + * without manufacturing a clean EOF. + */ +export async function createInterruptedRangeServer(): Promise { + const payload = Buffer.alloc(64 * 1024, 9); + const interruptedBytes = 16 * 1024; + const requests: RangeServerRequest[] = []; + + const server = createServer((req, res) => { + const range = req.headers.range; + const ifRange = req.headers['if-range']; + requests.push({ + ifRange: typeof ifRange === 'string' ? ifRange : undefined, + range: typeof range === 'string' ? range : undefined, + }); + + const offset = range + ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) + : 0; + if (range && Number.isFinite(offset)) { + res.writeHead(206, { + 'Content-Length': payload.length - offset, + 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, + 'Content-Type': 'video/mp4', + ETag: INTERRUPTED_RANGE_SERVER_ETAG, + }); + res.end(payload.subarray(offset)); + return; + } + + res.writeHead(200, { + 'Content-Length': payload.length, + 'Content-Type': 'video/mp4', + ETag: INTERRUPTED_RANGE_SERVER_ETAG, + }); + res.write(payload.subarray(0, interruptedBytes), () => { + setTimeout(() => res.socket?.destroy(), 20); + }); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + interruptedBytes, + payload, + requests, + url: `http://127.0.0.1:${port}/media/e2e-reset-movie.mp4`, + }; +} + /** * Ends a chunked response cleanly while Content-Range advertises a larger * representation. The runtime therefore retains the valid .part for a Range diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts index 955fe1ac4..152d1ebff 100644 --- a/apps/electron-backend/src/app/events/database/download-finalize.ts +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -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 { - 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; diff --git a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts index 1514e50b2..1a1d0b91a 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts @@ -3,6 +3,7 @@ interface SetupOptions { cleanupError?: Error; claim?: { changes: number }; fileReappeared?: boolean; + playlistType?: 'xtream' | 'stalker'; row?: Record | 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 }); diff --git a/apps/electron-backend/src/app/events/database/download-redownload.ts b/apps/electron-backend/src/app/events/database/download-redownload.ts index b34f49f2c..fd9aa8d44 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts b/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts new file mode 100644 index 000000000..050c6df61 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-headers.spec.ts @@ -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(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-request-headers.ts b/apps/electron-backend/src/app/events/database/download-request-headers.ts new file mode 100644 index 000000000..0f19754f6 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-headers.ts @@ -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 | 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; + const headers = STORED_HEADER_ALLOWLIST.reduce>( + (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 | 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, + }; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 4f6cd4650..8e4af8d69 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -384,6 +384,66 @@ describe('download request metadata snapshots', () => { }); 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(); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 5793ea0a4..5acc8d25a 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -6,6 +6,7 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; +import { resolveStoredDownloadHeaders } from './download-request-headers'; import { assertDownloadMetadataArtworkDiffersFromStream, assertDownloadMetadataMatchesContentType, @@ -80,40 +81,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 | 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; - const headers = STORED_HEADER_ALLOWLIST.reduce>( - (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 @@ -320,6 +287,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, @@ -345,7 +313,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, @@ -388,6 +356,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. @@ -416,7 +385,7 @@ export async function resumeDownloadRequest( directory, fileName, filePath: item.filePath, - headers: parseStoredHeaders(item.requestHeaders), + headers, id: item.id, resumeValidator: item.resumeValidator, totalBytes: item.totalBytes, diff --git a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts index 46941058f..bf8cea9f5 100644 --- a/apps/electron-backend/src/app/events/database/download-reserve.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-reserve.spec.ts @@ -62,6 +62,7 @@ describe('destination collision handling', () => { runtime.enqueueDownload({ ...createTask(), filePath: '/downloads/movie.mp4', + resumeValidator: '"etag-1"', totalBytes: 100, }); await waitForStatus(set, 'completed'); diff --git a/apps/electron-backend/src/app/events/database/download-resume.spec.ts b/apps/electron-backend/src/app/events/database/download-resume.spec.ts index 5688fa4cc..1e68b016f 100644 --- a/apps/electron-backend/src/app/events/database/download-resume.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-resume.spec.ts @@ -11,6 +11,7 @@ interface ResumeHarness { interface ResumeHarnessOptions { partialSize: number; + partialSizeAfterTransferError?: number; response: { data: Readable; headers: Record; @@ -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((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((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((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, diff --git a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts index 9856b8de9..ce818eb86 100644 --- a/apps/electron-backend/src/app/events/database/download-runtime.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-runtime.spec.ts @@ -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(() => ({ diff --git a/apps/electron-backend/src/app/events/database/download-transfer.ts b/apps/electron-backend/src/app/events/database/download-transfer.ts index cb8df4a81..638d89a53 100644 --- a/apps/electron-backend/src/app/events/database/download-transfer.ts +++ b/apps/electron-backend/src/app/events/database/download-transfer.ts @@ -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 { - 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 diff --git a/apps/electron-backend/src/app/events/stream-probe.ts b/apps/electron-backend/src/app/events/stream-probe.ts index 760b6f20f..1870c773f 100644 --- a/apps/electron-backend/src/app/events/stream-probe.ts +++ b/apps/electron-backend/src/app/events/stream-probe.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 6b43a3975..30f7349f4 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -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; diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 42825f6fc..1ccd77cbd 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -12,7 +12,7 @@ variants, contextual buttons, and theme-aware styling. - **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts`, and the renderer update broadcast in `download-broadcast.ts`. - **Range-aware transfer (`download-transfer.ts`)** - The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed) and sends `Range: bytes=-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. + The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; only a partial carrying that validator may send `Range: bytes=-` plus `If-Range` and append bytes. A retained partial without a validator restarts from byte zero and overwrites its `.part`, so a changed remote representation can never be joined to an unverified prefix. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. - **Destination collision policy** Existing destination files are never overwritten, inspected, or deleted. Before starting a new transfer, the backend atomically reserves a free @@ -23,11 +23,14 @@ variants, contextual buttons, and theme-aware styling. retained `.part` is renamed aside and finalized to the next free numbered destination (`Movie (1).mp4`) instead of resolving the collision by size or `unlink()`. Completion creates the final `filePath` from the `.part` without - overwriting an existing file; cancel and ordinary transfer failures remove - the `.part`, while finalization failures and completed-partial failures - deliberately retain it (the row keeps `filePath` so a later retry can finish - without re-downloading); pause and restart recovery keep it for a later - resume. Re-downloading such a failed row from a detail page + overwriting an existing file; cancel and non-recoverable transfer failures + remove the `.part`, while finalization failures, completed-partial failures, + and allowlisted network interruptions after bytes reached disk with a stored + representation validator deliberately retain it (the row keeps `filePath` + so a later retry can finish without re-downloading); pause and restart + recovery keep partials, but a later retry starts over when no validator was + available. + Re-downloading such a failed row from a detail page (`DOWNLOADS_START`) deletes the retained `.part` before the row is reset. - **Derived file readiness and recovery** `DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on @@ -184,6 +187,7 @@ variants, contextual buttons, and theme-aware styling. - A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. - Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download. - A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped. +- An allowlisted mid-response network failure such as `ECONNRESET` is recoverable only when the response advertised a larger total and the `.part` contains valid incomplete bytes. This includes a validated `206` resume that drops before adding another byte. The failed row retains that partial and exposes a stable `DOWNLOAD_NETWORK_INTERRUPTED ()` message without a URL; Retry continues through the same Range/If-Range validation. Pre-response failures, unknown stream errors, filesystem errors, empty fresh failures, and responses without a trustworthy total keep the generic failure path. - Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder. - Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file. - Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index d0eb29d88..71c1ce632 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -98,9 +98,17 @@ and private-network checks. ## User-Agent -Electron's `XTREAM_REQUEST` and `XTREAM_PROBE_URL` handlers -(`apps/electron-backend/src/app/events/xtream.events.ts`) send a shared -`XTREAM_CLIENT_USER_AGENT` constant on every outgoing request. Some Xtream +Electron's `XTREAM_REQUEST` and stream-probe handlers plus fresh Xtream movie +and series-episode download requests share the exported +`XTREAM_CLIENT_USER_AGENT` fallback. A playlist's explicit User-Agent, +Referer, and Origin are propagated to either download kind; the explicit +User-Agent still wins over the fallback. Legacy +download rows without a stored User-Agent receive the fallback when retrying, +resuming, or recovering a missing completed file. Download rows intentionally +survive individual source deletion; when the playlist row is already gone and +its type can no longer be recovered, a headerless legacy download receives the +same IPTV-player fallback, while a still-identifiable Stalker row remains +unchanged. Some Xtream panels sit behind a WAF (e.g. Cloudflare) configured to challenge generic/incomplete browser-looking User-Agents while allowlisting known IPTV player clients; a player-style User-Agent (currently a VLC signature) avoids diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 1bf16ec9f..3b6293254 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -88,6 +88,9 @@ describe('SerialDetailsComponent', () => { serverUrl: 'http://xtream.example', username: 'user', password: 'pass', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', }); const fetchSerialDetailsWithMetadata = jest.fn(); const cancelDetailsRequest = jest.fn(); @@ -356,6 +359,14 @@ describe('SerialDetailsComponent', () => { ], }); expect(seasonContainer?.downloadsEnabled()).toBe(true); + expect(seasonContainer?.xtreamDownloadContext()).toEqual({ + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', + }); expect(seasonContainer?.downloadMetadataContext()).toEqual( expect.objectContaining({ language: 'en', diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 19b98a87a..9c45117a1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -260,6 +260,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, } : null ); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index b7fc6a8a1..720006b3b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -5,6 +5,7 @@ import { DownloadsService } from '@iptvnator/services'; import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access'; import { getXtreamVodInfo, + XTREAM_CLIENT_USER_AGENT, XtreamVodDetails, type XtreamVodInfo, } from '@iptvnator/shared/interfaces'; @@ -179,7 +180,8 @@ export class VodDetailsDownloadsService { : textList(info?.director)?.map((name) => ({ name })), }), headers: { - userAgent: playlist.userAgent, + userAgent: + playlist.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT, referer: playlist.referrer, origin: playlist.origin, }, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index fbc5bfe85..130bdf0dc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -462,6 +462,9 @@ describe('VodDetailsRouteComponent — playback actions', () => { // came from — so the download would fetch the wrong movie. expect(stubs.startDownload).toHaveBeenCalledWith( expect.objectContaining({ + headers: expect.objectContaining({ + userAgent: 'VLC/3.0.18 LibVLC/3.0.18', + }), playlistId: 'playlist-1', xtreamId: 650020, metadataSnapshot: expect.objectContaining({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 1e89f598a..9987eaccd 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -399,6 +399,11 @@ describe('VodDetailsRouteComponent fallback actions', () => { ); expect(startDownload).toHaveBeenCalledWith( expect.objectContaining({ + headers: { + origin: 'https://origin.example', + referer: 'https://referrer.example', + userAgent: 'IPTVnator', + }, title: 'Catalog movie', posterUrl: 'https://example.com/catalog-poster.jpg', url: 'http://example.com/movie/650020.mp4', diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts index afb76e303..3eaf73a67 100644 --- a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts @@ -1,8 +1,5 @@ export type XtreamPortalStatusType = - | 'active' - | 'inactive' - | 'expired' - | 'unavailable'; + 'active' | 'inactive' | 'expired' | 'unavailable'; export interface XtreamPortalStatusResponseLike { user_info?: { @@ -19,6 +16,10 @@ export interface XtreamCredentialsFromUrl { const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i; +// Some Xtream panels challenge generic Node HTTP clients while allowing +// established IPTV players. Keep API, probe, and download requests aligned. +export const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; + export function normalizeXtreamServerUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) { diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts index de013e077..36960d811 100644 --- a/libs/ui/components/src/lib/season-container/episode-download.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-download.util.ts @@ -1,5 +1,6 @@ import { DownloadMetadataSnapshot, + XTREAM_CLIENT_USER_AGENT, XtreamSerieEpisode, XtreamSerieEpisodeInfo, } from '@iptvnator/shared/interfaces'; @@ -19,6 +20,9 @@ export interface XtreamEpisodeDownloadContext { serverUrl?: string; username?: string; password?: string; + userAgent?: string; + referrer?: string; + origin?: string; } /** Provider-neutral metadata already loaded by the detail host. */ @@ -34,6 +38,11 @@ export interface XtreamEpisodeDownloadRequest { seriesXtreamId: number; seasonNumber: number; episodeNumber: number; + headers: { + userAgent: string; + referer?: string; + origin?: string; + }; metadataSnapshot?: DownloadMetadataSnapshot; } @@ -150,6 +159,11 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesXtreamId: seriesId, seasonNumber, episodeNumber, + headers: { + userAgent: context.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT, + referer: context.referrer, + origin: context.origin, + }, ...(options.metadataContext ? { metadataSnapshot: buildMetadataSnapshot( diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index c363f2852..a104c5928 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -1,4 +1,7 @@ -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + XTREAM_CLIENT_USER_AGENT, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; import { buildXtreamEpisodeDownloadRequest, getEpisodeDownloadId, @@ -76,6 +79,35 @@ describe('episode-download.util', () => { seriesXtreamId: 900, seasonNumber: 2, episodeNumber: 3, + headers: { + userAgent: XTREAM_CLIENT_USER_AGENT, + referer: undefined, + origin: undefined, + }, + }); + }); + + it('forwards playlist request headers for the first episode transfer', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ id: '55' }), + context: { + serverUrl: 'http://host', + username: 'u', + password: 'p', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', + }, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: '1', + }); + + expect(request.headers).toEqual({ + userAgent: 'Provider Player/1.0', + referer: 'https://provider.test/player', + origin: 'https://provider.test', }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index 0fc992b13..d56b4f2f3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -157,6 +157,9 @@ describe('SeasonContainerComponent', () => { serverUrl: 'http://host', username: 'u', password: 'p', + userAgent: 'Provider Player/1.0', + referrer: 'https://provider.test/player', + origin: 'https://provider.test', }); fixture.componentRef.setInput('downloadMetadataContext', { language: 'en', @@ -170,6 +173,11 @@ describe('SeasonContainerComponent', () => { expect(downloadsServiceStub.startDownload).toHaveBeenCalledWith( expect.objectContaining({ title: 'Signal House - S01E01 - Pilot', + headers: { + userAgent: 'Provider Player/1.0', + referer: 'https://provider.test/player', + origin: 'https://provider.test', + }, metadataSnapshot: expect.objectContaining({ mediaKind: 'series', title: 'Signal House', diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 483d9da2b..d4ba3ca77 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -36,6 +36,7 @@ import { getEpisodeDownloadId, isStalkerEpisode, type SeasonContainerDownloadMetadataContext, + type XtreamEpisodeDownloadContext, } from './episode-download.util'; import { EPISODE_INFO_PLAY, @@ -51,11 +52,7 @@ import { SeasonTabsComponent } from './season-tabs.component'; type EpisodeViewMode = 'grid' | 'list'; const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; -export interface SeasonContainerXtreamDownloadContext { - serverUrl?: string; - username?: string; - password?: string; -} +export type SeasonContainerXtreamDownloadContext = XtreamEpisodeDownloadContext; export type { SeasonContainerDownloadMetadataContext } from './episode-download.util';