fix(downloads): harden offline metadata resolution

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

No files matched your search

@@ -0,0 +1,82 @@
import type {
DownloadMetadataSnapshot,
StalkerVodInfo,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
export 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,
};
}
export 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,
};
}
export 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,
};
}
@@ -126,6 +126,48 @@ describe('download metadata mapper', () => {
expect(mapped).not.toHaveProperty('cmd');
});
it('normalizes nested Stalker series identity and legacy editorial fallbacks', () => {
const fallback: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Downloaded episode title',
episode: {
title: 'Local episode',
seasonNumber: 2,
episodeNumber: 4,
},
};
const mapped = mapProviderToDownloadSnapshot({
source: 'stalker',
language: 'en',
mediaKind: 'series',
fallback,
provider: {
title: 'Root episode title',
screenshot_uri:
'https://images.example.test/stills/legacy-series.jpg',
genres_str: 'Drama, Mystery',
year: '2024',
info: {
name: 'Nested parent series title',
o_name: 'Original parent title',
},
},
});
expect(mapped.title).toBe('Nested parent series title');
expect(mapped.originalTitle).toBe('Original parent title');
expect(mapped.posterUrl).toBe(
'https://images.example.test/stills/legacy-series.jpg'
);
expect(mapped.genres).toEqual(['Drama', 'Mystery']);
expect(mapped.releaseDate).toBe('2024');
expect(mapped.year).toBe(2024);
expect(mapped.episode).toEqual(fallback.episode);
});
it('bounds mapped people and genres to the persisted DTO limits', () => {
const mapped = mapProviderToDownloadSnapshot({
source: 'xtream',
@@ -12,10 +12,12 @@ import {
import type {
DownloadMetadataPerson,
DownloadMetadataSnapshot,
StalkerVodInfo,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import {
movieSeed,
seriesSeed,
stalkerSeed,
} from './download-metadata-tmdb-seeds';
export type DownloadMetadataProviderSource = 'xtream' | 'stalker';
@@ -128,6 +130,7 @@ function durationMinutes(
}
export function mapProviderToDownloadSnapshot({
source,
language,
mediaKind,
fallback,
@@ -137,16 +140,36 @@ export function mapProviderToDownloadSnapshot({
const info = record(root['info']);
const editorial = [info, root];
const identity = [root, info];
const title = string(first(identity, ['title', 'name'])) ?? fallback.title;
const title =
(source === 'stalker'
? (string(info?.['name']) ??
string(info?.['o_name']) ??
string(root['o_name']) ??
string(root['name']) ??
string(root['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']));
string(
first(
editorial,
source === 'stalker'
? ['releaseDate', 'releasedate', 'year']
: ['releaseDate', 'releasedate']
)
) ?? fallback.releaseDate;
const providerGenres = strings(
first(
editorial,
source === 'stalker'
? ['genre', 'genres', 'genres_str']
: ['genre', 'genres']
)
);
const providerCast = people(
first(editorial, ['tmdb_cast', 'actors', 'cast'])
);
@@ -155,14 +178,19 @@ export function mapProviderToDownloadSnapshot({
);
const posterUrl =
string(
first(editorial, [
'movie_image',
'cover_big',
'cover',
'poster_url',
'posterUrl',
'logo',
])
source === 'stalker'
? (info?.['movie_image'] ??
root['cover'] ??
root['screenshot_uri'] ??
root['logo'])
: first(editorial, [
'movie_image',
'cover_big',
'cover',
'poster_url',
'posterUrl',
'logo',
])
) ?? fallback.posterUrl;
const backdropUrl =
backdrop(
@@ -213,78 +241,6 @@ export function mapProviderToDownloadSnapshot({
});
}
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,
@@ -148,6 +148,7 @@ describe('DownloadOfflineMetadataService', () => {
let downloads: DownloadsFake;
let playlists: PlaylistsFake;
let tmdb: TmdbFake;
let currentLanguage: string;
beforeEach(() => {
db = {
@@ -165,6 +166,7 @@ describe('DownloadOfflineMetadataService', () => {
enrichMovie: jest.fn().mockResolvedValue(null),
enrichTv: jest.fn().mockResolvedValue(null),
};
currentLanguage = 'en';
TestBed.configureTestingModule({
providers: [
@@ -175,7 +177,7 @@ describe('DownloadOfflineMetadataService', () => {
{ provide: TmdbEnrichmentService, useValue: tmdb },
{
provide: SettingsStore,
useValue: { language: () => 'en' },
useValue: { language: () => currentLanguage },
},
],
});
@@ -330,6 +332,79 @@ describe('DownloadOfflineMetadataService', () => {
);
});
it('keeps Stalker merge precedence when recent lookup fails', async () => {
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
playlists.getPortalRecentlyViewed.mockReturnValue(
throwError(() => new Error('recent unavailable'))
);
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockResolvedValue({
id: 603,
overview: 'Localized TMDB plot',
vote_average: 7.2,
vote_count: 100,
});
const local = snapshot({ plot: undefined, rating: 9.1 });
const resolved = await service.resolve(movieDetail({}, local));
expect(resolved.plot).toBe('Localized TMDB plot');
expect(resolved.rating).toBe(9.1);
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
});
it('labels a provider-missing successful TMDB refresh with the current language', async () => {
currentLanguage = 'de';
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockResolvedValue({
id: 603,
overview: 'Lokalisierte TMDB-Beschreibung',
});
const local = snapshot({ language: 'en' });
const resolved = await service.resolve(movieDetail({}, local));
expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID);
expect(resolved.language).toBe('de');
expect(downloads.updateMetadata).toHaveBeenCalledWith(
17,
expect.objectContaining({ language: 'de' })
);
});
it('does not relabel a mismatched snapshot when no refresh succeeds', async () => {
currentLanguage = 'de';
const local = snapshot({ language: 'en' });
await expect(service.resolve(movieDetail({}, local))).resolves.toEqual(
local
);
expect(downloads.updateMetadata).not.toHaveBeenCalled();
});
it('omits credential-bearing provider artwork before persistence', async () => {
db.getContentByXtreamId.mockResolvedValue({
id: 1,
category_id: 12,
title: 'Recovered provider title',
rating: '7',
added: '0',
poster_url:
'https://images.example.test/authorization/value/poster.jpg',
xtream_id: 41,
type: 'movie',
});
const local = snapshot({ plot: undefined, posterUrl: undefined });
const resolved = await service.resolve(movieDetail({}, local));
expect(resolved.posterUrl).toBeUndefined();
expect(downloads.updateMetadata).toHaveBeenCalledWith(
17,
expect.not.objectContaining({ posterUrl: expect.anything() })
);
});
it('persists one materially changed successful merge', async () => {
tmdb.isEnabled.mockReturnValue(true);
tmdb.enrichMovie.mockResolvedValue(tmdbMovie);
@@ -209,7 +209,11 @@ export class DownloadOfflineMetadataService {
resolved = local;
}
}
resolved = await this.enrichWithTmdb(resolved, context.source);
resolved = await this.enrichWithTmdb(
resolved,
context.source,
language
);
if (materiallyEqual(local, resolved)) {
return local;
@@ -238,15 +242,21 @@ export class DownloadOfflineMetadataService {
detail: DownloadOfflineDetail
): Promise<ProviderContext> {
const item = representative(detail);
let source: DownloadMetadataProviderSource;
try {
const playlist = await firstValueFrom(
this.playlists.getPlaylistById(item.playlistId)
);
const source: DownloadMetadataProviderSource =
source =
playlist?.portalUrl && playlist.macAddress
? 'stalker'
: 'xtream';
if (source === 'stalker') {
} catch {
return { source: 'xtream' };
}
if (source === 'stalker') {
try {
const recent = await firstValueFrom(
this.playlists.getPortalRecentlyViewed(item.playlistId)
);
@@ -254,7 +264,12 @@ export class DownloadOfflineMetadataService {
source,
provider: matchingRecent(recent, targetId(detail)),
};
} catch {
return { source };
}
}
try {
return {
source,
provider: await this.db.getContentByXtreamId(
@@ -269,7 +284,8 @@ export class DownloadOfflineMetadataService {
private async enrichWithTmdb(
snapshot: DownloadMetadataSnapshot,
source: DownloadMetadataProviderSource
source: DownloadMetadataProviderSource,
language: string
): Promise<DownloadMetadataSnapshot> {
try {
if (!this.tmdb.isEnabled()) {
@@ -286,7 +302,13 @@ export class DownloadOfflineMetadataService {
? await this.tmdb.enrichMovie(query)
: await this.tmdb.enrichTv(query);
return details
? mergeSnapshotWithTmdb(snapshot, details, source)
? mergeSnapshotWithTmdb(
snapshot.language === language
? snapshot
: { ...snapshot, language },
details,
source
)
: snapshot;
} catch {
return snapshot;
@@ -127,4 +127,77 @@ describe('download metadata snapshot factories', () => {
'https://images.example.test/posters/secret-garden.jpg'
);
});
it.each([
'accesskey',
'accesstoken',
'apikey',
'auth',
'authentication',
'authorization',
'cookie',
'credential',
'credentials',
'devicemac',
'key',
'mac',
'macaddress',
'oauth',
'password',
'passwd',
'privatekey',
'refreshtoken',
'secret',
'secretkey',
'session',
'sig',
'signature',
'signingkey',
'token',
])('drops artwork containing backend credential path alias %s', (alias) => {
const snapshot = createMovieDownloadSnapshot({
language: 'en',
title: 'Unsafe artwork',
posterUrl: `https://images.example.test/${alias}/value/poster.jpg`,
});
expect(snapshot.posterUrl).toBeUndefined();
});
it.each([
'authentication',
'authorization',
'cookie',
'credential',
'macaddress',
'oauth',
'password',
'passwd',
'secret',
'session',
'signature',
'token',
'auth',
'key',
'mac',
'sig',
'proxy-auth',
'device-mac',
'access-key',
'api-key',
'private-key',
'secret-key',
'signing-key',
])(
'drops artwork containing backend credential query alias %s',
(alias) => {
const snapshot = createMovieDownloadSnapshot({
language: 'en',
title: 'Unsafe artwork',
posterUrl: `https://images.example.test/poster.jpg?${alias}=value`,
});
expect(snapshot.posterUrl).toBeUndefined();
}
);
});
@@ -24,31 +24,44 @@ const IMAGE_PATH_HINTS = new Set([
'still',
'stills',
]);
// Keep these aliases synchronized with the canonical Electron validator in
// apps/electron-backend/src/app/events/database/download-artwork-url.ts.
const CREDENTIAL_PATH_KEYS = new Set([
'accesskey',
'accesstoken',
'apikey',
'auth',
'authentication',
'authorization',
'cookie',
'credential',
'credentials',
'devicemac',
'key',
'mac',
'macaddress',
'oauth',
'password',
'passwd',
'privatekey',
'refreshtoken',
'secret',
'secretkey',
'session',
'sig',
'signature',
'signingkey',
'token',
]);
const CREDENTIAL_QUERY_TERMS = [
'auth',
'authentication',
'authorization',
'cookie',
'credential',
'key',
'mac',
'macaddress',
'oauth',
'password',
'passwd',
'secret',
'session',
'signature',
@@ -94,6 +107,19 @@ function normalizedToken(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function isCredentialQueryKey(key: string): boolean {
const token = normalizedToken(key);
return (
['auth', 'key', 'mac', 'sig'].includes(token) ||
token.endsWith('auth') ||
token.endsWith('mac') ||
CREDENTIAL_QUERY_TERMS.some((term) => token.includes(term)) ||
['accesskey', 'apikey', 'privatekey', 'secretkey', 'signingkey'].some(
(term) => token.includes(term)
)
);
}
function credentialPath(pathname: string): boolean {
return pathname
.split('/')
@@ -150,14 +176,13 @@ function artworkUrl(value: string | undefined): string | undefined {
) {
return undefined;
}
let credentialQuery = false;
let hasCredentialQuery = false;
url.searchParams.forEach((_value, key) => {
const token = normalizedToken(key);
if (CREDENTIAL_QUERY_TERMS.some((term) => token.includes(term))) {
credentialQuery = true;
if (isCredentialQueryKey(key)) {
hasCredentialQuery = true;
}
});
if (credentialQuery) {
if (hasCredentialQuery) {
return undefined;
}
return normalized;