fix(downloads): resume interrupted Xtream VOD transfers (#1329)

* fix(downloads): resume interrupted Xtream VOD transfers

* fix(downloads): validate partials before resuming

* fix(downloads): propagate headers to episode transfers
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 22:01:10 +02:00
1 parent d44045de31
commit aba89d64cf
29 files changed
+894 -92

No files matched your search

@@ -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',
@@ -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
);
@@ -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,
},
@@ -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({
@@ -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',
@@ -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) {
@@ -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(
@@ -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',
});
});
@@ -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',
@@ -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';