mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(downloads): capture metadata at download time
This commit is contained in:
1 parent
c813ecb7db
commit
513348e011
7 files changed
+536
-13
No files matched your search
+4
@@ -264,6 +264,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
|
||||
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
|
||||
language:
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
'en',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import type { VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
import { startStalkerVodDownload } from './stalker-vod-download';
|
||||
|
||||
describe('startStalkerVodDownload', () => {
|
||||
it('captures the rendered Stalker movie metadata without changing provider identity', async () => {
|
||||
const startDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
const deps = {
|
||||
playlist: {
|
||||
id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
title: 'Living Room Portal',
|
||||
},
|
||||
downloadsService: { startDownload },
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay: jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://cdn.example.test/movie.mpg'),
|
||||
language: 'de',
|
||||
};
|
||||
const item = {
|
||||
type: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
cmd: '/media/file_42.mpg',
|
||||
data: {
|
||||
id: '42',
|
||||
cmd: '/media/file_42.mpg',
|
||||
category_id: '7',
|
||||
info: {
|
||||
name: 'Metadata Movie',
|
||||
o_name: 'Original Metadata Movie',
|
||||
description: 'A captured Stalker plot',
|
||||
movie_image:
|
||||
'https://images.example.test/posters/stalker-movie.jpg',
|
||||
tmdb_backdrop:
|
||||
'https://images.example.test/backdrops/stalker-movie.jpg',
|
||||
releasedate: '2024-11-03',
|
||||
genre: 'Drama, Thriller',
|
||||
rating_imdb: '7.7',
|
||||
tmdb_id: 541,
|
||||
tmdb_cast: [
|
||||
{
|
||||
name: 'Stella Star',
|
||||
character: 'Mira',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 77,
|
||||
},
|
||||
],
|
||||
tmdb_directors: [
|
||||
{
|
||||
name: 'Dorian Vale',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 78,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as VodDetailsItem;
|
||||
|
||||
await startStalkerVodDownload(item, deps);
|
||||
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Living Room Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
xtreamId: 42,
|
||||
metadataSnapshot: expect.objectContaining({
|
||||
version: 1,
|
||||
language: 'de',
|
||||
mediaKind: 'movie',
|
||||
title: 'Metadata Movie',
|
||||
originalTitle: 'Original Metadata Movie',
|
||||
plot: 'A captured Stalker plot',
|
||||
posterUrl:
|
||||
'https://images.example.test/posters/stalker-movie.jpg',
|
||||
backdropUrl:
|
||||
'https://images.example.test/backdrops/stalker-movie.jpg',
|
||||
providerCategoryId: '7',
|
||||
genres: ['Drama', 'Thriller'],
|
||||
tmdbId: 541,
|
||||
cast: [
|
||||
expect.objectContaining({
|
||||
name: 'Stella Star',
|
||||
role: 'Mira',
|
||||
tmdbPersonId: 77,
|
||||
}),
|
||||
],
|
||||
creators: [
|
||||
expect.objectContaining({
|
||||
name: 'Dorian Vale',
|
||||
tmdbPersonId: 78,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('still captures a useful sparse Stalker snapshot', async () => {
|
||||
const startDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
const item = {
|
||||
type: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
cmd: '/media/file_9.mpg',
|
||||
data: {
|
||||
id: '9',
|
||||
cmd: '/media/file_9.mpg',
|
||||
title: 'Sparse title',
|
||||
info: {
|
||||
name: ' ',
|
||||
rating_imdb: ' ',
|
||||
tmdb_id: ' ',
|
||||
},
|
||||
},
|
||||
} as unknown as VodDetailsItem;
|
||||
|
||||
await startStalkerVodDownload(item, {
|
||||
playlist: {
|
||||
id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
},
|
||||
downloadsService: { startDownload },
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay: jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://cdn.example.test/movie.mpg'),
|
||||
});
|
||||
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadataSnapshot: expect.objectContaining({
|
||||
mediaKind: 'movie',
|
||||
title: 'Sparse title',
|
||||
}),
|
||||
})
|
||||
);
|
||||
const snapshot = startDownload.mock.calls[0]?.[0]?.metadataSnapshot;
|
||||
expect(snapshot).not.toHaveProperty('rating');
|
||||
expect(snapshot).not.toHaveProperty('tmdbId');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { DownloadsService } from '@iptvnator/services';
|
||||
import type { VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
TmdbEnrichedCastMember,
|
||||
VodDetailsItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The Stalker branch of the item union — the only one carrying `cmd`. */
|
||||
type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
|
||||
@@ -22,7 +26,22 @@ export interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
title?: string;
|
||||
info?: { name?: string; movie_image?: string };
|
||||
category_id?: string | number;
|
||||
info?: {
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
description?: string;
|
||||
movie_image?: string;
|
||||
tmdb_backdrop?: string;
|
||||
releasedate?: string;
|
||||
genre?: string;
|
||||
rating_imdb?: string | number;
|
||||
tmdb_id?: string | number;
|
||||
tmdb_cast?: TmdbEnrichedCastMember[];
|
||||
tmdb_directors?: TmdbEnrichedCastMember[];
|
||||
actors?: string;
|
||||
director?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerVodDownloadPlaylist {
|
||||
@@ -44,6 +63,46 @@ export interface StalkerVodDownloadDeps {
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
) => Promise<string | null>;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
function textList(value: string | undefined): string[] | undefined {
|
||||
const entries = value
|
||||
?.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return entries?.length ? entries : undefined;
|
||||
}
|
||||
|
||||
function number(value: string | number | undefined): number | undefined {
|
||||
if (value === undefined || String(value).trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function year(value: string | undefined): number | undefined {
|
||||
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function firstText(...values: (string | undefined)[]): string | undefined {
|
||||
return values.map((value) => value?.trim()).find(Boolean);
|
||||
}
|
||||
|
||||
function people(
|
||||
enriched: TmdbEnrichedCastMember[] | undefined,
|
||||
fallback: string | undefined
|
||||
) {
|
||||
return (
|
||||
enriched?.map((person) => ({
|
||||
name: person.name,
|
||||
role: person.character,
|
||||
profileUrl: person.profileUrl ?? undefined,
|
||||
tmdbPersonId: person.tmdbPersonId,
|
||||
})) ?? textList(fallback)?.map((name) => ({ name }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function startStalkerVodDownload(
|
||||
@@ -60,6 +119,7 @@ export async function startStalkerVodDownload(
|
||||
}
|
||||
|
||||
const itemData = item.data as DownloadVodData;
|
||||
const title = firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown';
|
||||
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
|
||||
|
||||
const url = await deps.fetchLinkToPlay(
|
||||
@@ -75,9 +135,31 @@ export async function startStalkerVodDownload(
|
||||
playlistId: playlist.id,
|
||||
xtreamId: normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0,
|
||||
contentType: 'vod',
|
||||
title: itemData?.info?.name || itemData?.title || 'Unknown',
|
||||
title,
|
||||
url,
|
||||
posterUrl: itemData?.info?.movie_image,
|
||||
metadataSnapshot: createMovieDownloadSnapshot({
|
||||
language: deps.language?.trim() || 'en',
|
||||
title,
|
||||
originalTitle: itemData.info?.o_name,
|
||||
plot: itemData.info?.description,
|
||||
releaseDate: itemData.info?.releasedate,
|
||||
year: year(itemData.info?.releasedate),
|
||||
genres: textList(itemData.info?.genre),
|
||||
rating: number(itemData.info?.rating_imdb),
|
||||
posterUrl: itemData.info?.movie_image,
|
||||
backdropUrl: itemData.info?.tmdb_backdrop,
|
||||
tmdbId: number(itemData.info?.tmdb_id),
|
||||
providerCategoryId:
|
||||
itemData.category_id === undefined
|
||||
? undefined
|
||||
: String(itemData.category_id),
|
||||
cast: people(itemData.info?.tmdb_cast, itemData.info?.actors),
|
||||
creators: people(
|
||||
itemData.info?.tmdb_directors,
|
||||
itemData.info?.director
|
||||
),
|
||||
}),
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referer,
|
||||
|
||||
+85
-3
@@ -90,6 +90,13 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const isEmbeddedPlayer = jest.fn();
|
||||
const tmdbGetSeason = jest.fn();
|
||||
const startDownload = jest.fn();
|
||||
const fetchLinkToPlay = jest.fn();
|
||||
const currentPlaylist = signal({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType.set('series');
|
||||
@@ -148,6 +155,15 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
overview: 'Season overview from TMDB',
|
||||
episodes: [],
|
||||
});
|
||||
startDownload.mockReset().mockResolvedValue({ success: true });
|
||||
fetchLinkToPlay
|
||||
.mockReset()
|
||||
.mockResolvedValue('https://cdn.example.test/episode.mpg');
|
||||
currentPlaylist.set({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSeriesViewComponent],
|
||||
@@ -157,7 +173,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
useValue: {
|
||||
selectedItem,
|
||||
selectedContentType,
|
||||
currentPlaylist: signal({ _id: 'stalker-1' }),
|
||||
currentPlaylist,
|
||||
getSerialSeasonsResource: () => serialSeasonsResource(),
|
||||
getVodSeriesSeasonsResource: () =>
|
||||
vodSeriesSeasonsResource(),
|
||||
@@ -165,7 +181,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
isSerialSeasonsLoading,
|
||||
fetchVodSeriesEpisodes,
|
||||
resolveVodPlayback,
|
||||
fetchLinkToPlay: jest.fn(),
|
||||
fetchLinkToPlay,
|
||||
clearSelectedItem: jest.fn(),
|
||||
},
|
||||
},
|
||||
@@ -199,7 +215,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
startDownload: jest.fn(),
|
||||
startDownload,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -222,6 +238,8 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
// EMPTY (not of(null)): the real TranslatePipe inside
|
||||
// the detail shell reads `event.lang` from emissions.
|
||||
onLangChange: EMPTY,
|
||||
@@ -328,6 +346,70 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('captures enriched parent and episode metadata when an episode download starts', async () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: '50001',
|
||||
is_series: true,
|
||||
category_id: '18',
|
||||
info: {
|
||||
name: 'Signal House',
|
||||
description: 'Parent series plot',
|
||||
movie_image:
|
||||
'https://images.example.test/posters/signal-house.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await fixture.componentInstance.downloadEpisode({
|
||||
id: 'episode-tracking-id',
|
||||
episode_num: 2,
|
||||
title: 'The Call',
|
||||
container_extension: 'mpg',
|
||||
info: {
|
||||
plot: 'Episode-specific plot',
|
||||
movie_image: 'https://images.example.test/stills/the-call.jpg',
|
||||
duration_secs: 3120,
|
||||
rating: 8.6,
|
||||
},
|
||||
custom_sid: 'vod-series',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
originalId: '502',
|
||||
} as never);
|
||||
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
playlistId: 'stalker-1',
|
||||
playlistType: 'stalker',
|
||||
seriesXtreamId: 50001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
metadataSnapshot: expect.objectContaining({
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'series',
|
||||
title: 'Signal House',
|
||||
plot: 'Parent series plot',
|
||||
posterUrl:
|
||||
'https://images.example.test/posters/signal-house.jpg',
|
||||
providerCategoryId: '18',
|
||||
episode: {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
title: 'The Call',
|
||||
plot: 'Episode-specific plot',
|
||||
stillUrl:
|
||||
'https://images.example.test/stills/the-call.jpg',
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
|
||||
+81
-5
@@ -29,6 +29,7 @@ import {
|
||||
seriesStatusLabelKey,
|
||||
TmdbEnrichedCastMember,
|
||||
XtreamSerieEpisode,
|
||||
type XtreamSerieEpisodeInfo,
|
||||
youtubeEmbedUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createSeriesEpisodeDownloadSnapshot,
|
||||
createLogger,
|
||||
getStalkerReturnToState,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
@@ -90,6 +92,48 @@ interface SeriesPositionContext {
|
||||
readonly mutationKey: string;
|
||||
}
|
||||
|
||||
function snapshotTextList(value: string | undefined): string[] | undefined {
|
||||
const entries = value
|
||||
?.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return entries?.length ? entries : undefined;
|
||||
}
|
||||
|
||||
function snapshotNumber(
|
||||
value: string | number | undefined
|
||||
): number | undefined {
|
||||
if (value === undefined || String(value).trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function snapshotYear(value: string | undefined): number | undefined {
|
||||
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function snapshotPeople(
|
||||
enriched: TmdbEnrichedCastMember[] | undefined,
|
||||
fallback: string | undefined
|
||||
) {
|
||||
return (
|
||||
enriched?.map((person) => ({
|
||||
name: person.name,
|
||||
role: person.character,
|
||||
profileUrl: person.profileUrl ?? undefined,
|
||||
tmdbPersonId: person.tmdbPersonId,
|
||||
})) ?? snapshotTextList(fallback)?.map((name) => ({ name }))
|
||||
);
|
||||
}
|
||||
|
||||
function positiveCoordinate(value: number | string | undefined): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
* Supports three modes:
|
||||
@@ -1061,10 +1105,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
const episodeInfo = this.getEpisodeInfo(episode);
|
||||
const posterUrl = episodeInfo?.movie_image;
|
||||
const seasonNum = Number(episode.season || 1);
|
||||
const episodeNum = episode.episode_num || 1;
|
||||
const seasonNum = positiveCoordinate(episode.season);
|
||||
const episodeNum = positiveCoordinate(episode.episode_num);
|
||||
const seriesTitle =
|
||||
item.info?.name || this.displayItem()?.info?.name || 'Series';
|
||||
item.info?.name?.trim() ||
|
||||
this.displayItem()?.info?.name?.trim() ||
|
||||
'Series';
|
||||
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
|
||||
2,
|
||||
'0'
|
||||
@@ -1077,6 +1123,36 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
title: episodeTitle,
|
||||
url,
|
||||
posterUrl,
|
||||
metadataSnapshot: createSeriesEpisodeDownloadSnapshot({
|
||||
language:
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
'en',
|
||||
title: seriesTitle,
|
||||
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: seasonNum,
|
||||
episodeNumber: episodeNum,
|
||||
title: episode.title,
|
||||
plot: episodeInfo?.plot,
|
||||
stillUrl: episodeInfo?.movie_image,
|
||||
},
|
||||
}),
|
||||
seriesXtreamId: this.toSeriesId(item.id),
|
||||
seasonNumber: seasonNum,
|
||||
episodeNumber: episodeNum,
|
||||
@@ -1456,11 +1532,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
private getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): { movie_image?: string } | undefined {
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
if (!episode.info || Array.isArray(episode.info)) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info as { movie_image?: string };
|
||||
return episode.info;
|
||||
}
|
||||
|
||||
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
getXtreamVodInfo,
|
||||
XtreamVodDetails,
|
||||
type XtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
|
||||
|
||||
function textList(value: string | undefined): string[] | undefined {
|
||||
const entries = value
|
||||
?.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return entries?.length ? entries : undefined;
|
||||
}
|
||||
|
||||
function number(value: number | string | undefined): number | undefined {
|
||||
if (value === undefined || String(value).trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function year(value: string | undefined): number | undefined {
|
||||
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function durationMinutes(info: XtreamVodInfo | null): number | undefined {
|
||||
const seconds = number(info?.duration_secs);
|
||||
if (seconds && seconds > 0) {
|
||||
return Math.round(seconds / 60);
|
||||
}
|
||||
const minutes = number(info?.episode_run_time);
|
||||
return minutes && minutes > 0 ? Math.round(minutes) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline downloads for the movie on screen.
|
||||
*
|
||||
@@ -16,6 +52,7 @@ import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-pre
|
||||
export class VodDetailsDownloadsService {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
private routeContentId: Signal<number> = signal(NaN);
|
||||
|
||||
@@ -90,6 +127,7 @@ export class VodDetailsDownloadsService {
|
||||
}
|
||||
|
||||
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
|
||||
const info = getXtreamVodInfo(vodItem);
|
||||
const routeVodId = this.routeContentId();
|
||||
const id =
|
||||
Number.isSafeInteger(routeVodId) && routeVodId > 0
|
||||
@@ -103,6 +141,42 @@ export class VodDetailsDownloadsService {
|
||||
title: presentation.title,
|
||||
url: this.xtreamStore.constructVodStreamUrl(vodItem),
|
||||
posterUrl: presentation.posterUrl,
|
||||
metadataSnapshot: createMovieDownloadSnapshot({
|
||||
language:
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
'en',
|
||||
title: presentation.title,
|
||||
originalTitle: info?.o_name,
|
||||
plot: info?.plot || info?.description,
|
||||
releaseDate: info?.releasedate,
|
||||
year: year(info?.releasedate),
|
||||
durationMinutes: durationMinutes(info),
|
||||
genres: textList(info?.genre),
|
||||
rating: number(info?.rating),
|
||||
status: info?.status,
|
||||
posterUrl: presentation.posterUrl,
|
||||
backdropUrl: info?.backdrop_path?.[0],
|
||||
tmdbId: number(info?.tmdb_id),
|
||||
providerCategoryId: vodItem.movie_data?.category_id,
|
||||
cast:
|
||||
info?.tmdb_cast?.map((person) => ({
|
||||
name: person.name,
|
||||
role: person.character,
|
||||
profileUrl: person.profileUrl ?? undefined,
|
||||
tmdbPersonId: person.tmdbPersonId,
|
||||
})) ??
|
||||
textList(info?.actors || info?.cast)?.map((name) => ({
|
||||
name,
|
||||
})),
|
||||
creators:
|
||||
info?.tmdb_directors?.map((person) => ({
|
||||
name: person.name,
|
||||
role: person.character,
|
||||
profileUrl: person.profileUrl ?? undefined,
|
||||
tmdbPersonId: person.tmdbPersonId,
|
||||
})) ?? textList(info?.director)?.map((name) => ({ name })),
|
||||
}),
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
|
||||
@@ -426,11 +426,42 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
await fixture.componentInstance.downloadVod({
|
||||
info: {
|
||||
name: 'Metadata Movie',
|
||||
description: 'Provider or TMDB description',
|
||||
movie_image:
|
||||
'https://images.example.test/posters/metadata-movie.jpg',
|
||||
backdrop_path: [
|
||||
'https://images.example.test/backdrops/metadata-movie.jpg',
|
||||
],
|
||||
releasedate: '2025-03-14',
|
||||
duration_secs: 7200,
|
||||
genre: 'Drama, Mystery',
|
||||
rating: '8.4',
|
||||
status: 'Released',
|
||||
tmdb_id: 12345,
|
||||
tmdb_cast: [
|
||||
{
|
||||
name: 'Ada Actor',
|
||||
character: 'The Lead',
|
||||
profileUrl: 'https://image.tmdb.org/t/p/w185/ada.jpg',
|
||||
tmdbPersonId: 91,
|
||||
},
|
||||
],
|
||||
tmdb_directors: [
|
||||
{
|
||||
name: 'Dana Director',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 92,
|
||||
},
|
||||
],
|
||||
},
|
||||
// A DIFFERENT id in the payload: the route's id must win.
|
||||
movie_data: {
|
||||
stream_id: 111,
|
||||
name: 'Example',
|
||||
container_extension: 'mp4',
|
||||
category_id: '235',
|
||||
},
|
||||
} as never);
|
||||
|
||||
@@ -439,7 +470,37 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
// (the Similar rail), and the snapshot still names the film the user
|
||||
// came from — so the download would fetch the wrong movie.
|
||||
expect(stubs.startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xtreamId: 650020 })
|
||||
expect.objectContaining({
|
||||
playlistId: 'playlist-1',
|
||||
xtreamId: 650020,
|
||||
metadataSnapshot: expect.objectContaining({
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'movie',
|
||||
title: 'Metadata Movie',
|
||||
plot: 'Provider or TMDB description',
|
||||
posterUrl:
|
||||
'https://images.example.test/posters/metadata-movie.jpg',
|
||||
backdropUrl:
|
||||
'https://images.example.test/backdrops/metadata-movie.jpg',
|
||||
providerCategoryId: '235',
|
||||
tmdbId: 12345,
|
||||
genres: ['Drama', 'Mystery'],
|
||||
cast: [
|
||||
expect.objectContaining({
|
||||
name: 'Ada Actor',
|
||||
role: 'The Lead',
|
||||
tmdbPersonId: 91,
|
||||
}),
|
||||
],
|
||||
creators: [
|
||||
expect.objectContaining({
|
||||
name: 'Dana Director',
|
||||
tmdbPersonId: 92,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user