diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts new file mode 100644 index 000000000..d0ffec173 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts @@ -0,0 +1,58 @@ +import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata'; + +describe('createStalkerSeriesDownloadSnapshot', () => { + it('preserves zero coordinates from the download row', () => { + const snapshot = createStalkerSeriesDownloadSnapshot({ + item: { + id: '50', + category_id: '18', + info: { + name: 'Signal House', + description: 'Parent plot', + actors: 'Sienna Wave', + director: 'Cora Bell', + }, + } as never, + episode: { + title: 'The Call', + info: { plot: 'Episode plot', movie_image: 'still.jpg' }, + } as never, + language: 'en', + seriesTitle: 'Signal House', + seasonNumber: 0, + episodeNumber: '0', + }); + + expect(snapshot).toEqual( + expect.objectContaining({ + mediaKind: 'series', + title: 'Signal House', + cast: [{ name: 'Sienna Wave' }], + creators: [{ name: 'Cora Bell' }], + episode: expect.objectContaining({ + seasonNumber: 0, + episodeNumber: 0, + title: 'The Call', + }), + }) + ); + }); + + it.each([ + [Number.NaN, 2], + [1, -1], + [1.5, 2], + ])('omits episode metadata for malformed row coordinates', (season, ep) => { + const snapshot = createStalkerSeriesDownloadSnapshot({ + item: { id: '50', info: { name: 'Signal House' } } as never, + episode: { title: 'The Call', info: [] } as never, + language: 'en', + seriesTitle: 'Signal House', + seasonNumber: season, + episodeNumber: ep, + }); + + expect(snapshot.mediaKind).toBe('series'); + expect(snapshot.episode).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts new file mode 100644 index 000000000..d1bce3bcc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts @@ -0,0 +1,102 @@ +import type { + DownloadMetadataPerson, + DownloadMetadataSnapshot, + TmdbEnrichedCastMember, + XtreamSerieEpisode, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import type { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, +} from '@iptvnator/portal/shared/util'; + +interface StalkerSeriesDownloadSnapshotInput { + item: StalkerSelectedVodItem; + episode: XtreamSerieEpisode; + language: string; + seriesTitle: string; + /** Values already selected for the persisted download row. */ + seasonNumber: unknown; + episodeNumber: unknown; +} + +function textList(value: string | undefined): string[] | undefined { + const entries = value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries?.length ? entries : undefined; +} + +function finiteNumber(value: string | number | undefined) { + if (value === undefined || String(value).trim() === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function releaseYear(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function people( + enriched: TmdbEnrichedCastMember[] | undefined, + fallback: string | undefined +): DownloadMetadataPerson[] | undefined { + return enriched?.length + ? enriched.map((person) => ({ + name: person.name, + role: person.character, + profileUrl: person.profileUrl ?? undefined, + tmdbPersonId: person.tmdbPersonId, + })) + : textList(fallback)?.map((name) => ({ name })); +} + +function rowCoordinate(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function createStalkerSeriesDownloadSnapshot( + input: StalkerSeriesDownloadSnapshotInput +): DownloadMetadataSnapshot { + const info = input.item.info; + const common = { + language: input.language, + title: input.seriesTitle.trim() || 'Series', + originalTitle: info?.o_name, + plot: info?.description, + releaseDate: info?.releasedate, + year: releaseYear(info?.releasedate), + genres: textList(info?.genre), + rating: finiteNumber(info?.rating_imdb), + status: info?.tmdb_status, + posterUrl: info?.movie_image, + backdropUrl: info?.tmdb_backdrop, + tmdbId: info?.tmdb_id, + providerCategoryId: input.item.category_id, + cast: people(info?.tmdb_cast, info?.actors), + creators: people(info?.tmdb_directors, info?.director), + }; + const seasonNumber = rowCoordinate(input.seasonNumber); + const episodeNumber = rowCoordinate(input.episodeNumber); + if (seasonNumber === undefined || episodeNumber === undefined) { + return { ...createMovieDownloadSnapshot(common), mediaKind: 'series' }; + } + + const episodeInfo = Array.isArray(input.episode.info) + ? undefined + : (input.episode.info as XtreamSerieEpisodeInfo); + return createSeriesEpisodeDownloadSnapshot({ + ...common, + episode: { + seasonNumber, + episodeNumber, + title: input.episode.title, + plot: episodeInfo?.plot, + stillUrl: episodeInfo?.movie_image, + }, + }); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 98b777590..f256c1bd3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -368,7 +368,7 @@ describe('StalkerSeriesViewComponent', () => { await fixture.whenStable(); await fixture.componentInstance.downloadEpisode({ - episode_num: -3, + episode_num: '0', title: 'The Call', info: { plot: 'Episode-specific plot', @@ -385,8 +385,8 @@ describe('StalkerSeriesViewComponent', () => { playlistType: 'stalker', seriesXtreamId: 50001, seasonNumber: 0, - episodeNumber: -3, - title: 'Signal House - S00E-3 - The Call', + episodeNumber: '0', + title: 'Signal House - S00E00 - The Call', metadataSnapshot: expect.objectContaining({ language: 'en', mediaKind: 'series', @@ -398,8 +398,8 @@ describe('StalkerSeriesViewComponent', () => { cast: [{ name: 'Sienna Wave' }], creators: [{ name: 'Cora Bell' }], episode: { - seasonNumber: 1, - episodeNumber: 1, + seasonNumber: 0, + episodeNumber: 0, title: 'The Call', plot: 'Episode-specific plot', stillUrl: diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 0c703376b..319968b54 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -37,7 +37,6 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, - createSeriesEpisodeDownloadSnapshot, createLogger, getStalkerReturnToState, } from '@iptvnator/portal/shared/util'; @@ -84,6 +83,7 @@ import { saveStalkerSeriesPosition, StalkerSeriesPositionPartialSaveError, } from './stalker-series-position-compatibility'; +import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata'; interface SeriesPositionContext { readonly generation: number; @@ -1109,9 +1109,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { const episodeNum = episode.episode_num || 1; const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series'; - const snapshotSeriesTitle = seriesTitle.trim() || 'Series'; - const snapshotSeasonNum = positiveCoordinate(episode.season); - const snapshotEpisodeNum = positiveCoordinate(episode.episode_num); const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart( 2, '0' @@ -1124,35 +1121,16 @@ export class StalkerSeriesViewComponent implements OnDestroy { title: episodeTitle, url, posterUrl, - metadataSnapshot: createSeriesEpisodeDownloadSnapshot({ + metadataSnapshot: createStalkerSeriesDownloadSnapshot({ + item, + episode, language: this.translateService.currentLang || this.translateService.defaultLang || 'en', - title: snapshotSeriesTitle, - originalTitle: item.info?.o_name, - plot: item.info?.description, - releaseDate: item.info?.releasedate, - year: snapshotYear(item.info?.releasedate), - genres: snapshotTextList(item.info?.genre), - rating: snapshotNumber(item.info?.rating_imdb), - status: item.info?.tmdb_status, - posterUrl: item.info?.movie_image, - backdropUrl: item.info?.tmdb_backdrop, - tmdbId: item.info?.tmdb_id, - providerCategoryId: item.category_id, - cast: snapshotPeople(item.info?.tmdb_cast, item.info?.actors), - creators: snapshotPeople( - item.info?.tmdb_directors, - item.info?.director - ), - episode: { - seasonNumber: snapshotSeasonNum, - episodeNumber: snapshotEpisodeNum, - title: episode.title, - plot: episodeInfo?.plot, - stillUrl: episodeInfo?.movie_image, - }, + seriesTitle, + seasonNumber: seasonNum, + episodeNumber: episodeNum, }), seriesXtreamId: this.toSeriesId(item.id), seasonNumber: seasonNum, 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 2be57f9a8..09d4b6898 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 @@ -206,6 +206,7 @@ [seriesTitle]="info.name" [playbackPositions]="episodePlaybackPositions()" [xtreamDownloadContext]="xtreamDownloadContext()" + [downloadMetadataContext]="downloadMetadataContext()" [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 6a79f2164..1bf16ec9f 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 @@ -38,6 +38,7 @@ class StubSeasonContainerComponent { readonly seriesTitle = input(undefined); readonly playbackPositions = input(null); readonly xtreamDownloadContext = input(null); + readonly downloadMetadataContext = input(null); readonly downloadsEnabled = input(true); readonly openingEpisodeId = input(null); readonly activeEpisodeId = input(null); @@ -115,6 +116,9 @@ describe('SerialDetailsComponent', () => { cover: 'cover.jpg', backdrop_path: [], genre: 'Drama', + category_id: '3', + tmdb_id: 901, + tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }], }, episodes: { '1': [ @@ -352,6 +356,22 @@ describe('SerialDetailsComponent', () => { ], }); expect(seasonContainer?.downloadsEnabled()).toBe(true); + expect(seasonContainer?.downloadMetadataContext()).toEqual( + expect.objectContaining({ + language: 'en', + title: 'Series One', + plot: 'Series plot', + genres: ['Drama'], + providerCategoryId: '3', + tmdbId: 901, + cast: expect.arrayContaining([ + expect.objectContaining({ + name: 'Sienna Wave', + role: 'Mara', + }), + ]), + }) + ); }); 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 337b09155..19b98a87a 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,6 +21,7 @@ import { PortalDetailShellComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, + type SeasonContainerDownloadMetadataContext, SeasonContainerXtreamDownloadContext, } from '@iptvnator/ui/components'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; @@ -51,6 +52,7 @@ import { SimilarCatalogItem, matchRecommendationsToCatalog, } from '../tmdb-similar.util'; +import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; @Component({ selector: 'app-serial-details', @@ -102,6 +104,18 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { 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; + }); /** `playlistId:categoryId:serialId` of the last initialized view */ private readonly lastInitKey = signal(null); private readonly backdropBackfillKey = signal(null); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.spec.ts new file mode 100644 index 000000000..72dc88a93 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.spec.ts @@ -0,0 +1,61 @@ +import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; + +describe('createXtreamSeriesDownloadMetadataContext', () => { + it('maps already-enriched series metadata to the shared context', () => { + const context = createXtreamSeriesDownloadMetadataContext( + { + name: 'Signal House', + plot: 'Series plot', + cover: 'poster.jpg', + genre: 'Drama, Mystery', + releaseDate: '2024-04-10', + episode_run_time: '47', + rating: '8.2', + category_id: '7', + backdrop_path: ['backdrop.jpg'], + tmdb_id: 123, + tmdb_cast: [ + { + name: 'Sienna Wave', + character: 'Mara', + tmdbPersonId: 10, + }, + ], + tmdb_directors: [{ name: 'Cora Bell', tmdbPersonId: 11 }], + } as never, + 'de' + ); + + expect(context).toEqual( + expect.objectContaining({ + language: 'de', + title: 'Signal House', + plot: 'Series plot', + year: 2024, + durationMinutes: 47, + genres: ['Drama', 'Mystery'], + rating: 8.2, + tmdbId: 123, + cast: [ + { + name: 'Sienna Wave', + role: 'Mara', + tmdbPersonId: 10, + }, + ], + creators: [{ name: 'Cora Bell', tmdbPersonId: 11 }], + }) + ); + }); + + it('keeps sparse provider data valid', () => { + const context = createXtreamSeriesDownloadMetadataContext( + { name: 'Sparse Show' } as never, + 'en' + ); + + expect(context.language).toBe('en'); + expect(context.title).toBe('Sparse Show'); + expect(context.cast).toBeUndefined(); + }); +}); 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 new file mode 100644 index 000000000..a532f530e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts @@ -0,0 +1,66 @@ +import type { SeasonContainerDownloadMetadataContext } from '@iptvnator/ui/components'; +import type { + DownloadMetadataPerson, + TmdbEnrichedCastMember, + XtreamSerieInfo, +} from '@iptvnator/shared/interfaces'; + +function textList(value: string | undefined): string[] | undefined { + const entries = value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries?.length ? entries : undefined; +} + +function finiteNumber(value: number | string | undefined) { + if (value === undefined || String(value).trim() === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function releaseYear(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function people( + enriched: TmdbEnrichedCastMember[] | undefined, + fallback: string | undefined +): DownloadMetadataPerson[] | undefined { + return enriched?.length + ? enriched.map((person) => ({ + name: person.name, + role: person.character, + profileUrl: person.profileUrl ?? undefined, + tmdbPersonId: person.tmdbPersonId, + })) + : textList(fallback)?.map((name) => ({ name })); +} + +export function createXtreamSeriesDownloadMetadataContext( + info: XtreamSerieInfo, + language: string +): SeasonContainerDownloadMetadataContext { + const runtimeMinutes = finiteNumber(info.episode_run_time); + return { + language, + title: info.name?.trim() || 'Series', + plot: info.plot, + releaseDate: info.releaseDate, + year: releaseYear(info.releaseDate), + durationMinutes: + runtimeMinutes && runtimeMinutes > 0 + ? Math.round(runtimeMinutes) + : undefined, + genres: textList(info.genre), + rating: finiteNumber(info.rating), + status: info.tmdb_status, + posterUrl: info.cover, + backdropUrl: info.backdrop_path?.[0], + tmdbId: finiteNumber(info.tmdb_id), + providerCategoryId: info.category_id, + cast: people(info.tmdb_cast, info.cast), + creators: people(info.tmdb_directors, info.director), + }; +} diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts index 370db6310..de013e077 100644 --- a/libs/ui/components/src/lib/season-container/episode-download.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-download.util.ts @@ -1,4 +1,13 @@ -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + DownloadMetadataSnapshot, + XtreamSerieEpisode, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, + type DownloadMovieSnapshotInput, +} from '@iptvnator/portal/shared/util'; /** * Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the @@ -12,6 +21,9 @@ export interface XtreamEpisodeDownloadContext { password?: string; } +/** Provider-neutral metadata already loaded by the detail host. */ +export type SeasonContainerDownloadMetadataContext = DownloadMovieSnapshotInput; + export interface XtreamEpisodeDownloadRequest { playlistId: string; xtreamId: number; @@ -22,6 +34,42 @@ export interface XtreamEpisodeDownloadRequest { seriesXtreamId: number; seasonNumber: number; episodeNumber: number; + metadataSnapshot?: DownloadMetadataSnapshot; +} + +function rowCoordinate(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function buildMetadataSnapshot( + metadata: SeasonContainerDownloadMetadataContext, + episode: XtreamSerieEpisode, + seasonNumber: unknown, + episodeNumber: unknown +): DownloadMetadataSnapshot { + const normalizedSeason = rowCoordinate(seasonNumber); + const normalizedEpisode = rowCoordinate(episodeNumber); + if (normalizedSeason === undefined || normalizedEpisode === undefined) { + return { + ...createMovieDownloadSnapshot(metadata), + mediaKind: 'series', + }; + } + + const info = Array.isArray(episode.info) + ? undefined + : (episode.info as XtreamSerieEpisodeInfo); + return createSeriesEpisodeDownloadSnapshot({ + ...metadata, + episode: { + seasonNumber: normalizedSeason, + episodeNumber: normalizedEpisode, + title: episode.title, + plot: info?.plot, + stillUrl: info?.movie_image, + }, + }); } export function hashString(str: string): number { @@ -75,6 +123,7 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesTitle: string; fallbackSeasonKey: string | undefined; posterUrl?: string; + metadataContext?: SeasonContainerDownloadMetadataContext; }): XtreamEpisodeDownloadRequest { const { episode, context, playlistId, seriesId, seriesTitle } = options; const serverUrl = context.serverUrl?.replace(/\/$/, '') || ''; @@ -84,7 +133,9 @@ export function buildXtreamEpisodeDownloadRequest(options: { const seasonNumber = episode.season || Number(options.fallbackSeasonKey) || 1; const episodeNumber = episode.episode_num || 1; - const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart( + const title = `${seriesTitle || 'Series'} - S${String( + seasonNumber + ).padStart( 2, '0' )}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`; @@ -99,5 +150,15 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesXtreamId: seriesId, seasonNumber, episodeNumber, + ...(options.metadataContext + ? { + metadataSnapshot: buildMetadataSnapshot( + options.metadataContext, + episode, + seasonNumber, + episodeNumber + ), + } + : {}), }; } diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index 839ac554e..c363f2852 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -66,10 +66,90 @@ describe('episode-download.util', () => { fallbackSeasonKey: '1', }); - expect(request.url).toBe('http://host/series/u/p/55.mkv'); - expect(request.title).toBe('Show - S02E03 - The One'); - expect(request.seasonNumber).toBe(2); - expect(request.seriesXtreamId).toBe(900); + expect(request).toEqual({ + playlistId: 'pl-1', + xtreamId: 55, + contentType: 'episode', + title: 'Show - S02E03 - The One', + url: 'http://host/series/u/p/55.mkv', + posterUrl: undefined, + seriesXtreamId: 900, + seasonNumber: 2, + episodeNumber: 3, + }); + }); + + it('adds the loaded series and episode metadata when provided', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ + id: '55', + episode_num: 3, + season: 2, + title: 'The One', + info: { + plot: 'Episode plot', + movie_image: 'https://images.test/stills/one.jpg', + }, + }), + context: { serverUrl: 'http://host' }, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: '1', + metadataContext: { + language: 'de', + title: 'Show', + plot: 'Series plot', + genres: ['Drama'], + posterUrl: 'poster.jpg', + providerCategoryId: '7', + cast: [{ name: 'Actor' }], + }, + }); + + expect(request.metadataSnapshot).toEqual( + expect.objectContaining({ + language: 'de', + mediaKind: 'series', + title: 'Show', + plot: 'Series plot', + genres: ['Drama'], + providerCategoryId: '7', + cast: [{ name: 'Actor' }], + episode: { + seasonNumber: 2, + episodeNumber: 3, + title: 'The One', + plot: 'Episode plot', + stillUrl: 'https://images.test/stills/one.jpg', + }, + }) + ); + }); + + it('creates a valid sparse snapshot without provider enrichment', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ season: 1, info: [] }), + context: {}, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: undefined, + metadataContext: { language: 'en', title: 'Show' }, + }); + + expect(request.metadataSnapshot).toEqual( + expect.objectContaining({ + language: 'en', + mediaKind: 'series', + title: 'Show', + episode: { + seasonNumber: 1, + episodeNumber: 1, + title: 'Ep', + }, + }) + ); }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index c88943c6c..0fc992b13 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -12,7 +12,7 @@ import { SeasonContainerComponent } from './season-container.component'; const downloadsServiceStub = { isAvailable: signal(false), downloads: () => [], - startDownload: async () => undefined, + startDownload: jest.fn().mockResolvedValue(undefined), isDownloaded: () => false, isDownloading: () => false, getDownloadedFilePath: () => '', @@ -59,6 +59,7 @@ describe('SeasonContainerComponent', () => { beforeEach(async () => { downloadsServiceStub.isAvailable.set(false); + downloadsServiceStub.startDownload.mockClear(); await TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, @@ -148,6 +149,41 @@ describe('SeasonContainerComponent', () => { ).toBe(0); }); + it('forwards the optional series metadata into the Xtream request', async () => { + const target = createEpisode(); + setRequiredInputs({ '1': [target] }); + fixture.componentRef.setInput('seriesTitle', 'Signal House'); + fixture.componentRef.setInput('xtreamDownloadContext', { + serverUrl: 'http://host', + username: 'u', + password: 'p', + }); + fixture.componentRef.setInput('downloadMetadataContext', { + language: 'en', + title: 'Signal House', + plot: 'Series plot', + }); + fixture.detectChanges(); + + await component.downloadEpisode(new Event('click'), target); + + expect(downloadsServiceStub.startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Signal House - S01E01 - Pilot', + metadataSnapshot: expect.objectContaining({ + mediaKind: 'series', + title: 'Signal House', + plot: 'Series plot', + episode: expect.objectContaining({ + seasonNumber: 1, + episodeNumber: 1, + title: 'Pilot', + }), + }), + }) + ); + }); + it('renders the season-level placeholder when the selected season has no episodes', () => { setRequiredInputs({ '1': [] }); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 15dbd1cb3..483d9da2b 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -35,6 +35,7 @@ import { buildXtreamEpisodeDownloadRequest, getEpisodeDownloadId, isStalkerEpisode, + type SeasonContainerDownloadMetadataContext, } from './episode-download.util'; import { EPISODE_INFO_PLAY, @@ -56,6 +57,8 @@ export interface SeasonContainerXtreamDownloadContext { password?: string; } +export type { SeasonContainerDownloadMetadataContext } from './episode-download.util'; + export interface SeasonContainerPlaybackToggleRequest { contentXtreamId: number; nextPosition: PlaybackPositionData | null; @@ -94,6 +97,8 @@ export class SeasonContainerComponent implements OnInit { ); readonly xtreamDownloadContext = input(null); + readonly downloadMetadataContext = + input(null); readonly downloadsEnabled = input(true); readonly openingEpisodeId = input(null); /** Episode currently playing in an EXTERNAL player session. */ @@ -384,6 +389,7 @@ export class SeasonContainerComponent implements OnInit { seriesTitle: this.seriesTitle(), fallbackSeasonKey: this.selectedSeason(), posterUrl: this.getEpisodeInfo(episode)?.movie_image, + metadataContext: this.downloadMetadataContext() ?? undefined, }) ); }