feat(downloads): enrich offline detail metadata

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent eff978d626
commit e7467788ed
7 files changed
+1699

No files matched your search

@@ -0,0 +1,211 @@
import type { TmdbMovieDetails, TmdbTvDetails } from '@iptvnator/services';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import {
mapProviderToDownloadSnapshot,
mergeSnapshotWithTmdb,
} from './download-metadata.mapper';
const providerSnapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'de',
mediaKind: 'movie',
title: 'Provider title',
originalTitle: 'Provider original',
plot: 'Provider plot',
posterUrl: 'https://images.example.test/provider-poster.jpg',
providerCategoryId: '12',
cast: [{ name: 'Provider actor', role: 'Provider role' }],
enrichedAt: '2026-07-31T08:30:00.000Z',
};
const tmdbMovie: TmdbMovieDetails = {
id: 603,
title: 'TMDB translated title',
original_title: 'TMDB original title',
overview: 'Localized TMDB overview',
release_date: '1999-03-31',
runtime: 136,
genres: [
{ id: 1, name: 'Science Fiction' },
{ id: 2, name: 'Action' },
],
vote_average: 8.2,
vote_count: 100,
poster_path: '/tmdb-poster.jpg',
backdrop_path: '/tmdb-backdrop.jpg',
credits: {
cast: [
{
id: 1,
name: 'Actor One',
character: 'Lead',
order: 0,
profile_path: '/one.jpg',
},
{
id: 2,
name: 'Actor Two',
character: 'Guide',
order: 1,
profile_path: '/two.jpg',
},
],
crew: [
{
id: 3,
name: 'Director One',
job: 'Director',
profile_path: '/director.jpg',
},
],
},
};
describe('download metadata mapper', () => {
it('keeps provider identity while TMDB replaces editorial fields', () => {
const merged = mergeSnapshotWithTmdb(
providerSnapshot,
tmdbMovie,
'xtream'
);
expect(merged.title).toBe('Provider title');
expect(merged.originalTitle).toBe('Provider original');
expect(merged.providerCategoryId).toBe('12');
expect(merged.plot).toBe('Localized TMDB overview');
expect(merged.posterUrl).toBe(
'https://image.tmdb.org/t/p/w500/tmdb-poster.jpg'
);
expect(merged.backdropUrl).toBe(
'https://image.tmdb.org/t/p/w1280/tmdb-backdrop.jpg'
);
expect(merged.cast).toHaveLength(2);
expect(merged.cast?.[0]).toEqual(
expect.objectContaining({ name: 'Actor One', role: 'Lead' })
);
expect(merged.creators?.[0]?.name).toBe('Director One');
expect(merged.tmdbId).toBe(603);
});
it('maps a Stalker recent payload without copying credentials or playback fields', () => {
const mapped = mapProviderToDownloadSnapshot({
source: 'stalker',
language: 'en',
mediaKind: 'movie',
fallback: providerSnapshot,
provider: {
movie_id: '41',
title: 'Stalker provider title',
category_id: 'vod',
cover: 'https://images.example.test/stalker.jpg',
description: 'Stalker provider plot',
actors: 'First Actor, Second Actor',
genre: 'Drama, Mystery',
cmd: 'ffmpeg http://stream.example.test/movie/41',
portalUrl: 'https://portal.example.test',
username: 'account-user',
password: 'account-secret',
token: 'session-token',
},
});
expect(mapped.title).toBe('Stalker provider title');
expect(mapped.plot).toBe('Stalker provider plot');
expect(mapped.posterUrl).toBe(
'https://images.example.test/stalker.jpg'
);
expect(mapped.genres).toEqual(['Drama', 'Mystery']);
expect(mapped.cast?.map(({ name }) => name)).toEqual([
'First Actor',
'Second Actor',
]);
const encoded = JSON.stringify(mapped);
expect(encoded).not.toContain('account-secret');
expect(encoded).not.toContain('session-token');
expect(encoded).not.toContain('stream.example.test');
expect(mapped).not.toHaveProperty('cmd');
});
it('bounds mapped people and genres to the persisted DTO limits', () => {
const mapped = mapProviderToDownloadSnapshot({
source: 'xtream',
language: 'en',
mediaKind: 'movie',
fallback: providerSnapshot,
provider: {
title: 'Large provider record',
category_id: 5,
genre: Array.from(
{ length: 25 },
(_, index) => `Genre ${index + 1}`
).join(', '),
actors: Array.from(
{ length: 35 },
(_, index) => `Actor ${index + 1}`
).join(', '),
director: Array.from(
{ length: 35 },
(_, index) => `Creator ${index + 1}`
).join(', '),
},
});
expect(mapped.genres).toHaveLength(20);
expect(mapped.cast).toHaveLength(30);
expect(mapped.creators).toHaveLength(30);
});
it('preserves the downloaded episode and never derives episodes from TMDB series data', () => {
const snapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Provider series',
providerCategoryId: '9',
episode: {
title: 'Downloaded episode',
plot: 'Local episode plot',
stillUrl: 'https://images.example.test/local-still.jpg',
seasonNumber: 2,
episodeNumber: 7,
},
enrichedAt: '2026-07-31T08:30:00.000Z',
};
const details = {
id: 900,
name: 'TMDB series title',
overview: 'Localized series overview',
poster_path: '/series.jpg',
seasons: [{ season_number: 1, episode_count: 12 }],
episodes: [{ season_number: 1, episode_number: 1 }],
} as TmdbTvDetails & Record<string, unknown>;
const merged = mergeSnapshotWithTmdb(snapshot, details, 'stalker');
expect(merged.title).toBe('Provider series');
expect(merged.episode).toEqual(snapshot.episode);
expect(merged).not.toHaveProperty('episodes');
expect(merged).not.toHaveProperty('seasons');
});
it('does not fabricate episode metadata for a legacy series snapshot without it', () => {
const snapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Legacy provider series',
};
const merged = mergeSnapshotWithTmdb(
snapshot,
{
id: 901,
name: 'TMDB series title',
overview: 'Localized series overview',
},
'xtream'
);
expect(merged).not.toHaveProperty('episode');
});
});
@@ -0,0 +1,316 @@
import {
createMovieDownloadSnapshot,
createSeriesEpisodeDownloadSnapshot,
} from '@iptvnator/portal/shared/util';
import {
mergeSerieInfoWithTmdb,
mergeStalkerInfoWithTmdb,
mergeVodInfoWithTmdb,
type TmdbMovieDetails,
type TmdbTvDetails,
} from '@iptvnator/services';
import type {
DownloadMetadataPerson,
DownloadMetadataSnapshot,
StalkerVodInfo,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
export type DownloadMetadataProviderSource = 'xtream' | 'stalker';
export interface MapProviderToDownloadSnapshotInput {
source: DownloadMetadataProviderSource;
language: string;
mediaKind: DownloadMetadataSnapshot['mediaKind'];
fallback: DownloadMetadataSnapshot;
provider: unknown;
}
type ProviderRecord = Record<string, unknown>;
function record(value: unknown): ProviderRecord | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as ProviderRecord)
: undefined;
}
function first(
records: readonly (ProviderRecord | undefined)[],
keys: readonly string[]
): unknown {
for (const source of records) {
for (const key of keys) {
const value = source?.[key];
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
}
return undefined;
}
function string(value: unknown): string | undefined {
if (typeof value !== 'string' && typeof value !== 'number') {
return undefined;
}
const normalized = String(value).trim();
return normalized ? normalized : undefined;
}
function number(value: unknown): number | undefined {
const parsed = typeof value === 'number' ? value : Number(string(value));
return Number.isFinite(parsed) ? parsed : undefined;
}
function integer(value: unknown): number | undefined {
const parsed = number(value);
return parsed !== undefined && Number.isSafeInteger(parsed)
? parsed
: undefined;
}
function strings(value: unknown): string[] | undefined {
const values = Array.isArray(value) ? value : string(value)?.split(',');
const normalized = values
?.map((entry) => string(entry))
.filter((entry): entry is string => entry !== undefined);
return normalized && normalized.length > 0 ? normalized : undefined;
}
function people(value: unknown): DownloadMetadataPerson[] | undefined {
if (!Array.isArray(value)) {
return strings(value)?.map((name) => ({ name }));
}
const normalized = value
.map((entry): DownloadMetadataPerson | null => {
const source = record(entry);
const name = string(source?.['name']);
if (!name) {
return null;
}
const tmdbPersonId = integer(source?.['tmdbPersonId']);
const role = string(source?.['character'] ?? source?.['role']);
const profileUrl = string(source?.['profileUrl']);
return {
...(tmdbPersonId === undefined ? {} : { tmdbPersonId }),
name,
...(role === undefined ? {} : { role }),
...(profileUrl === undefined ? {} : { profileUrl }),
};
})
.filter((entry): entry is DownloadMetadataPerson => entry !== null);
return normalized.length > 0 ? normalized : undefined;
}
function backdrop(value: unknown): string | undefined {
return string(Array.isArray(value) ? value[0] : value);
}
function releaseYear(value: string | undefined): number | undefined {
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
return match ? Number(match[1]) : undefined;
}
function durationMinutes(
records: readonly ProviderRecord[]
): number | undefined {
const seconds = number(first(records, ['duration_secs']));
if (seconds !== undefined && seconds > 0) {
return Math.round(seconds / 60);
}
const minutes = number(
first(records, ['durationMinutes', 'episode_run_time', 'runtime'])
);
return minutes !== undefined && minutes > 0
? Math.round(minutes)
: undefined;
}
export function mapProviderToDownloadSnapshot({
language,
mediaKind,
fallback,
provider,
}: MapProviderToDownloadSnapshotInput): DownloadMetadataSnapshot {
const root = record(provider) ?? {};
const info = record(root['info']);
const editorial = [info, root];
const identity = [root, info];
const title = string(first(identity, ['title', 'name'])) ?? fallback.title;
const originalTitle =
string(first(editorial, ['o_name', 'originalTitle'])) ??
fallback.originalTitle;
const plot =
string(first(editorial, ['plot', 'description'])) ?? fallback.plot;
const releaseDate =
string(first(editorial, ['releaseDate', 'releasedate'])) ??
fallback.releaseDate;
const providerGenres = strings(first(editorial, ['genre', 'genres']));
const providerCast = people(
first(editorial, ['tmdb_cast', 'actors', 'cast'])
);
const providerCreators = people(
first(editorial, ['tmdb_directors', 'director', 'creators'])
);
const posterUrl =
string(
first(editorial, [
'movie_image',
'cover_big',
'cover',
'poster_url',
'posterUrl',
'logo',
])
) ?? fallback.posterUrl;
const backdropUrl =
backdrop(
first(editorial, ['tmdb_backdrop', 'backdrop_path', 'backdrop_url'])
) ?? fallback.backdropUrl;
const rating =
number(first(editorial, ['rating', 'rating_imdb'])) ?? fallback.rating;
const status =
string(first(editorial, ['tmdb_status', 'status'])) ?? fallback.status;
const tmdbId =
integer(first(editorial, ['tmdb_id', 'tmdbId'])) ?? fallback.tmdbId;
const providerCategoryId =
string(first(identity, ['category_id', 'providerCategoryId'])) ??
fallback.providerCategoryId;
const common = {
language,
title,
originalTitle,
plot,
releaseDate,
year: releaseYear(releaseDate) ?? fallback.year,
durationMinutes:
durationMinutes(editorial.filter(Boolean) as ProviderRecord[]) ??
fallback.durationMinutes,
genres: providerGenres ?? fallback.genres,
rating,
status,
posterUrl,
backdropUrl,
tmdbId,
providerCategoryId,
cast: providerCast ?? fallback.cast,
creators: providerCreators ?? fallback.creators,
};
if (mediaKind === 'movie') {
return createMovieDownloadSnapshot(common);
}
if (!fallback.episode) {
return {
...createMovieDownloadSnapshot(common),
mediaKind: 'series',
};
}
return createSeriesEpisodeDownloadSnapshot({
...common,
episode: fallback.episode,
});
}
function movieSeed(snapshot: DownloadMetadataSnapshot): XtreamVodInfo {
const cast = snapshot.cast?.map(({ name }) => name).join(', ') ?? '';
return {
kinopoisk_url: '',
tmdb_id: snapshot.tmdbId ?? 0,
name: snapshot.title,
o_name: snapshot.originalTitle ?? '',
cover_big: snapshot.posterUrl ?? '',
movie_image: snapshot.posterUrl ?? '',
releasedate: snapshot.releaseDate ?? '',
episode_run_time: snapshot.durationMinutes ?? 0,
youtube_trailer: '',
director: snapshot.creators?.map(({ name }) => name).join(', ') ?? '',
actors: cast,
cast,
description: snapshot.plot ?? '',
plot: snapshot.plot ?? '',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: '',
genre: snapshot.genres?.join(', ') ?? '',
backdrop_path: snapshot.backdropUrl ? [snapshot.backdropUrl] : [],
duration_secs: (snapshot.durationMinutes ?? 0) * 60,
duration: '',
video: [],
audio: [],
bitrate: 0,
rating: snapshot.rating ?? 0,
};
}
function seriesSeed(snapshot: DownloadMetadataSnapshot): XtreamSerieInfo {
return {
name: snapshot.title,
cover: snapshot.posterUrl ?? '',
plot: snapshot.plot ?? '',
cast: snapshot.cast?.map(({ name }) => name).join(', ') ?? '',
director: snapshot.creators?.map(({ name }) => name).join(', ') ?? '',
genre: snapshot.genres?.join(', ') ?? '',
releaseDate: snapshot.releaseDate ?? '',
last_modified: '',
rating: snapshot.rating === undefined ? '' : String(snapshot.rating),
rating_5based: 0,
backdrop_path: snapshot.backdropUrl ? [snapshot.backdropUrl] : [],
youtube_trailer: '',
episode_run_time:
snapshot.durationMinutes === undefined
? ''
: String(snapshot.durationMinutes),
category_id: snapshot.providerCategoryId ?? '',
tmdb_id: snapshot.tmdbId,
};
}
function stalkerSeed(snapshot: DownloadMetadataSnapshot): StalkerVodInfo {
return {
movie_image: snapshot.posterUrl ?? '',
description: snapshot.plot ?? '',
name: snapshot.title,
o_name: snapshot.originalTitle,
actors: snapshot.cast?.map(({ name }) => name).join(', ') ?? '',
director: snapshot.creators?.map(({ name }) => name).join(', ') ?? '',
releasedate: snapshot.releaseDate ?? '',
genre: snapshot.genres?.join(', ') ?? '',
rating_imdb:
snapshot.rating === undefined ? '' : String(snapshot.rating),
rating_kinopoisk: '',
tmdb_id: snapshot.tmdbId,
};
}
export function mergeSnapshotWithTmdb(
snapshot: DownloadMetadataSnapshot,
details: TmdbMovieDetails | TmdbTvDetails,
source: DownloadMetadataProviderSource
): DownloadMetadataSnapshot {
const provider =
source === 'stalker'
? mergeStalkerInfoWithTmdb(
stalkerSeed(snapshot),
details,
snapshot.mediaKind === 'movie' ? 'movie' : 'tv'
)
: snapshot.mediaKind === 'movie'
? mergeVodInfoWithTmdb(
movieSeed(snapshot),
details as TmdbMovieDetails
)
: mergeSerieInfoWithTmdb(
seriesSeed(snapshot),
details as TmdbTvDetails
);
return mapProviderToDownloadSnapshot({
source,
language: snapshot.language,
mediaKind: snapshot.mediaKind,
fallback: snapshot,
provider,
});
}
@@ -0,0 +1,454 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import {
DatabaseService,
DownloadsService,
PlaylistsService,
SettingsStore,
TmdbEnrichmentService,
type TmdbMovieDetails,
type TmdbTvDetails,
} from '@iptvnator/services';
import type {
DownloadMetadataSnapshot,
Playlist,
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import type { DownloadItem } from '@iptvnator/services';
import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel';
import { DownloadOfflineMetadataService } from './download-offline-metadata.service';
const PLAYLIST_ID = 'playlist-a';
const NOW = '2026-07-31T08:30:00.000Z';
const XTREAM_PLAYLIST = {
_id: PLAYLIST_ID,
title: 'Xtream source',
importDate: NOW,
lastUsage: NOW,
count: 0,
autoRefresh: false,
portalUrl: 'https://xtream.example.test',
} satisfies Playlist;
const STALKER_PLAYLIST = {
...XTREAM_PLAYLIST,
title: 'Stalker source',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
} satisfies Playlist;
type DatabaseFake = jest.Mocked<Pick<DatabaseService, 'getContentByXtreamId'>>;
type DownloadsFake = jest.Mocked<Pick<DownloadsService, 'updateMetadata'>>;
type PlaylistsFake = jest.Mocked<
Pick<PlaylistsService, 'getPlaylistById' | 'getPortalRecentlyViewed'>
>;
type TmdbFake = jest.Mocked<
Pick<TmdbEnrichmentService, 'isEnabled' | 'enrichMovie' | 'enrichTv'>
>;
function download(overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id: 17,
playlistId: PLAYLIST_ID,
xtreamId: 41,
contentType: 'vod',
title: 'Downloaded fallback title',
url: 'https://stream.example.test/movie/41.m3u8',
posterUrl: 'https://images.example.test/download.jpg',
status: 'completed',
fileAvailability: 'available',
filePath: '/downloads/movie.mp4',
...overrides,
};
}
function snapshot(
overrides: Partial<DownloadMetadataSnapshot> = {}
): DownloadMetadataSnapshot {
return {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Local title',
plot: 'Rich local plot',
posterUrl: 'https://images.example.test/local.jpg',
providerCategoryId: '4',
enrichedAt: new Date().toISOString(),
...overrides,
};
}
function movieDetail(
itemOverrides: Partial<DownloadItem> = {},
snapshotOverride?: DownloadMetadataSnapshot
): DownloadOfflineDetail {
const item = download({
...itemOverrides,
...(snapshotOverride === undefined
? {}
: { metadataSnapshot: snapshotOverride }),
});
return {
kind: 'movie',
item,
...(snapshotOverride === undefined
? {}
: { snapshot: snapshotOverride }),
};
}
function seriesDetail(
seriesSnapshot: DownloadMetadataSnapshot
): DownloadOfflineDetail {
const representative = download({
contentType: 'episode',
xtreamId: 501,
seriesXtreamId: 93,
seasonNumber: 2,
episodeNumber: 7,
title: 'Series - S02E07 - Relay',
metadataSnapshot: seriesSnapshot,
});
return {
kind: 'series',
representative,
snapshot: seriesSnapshot,
seasons: [
{
seasonNumber: 2,
seasonNumberState: 'known',
episodes: [
{
item: representative,
seasonNumber: 2,
seasonNumberState: 'known',
episodeNumber: 7,
episodeNumberState: 'known',
episodeMetadata: seriesSnapshot.episode,
},
],
},
],
};
}
const tmdbMovie: TmdbMovieDetails = {
id: 603,
overview: 'Localized TMDB movie plot',
poster_path: '/movie.jpg',
};
const tmdbTv: TmdbTvDetails = {
id: 900,
overview: 'Localized TMDB series plot',
poster_path: '/series.jpg',
};
describe('DownloadOfflineMetadataService', () => {
let service: DownloadOfflineMetadataService;
let db: DatabaseFake;
let downloads: DownloadsFake;
let playlists: PlaylistsFake;
let tmdb: TmdbFake;
beforeEach(() => {
db = {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
downloads = {
updateMetadata: jest.fn().mockResolvedValue({ success: true }),
};
playlists = {
getPlaylistById: jest.fn().mockReturnValue(of(XTREAM_PLAYLIST)),
getPortalRecentlyViewed: jest.fn().mockReturnValue(of([])),
};
tmdb = {
isEnabled: jest.fn().mockReturnValue(false),
enrichMovie: jest.fn().mockResolvedValue(null),
enrichTv: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
DownloadOfflineMetadataService,
{ provide: DatabaseService, useValue: db },
{ provide: DownloadsService, useValue: downloads },
{ provide: PlaylistsService, useValue: playlists },
{ provide: TmdbEnrichmentService, useValue: tmdb },
{
provide: SettingsStore,
useValue: { language: () => 'en' },
},
],
});
service = TestBed.inject(DownloadOfflineMetadataService);
});
it('returns a rich fresh current-language snapshot without provider lookup', async () => {
const local = snapshot();
await expect(service.resolve(movieDetail({}, local))).resolves.toEqual(
local
);
expect(playlists.getPlaylistById).not.toHaveBeenCalled();
expect(playlists.getPortalRecentlyViewed).not.toHaveBeenCalled();
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
expect(tmdb.enrichMovie).not.toHaveBeenCalled();
expect(downloads.updateMetadata).not.toHaveBeenCalled();
});
it('recovers a sparse Stalker legacy row from its matching recent item', async () => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
const recent = {
movie_id: '41:archive',
category_id: 'vod',
title: 'Stalker provider title',
cover: 'https://images.example.test/stalker-provider.jpg',
description: 'Stalker provider plot',
} satisfies StalkerPortalItem & { description: string };
playlists.getPortalRecentlyViewed.mockReturnValue(of([recent]));
const resolved = await service.resolve(
movieDetail({}, snapshot({ plot: undefined, posterUrl: undefined }))
);
expect(playlists.getPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST_ID
);
expect(resolved.title).toBe('Stalker provider title');
expect(resolved.plot).toBe('Stalker provider plot');
expect(resolved.posterUrl).toBe(
'https://images.example.test/stalker-provider.jpg'
);
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
});
it('recovers a sparse Xtream row through the database content lookup', async () => {
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: 'Xtream provider title',
rating: '7.5',
added: '0',
poster_url: 'https://images.example.test/xtream-provider.jpg',
xtream_id: 41,
type: 'movie',
});
const resolved = await service.resolve(movieDetail());
expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID);
expect(resolved.title).toBe('Xtream provider title');
expect(resolved.providerCategoryId).toBe('12');
expect(resolved.posterUrl).toBe(
'https://images.example.test/xtream-provider.jpg'
);
expect(playlists.getPortalRecentlyViewed).not.toHaveBeenCalled();
});
it.each([
['movie', 'enrichMovie'] as const,
['series', 'enrichTv'] as const,
])('uses %s TMDB enrichment when enabled', async (kind, method) => {
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockResolvedValue(tmdbMovie);
tmdb.enrichTv.mockResolvedValue(tmdbTv);
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: `Provider ${kind}`,
rating: '',
added: '0',
poster_url: '',
xtream_id: kind === 'movie' ? 41 : 93,
type: kind,
});
const localSeries = snapshot({
mediaKind: 'series',
title: 'Local series',
plot: undefined,
episode: {
title: 'Local episode',
seasonNumber: 2,
episodeNumber: 7,
},
});
const detail =
kind === 'movie' ? movieDetail() : seriesDetail(localSeries);
const resolved = await service.resolve(detail);
expect(tmdb[method]).toHaveBeenCalledWith(
expect.objectContaining({ title: `Provider ${kind}` })
);
expect(resolved.plot).toBe(
kind === 'movie'
? 'Localized TMDB movie plot'
: 'Localized TMDB series plot'
);
});
it('keeps provider metadata when TMDB is disabled', async () => {
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: 'Provider title',
rating: '7',
added: '0',
poster_url: 'https://images.example.test/provider.jpg',
xtream_id: 41,
type: 'movie',
});
const resolved = await service.resolve(movieDetail());
expect(resolved.title).toBe('Provider title');
expect(resolved.posterUrl).toBe(
'https://images.example.test/provider.jpg'
);
expect(tmdb.enrichMovie).not.toHaveBeenCalled();
});
it('keeps provider metadata when TMDB rejects', async () => {
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockRejectedValue(new Error('TMDB unavailable'));
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: 'Provider title',
rating: '7',
added: '0',
poster_url: 'https://images.example.test/provider.jpg',
xtream_id: 41,
type: 'movie',
});
await expect(service.resolve(movieDetail())).resolves.toEqual(
expect.objectContaining({
title: 'Provider title',
posterUrl: 'https://images.example.test/provider.jpg',
})
);
});
it('persists one materially changed successful merge', async () => {
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockResolvedValue(tmdbMovie);
const local = snapshot({ plot: undefined });
const resolved = await service.resolve(movieDetail({}, local));
expect(downloads.updateMetadata).toHaveBeenCalledTimes(1);
expect(downloads.updateMetadata).toHaveBeenCalledWith(17, resolved);
});
it('does not persist a stale refresh when only field order and timestamp changed', async () => {
const local = snapshot({ enrichedAt: '2020-01-01T00:00:00.000Z' });
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 4,
title: 'Local title',
rating: '',
added: '0',
poster_url: 'https://images.example.test/local.jpg',
xtream_id: 41,
type: 'movie',
});
await expect(service.resolve(movieDetail({}, local))).resolves.toEqual(
local
);
expect(downloads.updateMetadata).not.toHaveBeenCalled();
});
it('preserves local episode metadata and does not fabricate a series episode list', async () => {
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichTv.mockResolvedValue({
...tmdbTv,
seasons: [{ season_number: 1, episode_count: 99 }],
episodes: [{ season_number: 1, episode_number: 1 }],
} as TmdbTvDetails & Record<string, unknown>);
const local = snapshot({
mediaKind: 'series',
title: 'Local series',
plot: undefined,
episode: {
title: 'Downloaded episode',
plot: 'Local episode plot',
seasonNumber: 2,
episodeNumber: 7,
},
});
const resolved = await service.resolve(seriesDetail(local));
expect(resolved.episode).toEqual(local.episode);
expect(resolved).not.toHaveProperty('episodes');
expect(resolved).not.toHaveProperty('seasons');
});
it('does not fabricate episode coordinates for a sparse legacy series row', async () => {
const representative = download({
contentType: 'episode',
xtreamId: 501,
seriesXtreamId: 93,
title: 'Legacy series episode',
});
const detail: DownloadOfflineDetail = {
kind: 'series',
representative,
seasons: [
{
seasonNumber: null,
seasonNumberState: 'unknown',
episodes: [
{
item: representative,
seasonNumber: null,
seasonNumberState: 'unknown',
episodeNumber: null,
episodeNumberState: 'unknown',
},
],
},
],
};
const resolved = await service.resolve(detail);
expect(resolved.mediaKind).toBe('series');
expect(resolved).not.toHaveProperty('episode');
});
it('returns the best local metadata when provider lookup fails', async () => {
playlists.getPlaylistById.mockReturnValue(
throwError(() => new Error('playlist unavailable'))
);
const local = snapshot({ plot: undefined });
await expect(service.resolve(movieDetail({}, local))).resolves.toEqual(
local
);
expect(downloads.updateMetadata).not.toHaveBeenCalled();
});
it('returns changed metadata when best-effort persistence fails', async () => {
downloads.updateMetadata.mockRejectedValue(
new Error('persistence unavailable')
);
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: 'Recovered provider title',
rating: '7',
added: '0',
poster_url: 'https://images.example.test/provider.jpg',
xtream_id: 41,
type: 'movie',
});
await expect(service.resolve(movieDetail())).resolves.toEqual(
expect.objectContaining({ title: 'Recovered provider title' })
);
expect(downloads.updateMetadata).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,295 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
DatabaseService,
DownloadsService,
PlaylistsService,
SettingsStore,
TMDB_DETAILS_CACHE_TTL_MS,
TmdbEnrichmentService,
type DownloadItem,
} from '@iptvnator/services';
import {
createMovieDownloadSnapshot,
createSeriesEpisodeDownloadSnapshot,
} from '@iptvnator/portal/shared/util';
import type {
DownloadMetadataSnapshot,
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import {
mapProviderToDownloadSnapshot,
mergeSnapshotWithTmdb,
type DownloadMetadataProviderSource,
} from './download-metadata.mapper';
import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel';
interface ProviderContext {
source: DownloadMetadataProviderSource;
provider?: unknown;
}
function representative(detail: DownloadOfflineDetail): DownloadItem {
return (
detail.kind === 'movie' ? detail.item : detail.representative
) as DownloadItem;
}
function targetId(detail: DownloadOfflineDetail): number {
const item = representative(detail);
return detail.kind === 'series'
? (item.seriesXtreamId ?? item.xtreamId)
: item.xtreamId;
}
function normalizedPortalId(value: unknown): string {
return String(value ?? '')
.trim()
.split(':')[0];
}
function matchingRecent(
items: readonly StalkerPortalItem[],
expectedId: number
): StalkerPortalItem | undefined {
const expected = String(expectedId);
return items.find((item) =>
[item.id, item.movie_id, item.series_id, item.stream_id].some(
(candidate) => normalizedPortalId(candidate) === expected
)
);
}
function episodeTitle(item: DownloadItem): string {
const parts = item.title.split(/\s+-\s+S\d+E\d+\s+-\s+/i);
return parts[parts.length - 1] ?? item.title;
}
function seriesTitle(item: DownloadItem): string {
return item.title.replace(/\s+-\s+S\d+E\d+(?:\s+-\s+.*)?$/i, '').trim();
}
function fallbackSnapshot(
detail: DownloadOfflineDetail,
language: string
): DownloadMetadataSnapshot {
const item = representative(detail);
if (detail.kind === 'movie') {
return createMovieDownloadSnapshot({
language,
title: item.title,
posterUrl: item.posterUrl,
});
}
const parent = {
language,
title: seriesTitle(item) || item.title,
posterUrl: item.posterUrl,
};
const seasonNumber = item.seasonNumber;
const episodeNumber = item.episodeNumber;
if (
!Number.isSafeInteger(seasonNumber) ||
(seasonNumber ?? -1) < 0 ||
!Number.isSafeInteger(episodeNumber) ||
(episodeNumber ?? -1) < 0
) {
return {
...createMovieDownloadSnapshot(parent),
mediaKind: 'series',
};
}
return createSeriesEpisodeDownloadSnapshot({
...parent,
episode: {
title: episodeTitle(item),
seasonNumber: seasonNumber as number,
episodeNumber: episodeNumber as number,
},
});
}
function existingSnapshot(
detail: DownloadOfflineDetail
): DownloadMetadataSnapshot | undefined {
const snapshot = detail.snapshot as DownloadMetadataSnapshot | undefined;
const expectedKind = detail.kind === 'movie' ? 'movie' : 'series';
return snapshot?.version === 1 && snapshot.mediaKind === expectedKind
? snapshot
: undefined;
}
function isSparse(snapshot: DownloadMetadataSnapshot): boolean {
return !snapshot.plot || (!snapshot.posterUrl && !snapshot.backdropUrl);
}
function isFresh(snapshot: DownloadMetadataSnapshot): boolean {
const enrichedAt = Date.parse(snapshot.enrichedAt ?? '');
const age = Date.now() - enrichedAt;
return (
Number.isFinite(enrichedAt) &&
age >= 0 &&
age <= TMDB_DETAILS_CACHE_TTL_MS
);
}
function needsRefresh(
snapshot: DownloadMetadataSnapshot | undefined,
language: string
): boolean {
return (
!snapshot ||
snapshot.language !== language ||
isSparse(snapshot) ||
!isFresh(snapshot)
);
}
function materiallyEqual(
left: DownloadMetadataSnapshot,
right: DownloadMetadataSnapshot
): boolean {
const leftFields = { ...left };
const rightFields = { ...right };
delete leftFields.enrichedAt;
delete rightFields.enrichedAt;
return (
JSON.stringify(sortedValue(leftFields)) ===
JSON.stringify(sortedValue(rightFields))
);
}
function sortedValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortedValue);
}
if (!value || typeof value !== 'object') {
return value;
}
return Object.keys(value)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = sortedValue((value as Record<string, unknown>)[key]);
return result;
}, {});
}
@Injectable({ providedIn: 'root' })
export class DownloadOfflineMetadataService {
private readonly db = inject(DatabaseService);
private readonly downloads = inject(DownloadsService);
private readonly playlists = inject(PlaylistsService);
private readonly settings = inject(SettingsStore);
private readonly tmdb = inject(TmdbEnrichmentService);
async resolve(
detail: DownloadOfflineDetail
): Promise<DownloadMetadataSnapshot> {
const language = this.currentLanguage();
const persisted = existingSnapshot(detail);
const local = persisted ?? fallbackSnapshot(detail, language);
if (!needsRefresh(persisted, language)) {
return local;
}
const context = await this.loadProviderContext(detail);
let resolved = local;
if (context.provider) {
try {
resolved = mapProviderToDownloadSnapshot({
source: context.source,
language,
mediaKind: local.mediaKind,
fallback: local,
provider: context.provider,
});
} catch {
resolved = local;
}
}
resolved = await this.enrichWithTmdb(resolved, context.source);
if (materiallyEqual(local, resolved)) {
return local;
}
try {
await this.downloads.updateMetadata(
representative(detail).id,
resolved
);
} catch {
// Enriched metadata remains useful for this view without backfill.
}
return resolved;
}
private currentLanguage(): string {
try {
return this.settings.language().trim() || 'en';
} catch {
return 'en';
}
}
private async loadProviderContext(
detail: DownloadOfflineDetail
): Promise<ProviderContext> {
const item = representative(detail);
try {
const playlist = await firstValueFrom(
this.playlists.getPlaylistById(item.playlistId)
);
const source: DownloadMetadataProviderSource =
playlist?.portalUrl && playlist.macAddress
? 'stalker'
: 'xtream';
if (source === 'stalker') {
const recent = await firstValueFrom(
this.playlists.getPortalRecentlyViewed(item.playlistId)
);
return {
source,
provider: matchingRecent(recent, targetId(detail)),
};
}
return {
source,
provider: await this.db.getContentByXtreamId(
targetId(detail),
item.playlistId
),
};
} catch {
return { source: 'xtream' };
}
}
private async enrichWithTmdb(
snapshot: DownloadMetadataSnapshot,
source: DownloadMetadataProviderSource
): Promise<DownloadMetadataSnapshot> {
try {
if (!this.tmdb.isEnabled()) {
return snapshot;
}
const query = {
title: snapshot.title,
originalTitle: snapshot.originalTitle,
year: snapshot.year,
tmdbId: snapshot.tmdbId,
};
const details =
snapshot.mediaKind === 'movie'
? await this.tmdb.enrichMovie(query)
: await this.tmdb.enrichTv(query);
return details
? mergeSnapshotWithTmdb(snapshot, details, source)
: snapshot;
} catch {
return snapshot;
}
}
}
+1
View File
@@ -28,3 +28,4 @@ export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/external-playback-button-state';
export * from './lib/inline-playback-position-writer';
export * from './lib/downloads/download-metadata-snapshot';
@@ -0,0 +1,130 @@
import type { DownloadMetadataPerson } from '@iptvnator/shared/interfaces';
import {
createMovieDownloadSnapshot,
createSeriesEpisodeDownloadSnapshot,
} from './download-metadata-snapshot';
function people(count: number): DownloadMetadataPerson[] {
return Array.from({ length: count }, (_, index) => ({
tmdbPersonId: index + 1,
name: `Person ${index + 1}`,
role: `Role ${index + 1}`,
profileUrl: `https://image.tmdb.org/t/p/w185/person-${index + 1}.jpg`,
}));
}
describe('download metadata snapshot factories', () => {
it('creates an allowlisted movie snapshot without provider secrets or stream URLs', () => {
const snapshot = createMovieDownloadSnapshot({
language: 'de',
title: 'Provider title',
originalTitle: 'Original title',
plot: 'Provider plot',
posterUrl: 'https://images.example.test/poster.jpg',
providerCategoryId: '12',
enrichedAt: '2026-07-31T08:30:00.000Z',
username: 'account-user',
password: 'account-secret',
url: 'https://stream.example.test/movie/41.m3u8',
portalUrl: 'https://portal.example.test',
macAddress: '00:1A:79:12:34:56',
} as Parameters<typeof createMovieDownloadSnapshot>[0] &
Record<string, unknown>);
expect(snapshot).toEqual({
version: 1,
language: 'de',
mediaKind: 'movie',
title: 'Provider title',
originalTitle: 'Original title',
plot: 'Provider plot',
posterUrl: 'https://images.example.test/poster.jpg',
providerCategoryId: '12',
enrichedAt: '2026-07-31T08:30:00.000Z',
});
expect(JSON.stringify(snapshot)).not.toContain('account-secret');
expect(JSON.stringify(snapshot)).not.toContain('.m3u8');
expect(snapshot).not.toHaveProperty('username');
expect(snapshot).not.toHaveProperty('portalUrl');
});
it('creates series metadata with parent and episode display fields', () => {
const snapshot = createSeriesEpisodeDownloadSnapshot({
language: 'en',
title: 'Signal House',
plot: 'Parent series plot',
posterUrl: 'https://images.example.test/series.jpg',
providerCategoryId: '8',
episode: {
title: 'The Relay',
plot: 'Episode plot',
stillUrl: 'https://images.example.test/relay.jpg',
seasonNumber: 2,
episodeNumber: 4,
},
enrichedAt: '2026-07-31T08:30:00.000Z',
});
expect(snapshot).toEqual({
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Signal House',
plot: 'Parent series plot',
posterUrl: 'https://images.example.test/series.jpg',
providerCategoryId: '8',
episode: {
title: 'The Relay',
plot: 'Episode plot',
stillUrl: 'https://images.example.test/relay.jpg',
seasonNumber: 2,
episodeNumber: 4,
},
enrichedAt: '2026-07-31T08:30:00.000Z',
});
});
it('bounds people to 30 and genres to 20 in deterministic input order', () => {
const snapshot = createMovieDownloadSnapshot({
language: 'en',
title: 'Bounded metadata',
genres: Array.from(
{ length: 25 },
(_, index) => `Genre ${index + 1}`
),
cast: people(35),
creators: people(34),
});
expect(snapshot.genres).toHaveLength(20);
expect(snapshot.genres?.[19]).toBe('Genre 20');
expect(snapshot.cast).toHaveLength(30);
expect(snapshot.cast?.[29]?.name).toBe('Person 30');
expect(snapshot.creators).toHaveLength(30);
expect(snapshot.creators?.[29]?.name).toBe('Person 30');
expect(snapshot.enrichedAt).toEqual(expect.any(String));
});
it('keeps safe artwork names while dropping stream and credential URLs', () => {
const snapshot = createMovieDownloadSnapshot({
language: 'en',
title: 'Safe artwork',
posterUrl: 'https://streams.example.test/live/123',
backdropUrl:
'https://viewer:secret@images.example.test/backdrop.jpg',
cast: [
{
name: 'Actor',
profileUrl:
'https://images.example.test/posters/secret-garden.jpg',
},
],
});
expect(snapshot.posterUrl).toBeUndefined();
expect(snapshot.backdropUrl).toBeUndefined();
expect(snapshot.cast?.[0]?.profileUrl).toBe(
'https://images.example.test/posters/secret-garden.jpg'
);
});
});
@@ -0,0 +1,292 @@
import type {
DownloadEpisodeMetadata,
DownloadMetadataPerson,
DownloadMetadataSnapshot,
} from '@iptvnator/shared/interfaces';
const MAX_PEOPLE = 30;
const MAX_GENRES = 20;
const STREAM_EXTENSION = /\.(?:m3u8?|mpd|mp4|mkv|mov|ts|webm)(?:$|[?#])/i;
const IMAGE_EXTENSION = /\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i;
const TRUSTED_IMAGE_HOSTS = new Set(['image.tmdb.org', 'picsum.photos']);
const IMAGE_PATH_HINTS = new Set([
'backdrop',
'backdrops',
'cover',
'covers',
'getimage',
'image',
'images',
'logo',
'logos',
'poster',
'posters',
'still',
'stills',
]);
const CREDENTIAL_PATH_KEYS = new Set([
'accesskey',
'accesstoken',
'apikey',
'auth',
'cookie',
'credential',
'devicemac',
'key',
'mac',
'password',
'privatekey',
'refreshtoken',
'secret',
'session',
'signature',
'token',
]);
const CREDENTIAL_QUERY_TERMS = [
'auth',
'cookie',
'credential',
'key',
'mac',
'password',
'secret',
'session',
'signature',
'token',
];
interface DownloadSnapshotFields {
language: string;
title: string;
originalTitle?: string;
plot?: string;
releaseDate?: string;
year?: number;
durationMinutes?: number;
genres?: readonly string[];
rating?: number;
status?: string;
posterUrl?: string;
backdropUrl?: string;
tmdbId?: number;
providerCategoryId?: string;
cast?: readonly DownloadMetadataPerson[];
creators?: readonly DownloadMetadataPerson[];
enrichedAt?: string;
}
export type DownloadMovieSnapshotInput = DownloadSnapshotFields;
export interface DownloadSeriesEpisodeSnapshotInput extends DownloadSnapshotFields {
episode: DownloadEpisodeMetadata;
}
function text(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function finite(value: number | undefined): number | undefined {
return value !== undefined && Number.isFinite(value) ? value : undefined;
}
function normalizedToken(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function credentialPath(pathname: string): boolean {
return pathname
.split('/')
.filter(Boolean)
.some((segment) => {
const separator = segment.search(/[=:]/);
const key = separator < 0 ? segment : segment.slice(0, separator);
return CREDENTIAL_PATH_KEYS.has(normalizedToken(key));
});
}
function positiveImageSignal(url: URL, pathname: string): boolean {
let imageAction = false;
url.searchParams.forEach((value, key) => {
imageAction ||=
['action', 'method'].includes(normalizedToken(key)) &&
['getimage', 'image'].includes(normalizedToken(value));
});
return (
IMAGE_EXTENSION.test(pathname) ||
TRUSTED_IMAGE_HOSTS.has(url.hostname) ||
pathname
.split('/')
.filter(Boolean)
.some((segment) =>
IMAGE_PATH_HINTS.has(normalizedToken(segment))
) ||
imageAction
);
}
function integer(value: number | undefined): number | undefined {
return value !== undefined && Number.isSafeInteger(value)
? value
: undefined;
}
function artworkUrl(value: string | undefined): string | undefined {
const normalized = text(value);
if (!normalized || STREAM_EXTENSION.test(normalized)) {
return undefined;
}
try {
const url = new URL(normalized);
const pathname = decodeURIComponent(url.pathname).replace(/\/+$/, '');
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.hash ||
credentialPath(pathname) ||
!positiveImageSignal(url, pathname)
) {
return undefined;
}
let credentialQuery = false;
url.searchParams.forEach((_value, key) => {
const token = normalizedToken(key);
if (CREDENTIAL_QUERY_TERMS.some((term) => token.includes(term))) {
credentialQuery = true;
}
});
if (credentialQuery) {
return undefined;
}
return normalized;
} catch {
return undefined;
}
}
function stringList(
values: readonly string[] | undefined,
limit: number
): string[] | undefined {
if (!values) {
return undefined;
}
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const entry = text(value);
if (!entry || seen.has(entry)) {
continue;
}
seen.add(entry);
normalized.push(entry);
if (normalized.length === limit) {
break;
}
}
return normalized.length > 0 ? normalized : undefined;
}
function person(value: DownloadMetadataPerson): DownloadMetadataPerson | null {
const name = text(value.name);
if (!name) {
return null;
}
const tmdbPersonId = integer(value.tmdbPersonId);
const role = text(value.role);
const profileUrl = artworkUrl(value.profileUrl);
return {
...(tmdbPersonId === undefined ? {} : { tmdbPersonId }),
name,
...(role === undefined ? {} : { role }),
...(profileUrl === undefined ? {} : { profileUrl }),
};
}
function people(
values: readonly DownloadMetadataPerson[] | undefined
): DownloadMetadataPerson[] | undefined {
if (!values) {
return undefined;
}
const normalized: DownloadMetadataPerson[] = [];
for (const value of values) {
const entry = person(value);
if (entry) {
normalized.push(entry);
}
if (normalized.length === MAX_PEOPLE) {
break;
}
}
return normalized.length > 0 ? normalized : undefined;
}
function episode(value: DownloadEpisodeMetadata): DownloadEpisodeMetadata {
const title = text(value.title);
const plot = text(value.plot);
const stillUrl = artworkUrl(value.stillUrl);
return {
...(title === undefined ? {} : { title }),
...(plot === undefined ? {} : { plot }),
...(stillUrl === undefined ? {} : { stillUrl }),
seasonNumber: value.seasonNumber,
episodeNumber: value.episodeNumber,
};
}
function snapshotFields(input: DownloadSnapshotFields) {
const originalTitle = text(input.originalTitle);
const plot = text(input.plot);
const releaseDate = text(input.releaseDate);
const year = integer(input.year);
const durationMinutes = integer(input.durationMinutes);
const genres = stringList(input.genres, MAX_GENRES);
const rating = finite(input.rating);
const status = text(input.status);
const posterUrl = artworkUrl(input.posterUrl);
const backdropUrl = artworkUrl(input.backdropUrl);
const tmdbId = integer(input.tmdbId);
const providerCategoryId = text(input.providerCategoryId);
const cast = people(input.cast);
const creators = people(input.creators);
return {
version: 1 as const,
language: input.language.trim(),
title: input.title.trim(),
...(originalTitle === undefined ? {} : { originalTitle }),
...(plot === undefined ? {} : { plot }),
...(releaseDate === undefined ? {} : { releaseDate }),
...(year === undefined ? {} : { year }),
...(durationMinutes === undefined ? {} : { durationMinutes }),
...(genres === undefined ? {} : { genres }),
...(rating === undefined ? {} : { rating }),
...(status === undefined ? {} : { status }),
...(posterUrl === undefined ? {} : { posterUrl }),
...(backdropUrl === undefined ? {} : { backdropUrl }),
...(tmdbId === undefined ? {} : { tmdbId }),
...(providerCategoryId === undefined ? {} : { providerCategoryId }),
...(cast === undefined ? {} : { cast }),
...(creators === undefined ? {} : { creators }),
enrichedAt: text(input.enrichedAt) ?? new Date().toISOString(),
};
}
export function createMovieDownloadSnapshot(
input: DownloadMovieSnapshotInput
): DownloadMetadataSnapshot {
return { ...snapshotFields(input), mediaKind: 'movie' };
}
export function createSeriesEpisodeDownloadSnapshot(
input: DownloadSeriesEpisodeSnapshotInput
): DownloadMetadataSnapshot {
return {
...snapshotFields(input),
mediaKind: 'series',
episode: episode(input.episode),
};
}