mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(downloads): preserve episode snapshot identity
This commit is contained in:
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user