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 new file mode 100644 index 000000000..21957bab0 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts @@ -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; + + 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'); + }); +}); 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 new file mode 100644 index 000000000..91f65783f --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts @@ -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; + +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, + }); +} 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 new file mode 100644 index 000000000..933ab310e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts @@ -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>; +type DownloadsFake = jest.Mocked>; +type PlaylistsFake = jest.Mocked< + Pick +>; +type TmdbFake = jest.Mocked< + Pick +>; + +function download(overrides: Partial = {}): 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 { + 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 = {}, + 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); + 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); + }); +}); 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 new file mode 100644 index 000000000..3fc39880b --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts @@ -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>((result, key) => { + result[key] = sortedValue((value as Record)[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 { + 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 { + 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 { + 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; + } + } +} diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index f661cf4d9..a1e6be3cd 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; 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 new file mode 100644 index 000000000..059c3bd93 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts @@ -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[0] & + Record); + + 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' + ); + }); +}); 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 new file mode 100644 index 000000000..a02657298 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts @@ -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(); + 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), + }; +}