feat(downloads): capture metadata at download time

This commit is contained in:
4gray committed 2026-08-01 16:29:51 +02:00
1 parent c813ecb7db
commit 513348e011
7 files changed
+536 -13

No files matched your search

@@ -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',
});
}
@@ -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,
@@ -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({
@@ -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,
}),
],
}),
})
);
});