mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user