mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor(downloads): adapt Xtream episode requests
This commit is contained in:
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()"
|
||||
|
||||
+43
-13
@@ -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,
|
||||
|
||||
+168
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user