From 73ea21b7c73b923a56f3ef5ff11fcb9eae35999a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 11:34:19 +0200 Subject: [PATCH] refactor(downloads): adapt Xtream episode requests --- .../serial-details.component.html | 3 +- .../serial-details.component.spec.ts | 56 ++++-- .../serial-details.component.ts | 48 ++--- .../serial-download-metadata.ts | 4 +- .../xtream-series-download.adapter.spec.ts | 168 ++++++++++++++++++ .../xtream-series-download.adapter.ts | 106 +++++++++++ 6 files changed, 344 insertions(+), 41 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 09d4b6898..cbe1231ba 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -205,8 +205,7 @@ [playlistId]="currentPlaylistId()" [seriesTitle]="info.name" [playbackPositions]="episodePlaybackPositions()" - [xtreamDownloadContext]="xtreamDownloadContext()" - [downloadMetadataContext]="downloadMetadataContext()" + [downloadAdapter]="episodeDownloadAdapter()" [downloadsEnabled]="!providerOnly()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" 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..0f0639669 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 @@ -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(undefined); readonly playbackPositions = input(null); - readonly xtreamDownloadContext = input(null); - readonly downloadMetadataContext = input(null); + readonly downloadAdapter = input(null); readonly downloadsEnabled = input(true); readonly openingEpisodeId = input(null); readonly activeEpisodeId = input(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> + > + )['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 () => { 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..d511aa52f 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 @@ -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(null); - readonly downloadMetadataContext = - computed(() => { - const info = this.selectedItem()?.info; - return info - ? createXtreamSeriesDownloadMetadataContext( - info, - this.translateService.currentLang || - this.translateService.defaultLang || - 'en' - ) - : null; + readonly episodeDownloadAdapter = + computed(() => { + 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(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 diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts index a532f530e..bb6309d19 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts new file mode 100644 index 000000000..63651ac54 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts @@ -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 { + 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(); + } + ); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts new file mode 100644 index 000000000..19528d3d6 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts @@ -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, + }, + }), + }; + }, + }; + }, + }; +}