diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts new file mode 100644 index 000000000..d25cbeb80 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts @@ -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, + }; +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts index 21957bab0..d1a3bad6e 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts @@ -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', diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts index 91f65783f..9f25692fc 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts @@ -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, diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts index 933ab310e..b51a29808 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts @@ -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); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts index 3fc39880b..e1b3588e9 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts @@ -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 { 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 { 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; diff --git a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts index 059c3bd93..a3f3b27be 100644 --- a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts @@ -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(); + } + ); }); diff --git a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts index a02657298..0baf08228 100644 --- a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts @@ -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;