mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(downloads): enrich offline detail metadata
This commit is contained in:
1 parent
eff978d626
commit
e7467788ed
7 files changed
+1699
No files matched your search
+211
@@ -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,
|
||||
});
|
||||
}
|
||||
+454
@@ -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);
|
||||
});
|
||||
});
|
||||
+295
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user