fix(downloads): stabilize offline metadata refresh

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 00a12d8b2a
commit 082c90caa1
11 files changed
+632 -128

No files matched your search

@@ -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 {
@@ -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();
});
+16 -9
View File
@@ -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.
@@ -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<Record<string, unknown>>((result, key) => {
result[key] = sortedValue((value as Record<string, unknown>)[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<string, number>();
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);
}
}
}
@@ -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),
},
};
}
@@ -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',
@@ -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'])
@@ -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<TmdbEnrichmentService, 'isEnabled' | 'enrichMovie' | 'enrichTv'>
>;
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(reason?: unknown): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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> = {}): DownloadItem {
return {
id: 17,
@@ -97,7 +131,8 @@ function movieDetail(
}
function seriesDetail(
seriesSnapshot: DownloadMetadataSnapshot
seriesSnapshot: DownloadMetadataSnapshot,
itemOverrides: Partial<DownloadItem> = {}
): 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<XtreamContent | null>();
const newerProvider = deferred<XtreamContent | null>();
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<XtreamContent | null>();
const newerProvider = deferred<XtreamContent | null>();
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'))
@@ -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<Record<string, unknown>>((result, key) => {
result[key] = sortedValue((value as Record<string, unknown>)[key]);
return result;
}, {});
}
@Injectable({ providedIn: 'root' })
export class DownloadOfflineMetadataService {
private readonly db = inject(DatabaseService);
@@ -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<DownloadMetadataSnapshot> {
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<DownloadMetadataSnapshot> {
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<DownloadMetadataSnapshot> {
): Promise<TmdbRefreshResult> {
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 };
}
}
}
@@ -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) => {
@@ -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 {