diff --git a/.changes/downloads-vod-reliability.md b/.changes/downloads-vod-reliability.md index a69b636e5..899f97e5a 100644 --- a/.changes/downloads-vod-reliability.md +++ b/.changes/downloads-vod-reliability.md @@ -4,4 +4,4 @@ area: downloads issues: [897, 1289] --- -Xtream movie downloads now keep their provider-compatible identity for 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. +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 8db46ba12..32868bb61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -911,9 +911,10 @@ engine` (restart required) or **Download Manager**: -- Xtream VOD downloads default to the same provider-compatible - `XTREAM_CLIENT_USER_AGENT` used by API requests and stream probes, while an - explicit playlist User-Agent still wins. Retry, resume, and missing-file +- 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 diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index c2abee799..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. Xtream VOD downloads use the playlist's configured User-Agent when present and otherwise share 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. + 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 diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 268e662b6..71c1ce632 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -98,9 +98,11 @@ and private-network checks. ## User-Agent -Electron's `XTREAM_REQUEST` and stream-probe handlers plus Xtream VOD download -requests share the exported `XTREAM_CLIENT_USER_AGENT` fallback. A playlist's -explicit User-Agent still wins for its stream probe and download. Legacy +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 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/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';