fix(downloads): preserve episode snapshot identity

This commit is contained in:
4gray committed 2026-08-01 16:29:51 +02:00
1 parent 9ba45ed443
commit ce0f563735
13 files changed
+524 -41

No files matched your search

@@ -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
),
}
: {}),
};
}
@@ -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',
},
})
);
});
});
@@ -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();
@@ -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<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
input<SeasonContainerDownloadMetadataContext | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(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,
})
);
}