mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -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
+58
@@ -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();
|
||||
});
|
||||
});
|
||||
+102
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
+5
-5
@@ -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:
|
||||
|
||||
+7
-29
@@ -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,
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
[seriesTitle]="info.name"
|
||||
[playbackPositions]="episodePlaybackPositions()"
|
||||
[xtreamDownloadContext]="xtreamDownloadContext()"
|
||||
[downloadMetadataContext]="downloadMetadataContext()"
|
||||
[downloadsEnabled]="!providerOnly()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
|
||||
@@ -38,6 +38,7 @@ class StubSeasonContainerComponent {
|
||||
readonly seriesTitle = input<string | undefined>(undefined);
|
||||
readonly playbackPositions = input<unknown>(null);
|
||||
readonly xtreamDownloadContext = input<unknown>(null);
|
||||
readonly downloadMetadataContext = input<unknown>(null);
|
||||
readonly downloadsEnabled = input(true);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(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 () => {
|
||||
|
||||
@@ -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<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;
|
||||
});
|
||||
/** `playlistId:categoryId:serialId` of the last initialized view */
|
||||
private readonly lastInitKey = signal<string | null>(null);
|
||||
private readonly backdropBackfillKey = signal<string | null>(null);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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