refactor(downloads): adapt Xtream episode requests

This commit is contained in:
4gray committed 2026-08-02 11:34:19 +02:00
1 parent aaf4568d89
commit 73ea21b7c7
6 files changed
+344 -41

No files matched your search

@@ -205,8 +205,7 @@
[playlistId]="currentPlaylistId()"
[seriesTitle]="info.name"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[downloadMetadataContext]="downloadMetadataContext()"
[downloadAdapter]="episodeDownloadAdapter()"
[downloadsEnabled]="!providerOnly()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
@@ -17,6 +17,7 @@ import {
PORTAL_PLAYER,
SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
@@ -37,8 +38,7 @@ class StubSeasonContainerComponent {
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly xtreamDownloadContext = input<unknown>(null);
readonly downloadMetadataContext = input<unknown>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
@@ -356,22 +356,52 @@ describe('SerialDetailsComponent', () => {
],
});
expect(seasonContainer?.downloadsEnabled()).toBe(true);
expect(seasonContainer?.downloadMetadataContext()).toEqual(
expect.objectContaining({
const adapter = seasonContainer?.downloadAdapter();
const candidate = adapter?.createCandidate(
(
seasonContainer?.seasons() as Record<
string,
Array<Record<string, unknown>>
>
)['1'][0] as never,
'1'
);
expect(candidate?.identity).toEqual({
playlistId: 'xtream-1',
contentType: 'episode',
xtreamId: 1001,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
await expect(candidate?.prepare()).resolves.toEqual({
playlistId: 'xtream-1',
xtreamId: 1001,
contentType: 'episode',
title: 'Series One - S01E01 - Episode 1',
url: 'http://xtream.example/series/user/pass/1001.mp4',
posterUrl: undefined,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
metadataSnapshot: {
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Series One',
plot: 'Series plot',
genres: ['Drama'],
providerCategoryId: '3',
tmdbId: 901,
cast: expect.arrayContaining([
expect.objectContaining({
name: 'Sienna Wave',
role: 'Mara',
}),
]),
})
);
providerCategoryId: '3',
cast: [{ name: 'Sienna Wave', role: 'Mara' }],
episode: {
seasonNumber: 1,
episodeNumber: 1,
title: 'Episode 1',
},
enrichedAt: expect.any(String),
},
});
});
it('keeps every provider episode but disables download presentation in provider-only mode', async () => {
@@ -21,9 +21,8 @@ import {
PortalDetailShellComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
type SeasonContainerDownloadMetadataContext,
SeasonContainerXtreamDownloadContext,
} from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
buildUpNextRailItems,
@@ -53,6 +52,7 @@ import {
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
@Component({
selector: 'app-serial-details',
@@ -102,19 +102,28 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
readonly currentPlaylistId = signal('');
readonly xtreamDownloadContext =
signal<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
computed<SeasonContainerDownloadMetadataContext | null>(() => {
const info = this.selectedItem()?.info;
return info
? createXtreamSeriesDownloadMetadataContext(
info,
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en'
)
: null;
readonly episodeDownloadAdapter =
computed<SeasonEpisodeDownloadAdapter | null>(() => {
const playlist = this.xtreamStore.currentPlaylist();
const item = this.selectedItem();
if (!playlist || !item) {
return null;
}
return createXtreamSeriesDownloadAdapter({
playlistId: playlist.id,
seriesId: Number(item.series_id),
title: item.info.name,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
metadataContext: createXtreamSeriesDownloadMetadataContext(
item.info,
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en'
),
});
});
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
@@ -254,15 +263,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
this.currentPlaylistId.set(playlist?.id ?? '');
this.xtreamDownloadContext.set(
playlist
? {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
}
: null
);
});
// Initializes on first render and RE-initializes when the route
@@ -1,4 +1,4 @@
import type { SeasonContainerDownloadMetadataContext } from '@iptvnator/ui/components';
import type { DownloadMovieSnapshotInput } from '@iptvnator/portal/shared/util';
import type {
DownloadMetadataPerson,
TmdbEnrichedCastMember,
@@ -41,7 +41,7 @@ function people(
export function createXtreamSeriesDownloadMetadataContext(
info: XtreamSerieInfo,
language: string
): SeasonContainerDownloadMetadataContext {
): DownloadMovieSnapshotInput {
const runtimeMinutes = finiteNumber(info.episode_run_time);
return {
language,
@@ -0,0 +1,168 @@
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
const POSTER_URL = 'https://images.test/stills/the-one.jpg';
const OPTIONS = {
playlistId: 'playlist-1',
seriesId: 900,
title: 'Signal House',
serverUrl: 'http://host/',
username: 'user',
password: 'pass',
metadataContext: {
language: 'en',
title: 'Signal House',
plot: 'Series plot',
},
} as const;
function episode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '55',
episode_num: 3,
title: 'The One',
container_extension: 'mkv',
info: {
plot: 'Episode plot',
movie_image: POSTER_URL,
},
custom_sid: '',
added: '',
season: 2,
direct_source: '',
...overrides,
};
}
describe('createXtreamSeriesDownloadAdapter', () => {
it('creates a canonical candidate and prepares the complete Xtream request', async () => {
const adapter = createXtreamSeriesDownloadAdapter(OPTIONS);
const candidate = adapter.createCandidate(episode(), '1');
expect(candidate?.identity).toEqual({
playlistId: 'playlist-1',
contentType: 'episode',
xtreamId: 55,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
});
if (!candidate) {
throw new Error('expected a valid Xtream episode candidate');
}
const request = await candidate.prepare();
const { metadataSnapshot, ...requestWithoutSnapshot } = request;
expect(requestWithoutSnapshot).toEqual({
playlistId: 'playlist-1',
xtreamId: 55,
contentType: 'episode',
title: 'Signal House - S02E03 - The One',
url: 'http://host/series/user/pass/55.mkv',
posterUrl: POSTER_URL,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
});
expect(metadataSnapshot).toEqual({
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Signal House',
plot: 'Series plot',
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The One',
plot: 'Episode plot',
stillUrl: POSTER_URL,
},
enrichedAt: expect.any(String),
});
});
it('preserves the existing season and episode fallback rules', async () => {
const adapter = createXtreamSeriesDownloadAdapter(OPTIONS);
const fromSeasonKey = adapter.createCandidate(
episode({ season: 0, episode_num: 0 }),
'4'
);
const defaults = adapter.createCandidate(
episode({ season: 0, episode_num: Number.NaN }),
undefined
);
expect(fromSeasonKey?.identity).toEqual(
expect.objectContaining({ seasonNumber: 4, episodeNumber: 1 })
);
expect(defaults?.identity).toEqual(
expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 })
);
await expect(fromSeasonKey?.prepare()).resolves.toEqual(
expect.objectContaining({
title: 'Signal House - S04E01 - The One',
})
);
});
it('preserves raw Xtream path concatenation and the mp4 extension fallback', async () => {
const adapter = createXtreamSeriesDownloadAdapter({
...OPTIONS,
serverUrl: 'http://host/base//',
username: 'u@ser',
password: 'p/a',
});
const candidate = adapter.createCandidate(
episode({ container_extension: '' }),
undefined
);
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({
url: 'http://host/base//series/u@ser/p/a/55.mp4',
})
);
});
it.each([
['missing server URL', { serverUrl: undefined }, {}, undefined],
['empty server URL', { serverUrl: ' ' }, {}, undefined],
['missing username', { username: undefined }, {}, undefined],
['empty username', { username: '' }, {}, undefined],
['missing password', { password: undefined }, {}, undefined],
['empty password', { password: ' ' }, {}, undefined],
['missing playlist id', { playlistId: undefined }, {}, undefined],
['empty playlist id', { playlistId: ' ' }, {}, undefined],
['unsafe episode id', {}, { id: 'not-an-id' }, undefined],
['non-positive episode id', {}, { id: '0' }, undefined],
[
'unsafe series id',
{ seriesId: Number.MAX_SAFE_INTEGER + 1 },
{},
undefined,
],
['non-positive series id', { seriesId: 0 }, {}, undefined],
['unsafe season', {}, { season: 1.5 }, undefined],
['unsafe episode number', {}, { episode_num: -1 }, undefined],
['unsafe fallback season', {}, { season: 0 }, '-2'],
] as const)(
'returns null for %s',
(_label, optionOverrides, episodeOverrides, fallbackSeasonKey) => {
const adapter = createXtreamSeriesDownloadAdapter({
...OPTIONS,
...optionOverrides,
});
expect(
adapter.createCandidate(
episode(episodeOverrides),
fallbackSeasonKey
)
).toBeNull();
}
);
});
@@ -0,0 +1,106 @@
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createSeriesEpisodeDownloadSnapshot,
type DownloadMovieSnapshotInput,
} from '@iptvnator/portal/shared/util';
import type {
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
export interface XtreamSeriesDownloadAdapterOptions {
readonly playlistId?: string;
readonly seriesId: number;
readonly title: string;
readonly serverUrl?: string;
readonly username?: string;
readonly password?: string;
readonly metadataContext: DownloadMovieSnapshotInput;
}
function isPresent(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function positiveSafeInteger(value: unknown): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function episodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
return Array.isArray(episode.info) ? undefined : episode.info;
}
export function createXtreamSeriesDownloadAdapter(
options: XtreamSeriesDownloadAdapterOptions
): SeasonEpisodeDownloadAdapter {
return {
createCandidate(episode, fallbackSeasonKey) {
const playlistId = options.playlistId;
const serverUrl = options.serverUrl;
const username = options.username;
const password = options.password;
const xtreamId = positiveSafeInteger(episode.id);
const seriesXtreamId = positiveSafeInteger(options.seriesId);
const seasonNumber = positiveSafeInteger(
episode.season || Number(fallbackSeasonKey) || 1
);
const episodeNumber = positiveSafeInteger(episode.episode_num || 1);
if (
!isPresent(playlistId) ||
!isPresent(serverUrl) ||
!isPresent(username) ||
!isPresent(password) ||
!options.metadataContext ||
xtreamId === null ||
seriesXtreamId === null ||
seasonNumber === null ||
episodeNumber === null
) {
return null;
}
const identity = {
playlistId,
contentType: 'episode' as const,
xtreamId,
seriesXtreamId,
seasonNumber,
episodeNumber,
};
return {
identity,
prepare: async () => {
const info = episodeInfo(episode);
const extension = episode.container_extension || 'mp4';
const normalizedServerUrl = serverUrl.replace(/\/$/, '');
const episodeCode = `S${String(seasonNumber).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')}`;
return {
...identity,
title: `${options.title || 'Series'} - ${episodeCode} - ${episode.title}`,
url: `${normalizedServerUrl}/series/${username}/${password}/${episode.id}.${extension}`,
posterUrl: info?.movie_image,
metadataSnapshot: createSeriesEpisodeDownloadSnapshot({
...options.metadataContext,
episode: {
seasonNumber,
episodeNumber,
title: episode.title,
plot: info?.plot,
stillUrl: info?.movie_image,
},
}),
};
},
};
},
};
}