diff --git a/apps/electron-backend/src/app/events/database/download-artwork-url.ts b/apps/electron-backend/src/app/events/database/download-artwork-url.ts index 4ae636492..a61303b34 100644 --- a/apps/electron-backend/src/app/events/database/download-artwork-url.ts +++ b/apps/electron-backend/src/app/events/database/download-artwork-url.ts @@ -31,6 +31,7 @@ const CREDENTIAL_QUERY_TERMS = [ 'session', 'signature', 'token', + 'username', ]; const CREDENTIAL_PATH_KEYS = new Set([ @@ -59,6 +60,7 @@ const CREDENTIAL_PATH_KEYS = new Set([ 'signature', 'signingkey', 'token', + 'username', ]); function invalidArtworkUrl(): never { diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts index f2790d3b8..d3ea42be6 100644 --- a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts @@ -4,6 +4,7 @@ import { DOWNLOAD_METADATA_MAX_BYTES, encodeDownloadMetadataSnapshot, } from './download-metadata-snapshot'; +import { normalizeDownloadArtworkUrl } from './download-artwork-url'; const validSnapshot: DownloadMetadataSnapshot = { version: 1, @@ -413,6 +414,15 @@ describe('download metadata snapshot', () => { ).toThrow('Invalid download metadata snapshot'); }); + it.each([ + 'https://images.example.test/username/account/poster.jpg', + 'https://images.example.test/poster.jpg?username=account', + ])('rejects username-bearing artwork at the canonical validator', (url) => { + expect(() => normalizeDownloadArtworkUrl(url)).toThrow( + 'Invalid download metadata snapshot' + ); + }); + it('decodes malformed persisted JSON as absent metadata', () => { expect(decodeDownloadMetadataSnapshot('{not-json')).toBeUndefined(); }); diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index a1c1069f5..52474eb52 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -117,11 +117,18 @@ variants, contextual buttons, and theme-aware styling. - Managed MPV/VLC state remains authoritative: Opening disables conflicting playback actions, and Stop closes the matched external session before any local or provider choice can run. -- Detail metadata still comes from the owning Xtream or Stalker provider. - Download rows do not cache a standalone offline detail payload in this MVP, - so a provider that is unavailable may prevent the detail shell from loading; - the explicit Play action on the download card remains available for the - local file. +- Download rows carry a versioned, display-only metadata snapshot, so the + offline detail resolver can work without the owning Xtream or Stalker + provider. Sparse, stale, or wrong-language snapshots are refreshed from the + typed provider catalog and optional TMDB enrichment. A successful refresh + advances its freshness timestamp; transient TMDB failures may retain improved + provider fields without advancing the timestamp or language, so a later visit + retries. Concurrent movie and grouped-series refreshes are request-ordered so + only the newest generation may persist. +- Snapshot artwork accepts only safe HTTP(S) image URLs. Renderer normalization + and main-process persistence independently reject credential-shaped path and + query keys, including `username`, so provider credentials cannot be cached in + offline metadata. ## Global API surface @@ -135,11 +142,12 @@ variants, contextual buttons, and theme-aware styling. `/workspace/xtreams/:id/downloads` and `/workspace/stalker/:id/downloads`. All three routes render the same global store; the `:id` routes derive a view-only playlist scope. +- Each scope exposes an offline detail child at `downloads/:downloadId`. - Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page. ## Queuing, persistence, and UX notes -- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations. +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, the offline-detail metadata snapshot, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; additive columns are applied through the idempotent column migrations. - On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`. - Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation. - A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. @@ -159,6 +167,5 @@ variants, contextual buttons, and theme-aware styling. Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work -might cover an offline metadata snapshot for provider-independent details, -recordings, queue reordering, bulk pause/cancel actions, disk-free space -telemetry, or playback analytics. +might cover recordings, queue reordering, bulk pause/cancel actions, disk-free +space telemetry, or playback analytics. diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts new file mode 100644 index 000000000..55a50e752 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts @@ -0,0 +1,144 @@ +import { TMDB_DETAILS_CACHE_TTL_MS } from '@iptvnator/services'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; + +export const TMDB_REFRESH_STATE = { + DISABLED: 'disabled', + FAILED: 'failed', + SUCCEEDED: 'succeeded', +} as const; + +export type TmdbRefreshState = + (typeof TMDB_REFRESH_STATE)[keyof typeof TMDB_REFRESH_STATE]; + +export interface TmdbRefreshResult { + snapshot: DownloadMetadataSnapshot; + state: TmdbRefreshState; +} + +interface FinalizeMetadataRefreshInput { + language: string; + local: DownloadMetadataSnapshot; + providerSucceeded: boolean; + snapshot: DownloadMetadataSnapshot; + tmdbState: TmdbRefreshState; +} + +export interface FinalizedMetadataRefresh { + shouldPersist: boolean; + snapshot: DownloadMetadataSnapshot; +} + +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 + ); +} + +export function needsMetadataRefresh( + snapshot: DownloadMetadataSnapshot | undefined, + language: string +): boolean { + return ( + !snapshot || + snapshot.language !== language || + isSparse(snapshot) || + !isFresh(snapshot) + ); +} + +export function withoutMetadataFreshness( + snapshot: DownloadMetadataSnapshot +): DownloadMetadataSnapshot { + const pending = { ...snapshot }; + delete pending.enrichedAt; + return pending; +} + +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; + }, {}); +} + +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 preserveAttemptIdentity( + snapshot: DownloadMetadataSnapshot, + local: DownloadMetadataSnapshot +): DownloadMetadataSnapshot { + const preserved = { ...snapshot, language: local.language }; + return local.enrichedAt === undefined + ? withoutMetadataFreshness(preserved) + : { ...preserved, enrichedAt: local.enrichedAt }; +} + +export function finalizeMetadataRefresh({ + language, + local, + providerSucceeded, + snapshot, + tmdbState, +}: FinalizeMetadataRefreshInput): FinalizedMetadataRefresh { + const refreshSucceeded = + tmdbState === TMDB_REFRESH_STATE.SUCCEEDED || + (tmdbState === TMDB_REFRESH_STATE.DISABLED && providerSucceeded); + const finalized = refreshSucceeded + ? { ...snapshot, language, enrichedAt: new Date().toISOString() } + : preserveAttemptIdentity(snapshot, local); + const shouldPersist = + refreshSucceeded || !materiallyEqual(local, finalized); + return { + shouldPersist, + snapshot: shouldPersist ? finalized : local, + }; +} + +export class LatestMetadataWriteGuard { + private generation = 0; + private readonly latestByKey = new Map(); + + begin(key: string): number { + const generation = ++this.generation; + this.latestByKey.set(key, generation); + return generation; + } + + isLatest(key: string, generation: number): boolean { + return this.latestByKey.get(key) === generation; + } + + finish(key: string, generation: number): void { + if (this.isLatest(key, generation)) { + this.latestByKey.delete(key); + } + } +} 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 index 5f2a28e14..f526a444e 100644 --- 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 @@ -1,10 +1,43 @@ import type { DownloadMetadataSnapshot, StalkerVodInfo, + TmdbEnrichedCastMember, XtreamSerieInfo, XtreamVodInfo, } from '@iptvnator/shared/interfaces'; +const MAX_PEOPLE = 30; + +interface SeedPeopleFields { + tmdb_cast?: TmdbEnrichedCastMember[]; + tmdb_directors?: TmdbEnrichedCastMember[]; +} + +function seedPeople( + people: DownloadMetadataSnapshot['cast'] +): TmdbEnrichedCastMember[] | undefined { + const seeded = people?.slice(0, MAX_PEOPLE).map((person) => ({ + name: person.name, + ...(person.role === undefined ? {} : { character: person.role }), + profileUrl: person.profileUrl ?? null, + ...(person.tmdbPersonId === undefined + ? {} + : { tmdbPersonId: person.tmdbPersonId }), + })); + return seeded && seeded.length > 0 ? seeded : undefined; +} + +function seedPeopleFields( + snapshot: DownloadMetadataSnapshot +): SeedPeopleFields { + const cast = seedPeople(snapshot.cast); + const directors = seedPeople(snapshot.creators); + return { + ...(cast === undefined ? {} : { tmdb_cast: cast }), + ...(directors === undefined ? {} : { tmdb_directors: directors }), + }; +} + export function movieSeed(snapshot: DownloadMetadataSnapshot): XtreamVodInfo { const cast = snapshot.cast?.map(({ name }) => name).join(', ') ?? ''; return { @@ -34,6 +67,7 @@ export function movieSeed(snapshot: DownloadMetadataSnapshot): XtreamVodInfo { audio: [], bitrate: 0, rating: snapshot.rating ?? 0, + ...seedPeopleFields(snapshot), }; } @@ -59,6 +93,7 @@ export function seriesSeed( : String(snapshot.durationMinutes), category_id: snapshot.providerCategoryId ?? '', tmdb_id: snapshot.tmdbId, + ...seedPeopleFields(snapshot), }; } @@ -80,6 +115,7 @@ export function stalkerSeed(snapshot: DownloadMetadataSnapshot): { snapshot.rating === undefined ? '' : String(snapshot.rating), rating_kinopoisk: '', tmdb_id: snapshot.tmdbId, + ...seedPeopleFields(snapshot), }, }; } 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 a30adf1c1..13d34ef3f 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 @@ -98,6 +98,59 @@ describe('download metadata mapper', () => { expect(merged.originalTitle).toBe('Provider original'); }); + it.each([ + { + label: 'Xtream movie', + source: 'xtream' as const, + mediaKind: 'movie' as const, + details: { id: 603 } satisfies TmdbMovieDetails, + }, + { + label: 'Xtream series', + source: 'xtream' as const, + mediaKind: 'series' as const, + details: { id: 900 } satisfies TmdbTvDetails, + }, + { + label: 'Stalker movie', + source: 'stalker' as const, + mediaKind: 'movie' as const, + details: { id: 603 } satisfies TmdbMovieDetails, + }, + ])('preserves rich people for sparse $label credits', (scenario) => { + const richActor = { + tmdbPersonId: 7, + name: 'Provider actor', + role: 'Lead', + profileUrl: 'https://image.tmdb.org/t/p/w185/provider-actor.jpg', + }; + const richCreator = { + tmdbPersonId: 11, + name: 'Provider creator', + role: 'Director', + profileUrl: 'https://image.tmdb.org/t/p/w185/provider-creator.jpg', + }; + const merged = mergeSnapshotWithTmdb( + { + ...providerSnapshot, + mediaKind: scenario.mediaKind, + cast: [ + richActor, + ...Array.from({ length: 30 }, (_, index) => ({ + name: `Provider actor ${index + 2}`, + })), + ], + creators: [richCreator], + }, + scenario.details, + scenario.source + ); + + expect(merged.cast).toHaveLength(30); + expect(merged.cast?.[0]).toEqual(richActor); + expect(merged.creators?.[0]).toEqual(richCreator); + }); + it('maps a Stalker recent payload without copying credentials or playback fields', () => { const mapped = mapProviderToDownloadSnapshot({ source: 'stalker', @@ -210,6 +263,26 @@ describe('download metadata mapper', () => { expect(mapped.episode).toEqual(fallback.episode); }); + it('falls back from blank nested Stalker artwork to the root cover', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + cover: 'https://images.example.test/covers/root-cover.jpg', + info: { + name: 'Nested title', + movie_image: '', + }, + }, + }); + + expect(mapped.posterUrl).toBe( + 'https://images.example.test/covers/root-cover.jpg' + ); + }); + 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 63beb6a2c..60e9c4f02 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 @@ -181,13 +181,13 @@ export function mapProviderToDownloadSnapshot({ first(editorial, ['tmdb_directors', 'director', 'creators']) ); const posterUrl = - string( - source === 'stalker' - ? (info?.['movie_image'] ?? - root['cover'] ?? - root['screenshot_uri'] ?? - root['logo']) - : first(editorial, [ + (source === 'stalker' + ? (string(info?.['movie_image']) ?? + string(root['cover']) ?? + string(root['screenshot_uri']) ?? + string(root['logo'])) + : string( + first(editorial, [ 'movie_image', 'cover_big', 'cover', @@ -195,7 +195,7 @@ export function mapProviderToDownloadSnapshot({ 'posterUrl', 'logo', ]) - ) ?? fallback.posterUrl; + )) ?? fallback.posterUrl; const backdropUrl = backdrop( first(editorial, ['tmdb_backdrop', 'backdrop_path', 'backdrop_url']) 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 b51a29808..1b44b76b6 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 @@ -8,6 +8,7 @@ import { TmdbEnrichmentService, type TmdbMovieDetails, type TmdbTvDetails, + type XtreamContent, } from '@iptvnator/services'; import type { DownloadMetadataSnapshot, @@ -45,6 +46,39 @@ type TmdbFake = jest.Mocked< Pick >; +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function providerContent( + title: string, + xtreamId: number, + type: 'movie' | 'series' = 'movie' +): XtreamContent { + return { + id: xtreamId, + category_id: 12, + title, + rating: '7', + added: '0', + poster_url: 'https://images.example.test/provider.jpg', + xtream_id: xtreamId, + type, + }; +} + function download(overrides: Partial = {}): DownloadItem { return { id: 17, @@ -97,7 +131,8 @@ function movieDetail( } function seriesDetail( - seriesSnapshot: DownloadMetadataSnapshot + seriesSnapshot: DownloadMetadataSnapshot, + itemOverrides: Partial = {} ): DownloadOfflineDetail { const representative = download({ contentType: 'episode', @@ -106,6 +141,7 @@ function seriesDetail( seasonNumber: 2, episodeNumber: 7, title: 'Series - S02E07 - Relay', + ...itemOverrides, metadataSnapshot: seriesSnapshot, }); return { @@ -238,7 +274,11 @@ describe('DownloadOfflineMetadataService', () => { const resolved = await service.resolve(movieDetail()); - expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID); + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 41, + PLAYLIST_ID, + 'movie' + ); expect(resolved.title).toBe('Xtream provider title'); expect(resolved.providerCategoryId).toBe('12'); expect(resolved.posterUrl).toBe( @@ -247,6 +287,26 @@ describe('DownloadOfflineMetadataService', () => { expect(playlists.getPortalRecentlyViewed).not.toHaveBeenCalled(); }); + it('uses the series discriminator for an offline episode lookup', async () => { + const local = snapshot({ + mediaKind: 'series', + plot: undefined, + episode: { + title: 'Downloaded episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + + await service.resolve(seriesDetail(local)); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 93, + PLAYLIST_ID, + 'series' + ); + }); + it.each([ ['movie', 'enrichMovie'] as const, ['series', 'enrichTv'] as const, @@ -364,7 +424,11 @@ describe('DownloadOfflineMetadataService', () => { const resolved = await service.resolve(movieDetail({}, local)); - expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID); + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 41, + PLAYLIST_ID, + 'movie' + ); expect(resolved.language).toBe('de'); expect(downloads.updateMetadata).toHaveBeenCalledWith( 17, @@ -416,17 +480,109 @@ describe('DownloadOfflineMetadataService', () => { 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', + it('persists a materially identical successful TMDB refresh once', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue({ id: 603 }); + const local = snapshot({ + tmdbId: 603, + rating: 0, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + + expect(refreshed.enrichedAt).toBe(NOW); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + refreshed + ); + + await service.resolve(movieDetail({}, refreshed)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('persists provider metadata without marking a failed TMDB refresh current', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + currentLanguage = 'de'; + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue( + new Error('TMDB temporarily unavailable') + ); + db.getContentByXtreamId.mockResolvedValue( + providerContent('Recovered provider title', 41) + ); + const local = snapshot({ + language: 'en', + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(resolved.title).toBe('Recovered provider title'); + expect(resolved.language).toBe('en'); + expect(resolved.enrichedAt).toBe(local.enrichedAt); + expect(downloads.updateMetadata).toHaveBeenCalledWith(17, resolved); + + await service.resolve(movieDetail({}, resolved)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('marks a successful provider-only refresh fresh when TMDB is disabled', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + db.getContentByXtreamId.mockResolvedValue({ + ...providerContent('Local title', 41), + category_id: 4, + rating: '', + poster_url: 'https://images.example.test/local.jpg', + }); + const local = snapshot({ + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + + expect(refreshed.enrichedAt).toBe(NOW); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + refreshed + ); + + await service.resolve(movieDetail({}, refreshed)); + + expect(db.getContentByXtreamId).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('retains stale language and freshness when provider and TMDB fail', async () => { + currentLanguage = 'de'; + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue(new Error('TMDB unavailable')); + db.getContentByXtreamId.mockRejectedValue( + new Error('Provider unavailable') + ); + const local = snapshot({ + language: 'en', + enrichedAt: '2020-01-01T00:00:00.000Z', }); await expect(service.resolve(movieDetail({}, local))).resolves.toEqual( @@ -494,6 +650,77 @@ describe('DownloadOfflineMetadataService', () => { expect(resolved).not.toHaveProperty('episode'); }); + it('does not persist an older movie resolve after a newer generation', async () => { + const olderProvider = deferred(); + const newerProvider = deferred(); + db.getContentByXtreamId + .mockImplementationOnce(() => olderProvider.promise) + .mockImplementationOnce(() => newerProvider.promise); + const local = snapshot({ enrichedAt: '2020-01-01T00:00:00.000Z' }); + const detail = movieDetail({}, local); + + const olderResolve = service.resolve(detail); + const newerResolve = service.resolve(detail); + newerProvider.resolve(providerContent('Newer provider title', 41)); + const newerResult = await newerResolve; + olderProvider.resolve(providerContent('Older provider title', 41)); + const olderResult = await olderResolve; + + expect(newerResult.title).toBe('Newer provider title'); + expect(olderResult.title).toBe('Older provider title'); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith(17, newerResult); + }); + + it('guards persistence across different episodes in one series group', async () => { + const olderProvider = deferred(); + const newerProvider = deferred(); + db.getContentByXtreamId + .mockImplementationOnce(() => olderProvider.promise) + .mockImplementationOnce(() => newerProvider.promise); + const olderSnapshot = snapshot({ + mediaKind: 'series', + title: 'Local series', + enrichedAt: '2020-01-01T00:00:00.000Z', + episode: { + title: 'Older episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + const newerSnapshot = { + ...olderSnapshot, + episode: { + title: 'Newer episode', + seasonNumber: 2, + episodeNumber: 8, + }, + }; + const olderDetail = seriesDetail(olderSnapshot); + const newerDetail = seriesDetail(newerSnapshot, { + id: 18, + xtreamId: 502, + episodeNumber: 8, + title: 'Series - S02E08 - Newer episode', + }); + + const olderResolve = service.resolve(olderDetail); + const newerResolve = service.resolve(newerDetail); + newerProvider.resolve( + providerContent('Newer series title', 93, 'series') + ); + const newerResult = await newerResolve; + olderProvider.resolve( + providerContent('Older series title', 93, 'series') + ); + const olderResult = await olderResolve; + + expect(newerResult.title).toBe('Newer series title'); + expect(olderResult.title).toBe('Older series title'); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith(18, newerResult); + }); + it('returns the best local metadata when provider lookup fails', async () => { playlists.getPlaylistById.mockReturnValue( throwError(() => new Error('playlist unavailable')) 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 e1b3588e9..5bd169a05 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 @@ -5,7 +5,6 @@ import { DownloadsService, PlaylistsService, SettingsStore, - TMDB_DETAILS_CACHE_TTL_MS, TmdbEnrichmentService, type DownloadItem, } from '@iptvnator/services'; @@ -22,6 +21,14 @@ import { mergeSnapshotWithTmdb, type DownloadMetadataProviderSource, } from './download-metadata.mapper'; +import { + finalizeMetadataRefresh, + LatestMetadataWriteGuard, + needsMetadataRefresh, + TMDB_REFRESH_STATE, + type TmdbRefreshResult, + withoutMetadataFreshness, +} from './download-metadata-refresh'; import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; interface ProviderContext { @@ -42,6 +49,18 @@ function targetId(detail: DownloadOfflineDetail): number { : item.xtreamId; } +function metadataWriteKey(detail: DownloadOfflineDetail): string { + const item = representative(detail); + if ( + detail.kind === 'series' && + Number.isSafeInteger(item.seriesXtreamId) && + (item.seriesXtreamId ?? 0) > 0 + ) { + return `series:${item.playlistId}:${item.seriesXtreamId}`; + } + return `download:${item.id}`; +} + function normalizedPortalId(value: unknown): string { return String(value ?? '') .trim() @@ -75,11 +94,13 @@ function fallbackSnapshot( ): DownloadMetadataSnapshot { const item = representative(detail); if (detail.kind === 'movie') { - return createMovieDownloadSnapshot({ - language, - title: item.title, - posterUrl: item.posterUrl, - }); + return withoutMetadataFreshness( + createMovieDownloadSnapshot({ + language, + title: item.title, + posterUrl: item.posterUrl, + }) + ); } const parent = { @@ -95,20 +116,22 @@ function fallbackSnapshot( !Number.isSafeInteger(episodeNumber) || (episodeNumber ?? -1) < 0 ) { - return { + return withoutMetadataFreshness({ ...createMovieDownloadSnapshot(parent), mediaKind: 'series', - }; + }); } - return createSeriesEpisodeDownloadSnapshot({ - ...parent, - episode: { - title: episodeTitle(item), - seasonNumber: seasonNumber as number, - episodeNumber: episodeNumber as number, - }, - }); + return withoutMetadataFreshness( + createSeriesEpisodeDownloadSnapshot({ + ...parent, + episode: { + title: episodeTitle(item), + seasonNumber: seasonNumber as number, + episodeNumber: episodeNumber as number, + }, + }) + ); } function existingSnapshot( @@ -121,61 +144,6 @@ function existingSnapshot( : 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); @@ -183,19 +151,35 @@ export class DownloadOfflineMetadataService { private readonly playlists = inject(PlaylistsService); private readonly settings = inject(SettingsStore); private readonly tmdb = inject(TmdbEnrichmentService); + private readonly writeGuard = new LatestMetadataWriteGuard(); async resolve( detail: DownloadOfflineDetail + ): Promise { + const writeKey = metadataWriteKey(detail); + const generation = this.writeGuard.begin(writeKey); + try { + return await this.resolveGeneration(detail, writeKey, generation); + } finally { + this.writeGuard.finish(writeKey, generation); + } + } + + private async resolveGeneration( + detail: DownloadOfflineDetail, + writeKey: string, + generation: number ): Promise { const language = this.currentLanguage(); const persisted = existingSnapshot(detail); const local = persisted ?? fallbackSnapshot(detail, language); - if (!needsRefresh(persisted, language)) { + if (!needsMetadataRefresh(persisted, language)) { return local; } const context = await this.loadProviderContext(detail); let resolved = local; + let providerSucceeded = false; if (context.provider) { try { resolved = mapProviderToDownloadSnapshot({ @@ -205,29 +189,38 @@ export class DownloadOfflineMetadataService { fallback: local, provider: context.provider, }); + providerSucceeded = true; } catch { resolved = local; } } - resolved = await this.enrichWithTmdb( + const tmdb = await this.enrichWithTmdb( resolved, context.source, language ); - - if (materiallyEqual(local, resolved)) { - return local; + const finalized = finalizeMetadataRefresh({ + language, + local, + providerSucceeded, + snapshot: tmdb.snapshot, + tmdbState: tmdb.state, + }); + if (!finalized.shouldPersist) { + return finalized.snapshot; } - try { - await this.downloads.updateMetadata( - representative(detail).id, - resolved - ); - } catch { - // Enriched metadata remains useful for this view without backfill. + if (this.writeGuard.isLatest(writeKey, generation)) { + try { + await this.downloads.updateMetadata( + representative(detail).id, + finalized.snapshot + ); + } catch { + // Enriched metadata remains useful without backfill. + } } - return resolved; + return finalized.snapshot; } private currentLanguage(): string { @@ -274,7 +267,8 @@ export class DownloadOfflineMetadataService { source, provider: await this.db.getContentByXtreamId( targetId(detail), - item.playlistId + item.playlistId, + detail.kind === 'movie' ? 'movie' : 'series' ), }; } catch { @@ -286,10 +280,13 @@ export class DownloadOfflineMetadataService { snapshot: DownloadMetadataSnapshot, source: DownloadMetadataProviderSource, language: string - ): Promise { + ): Promise { try { if (!this.tmdb.isEnabled()) { - return snapshot; + return { + snapshot, + state: TMDB_REFRESH_STATE.DISABLED, + }; } const query = { title: snapshot.title, @@ -301,17 +298,21 @@ export class DownloadOfflineMetadataService { snapshot.mediaKind === 'movie' ? await this.tmdb.enrichMovie(query) : await this.tmdb.enrichTv(query); - return details - ? mergeSnapshotWithTmdb( - snapshot.language === language - ? snapshot - : { ...snapshot, language }, - details, - source - ) - : snapshot; + if (!details) { + return { snapshot, state: TMDB_REFRESH_STATE.FAILED }; + } + return { + snapshot: mergeSnapshotWithTmdb( + snapshot.language === language + ? snapshot + : { ...snapshot, language }, + details, + source + ), + state: TMDB_REFRESH_STATE.SUCCEEDED, + }; } catch { - return snapshot; + return { snapshot, state: TMDB_REFRESH_STATE.FAILED }; } } } 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 a3f3b27be..fcbf2cf59 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 @@ -154,6 +154,7 @@ describe('download metadata snapshot factories', () => { 'signature', 'signingkey', 'token', + 'username', ])('drops artwork containing backend credential path alias %s', (alias) => { const snapshot = createMovieDownloadSnapshot({ language: 'en', @@ -188,6 +189,7 @@ describe('download metadata snapshot factories', () => { 'private-key', 'secret-key', 'signing-key', + 'username', ])( 'drops artwork containing backend credential query alias %s', (alias) => { 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 0baf08228..1a6f53b2e 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 @@ -52,6 +52,7 @@ const CREDENTIAL_PATH_KEYS = new Set([ 'signature', 'signingkey', 'token', + 'username', ]); const CREDENTIAL_QUERY_TERMS = [ 'authentication', @@ -66,6 +67,7 @@ const CREDENTIAL_QUERY_TERMS = [ 'session', 'signature', 'token', + 'username', ]; interface DownloadSnapshotFields {