mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(downloads): stabilize offline metadata refresh
This commit is contained in:
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();
|
||||
});
|
||||
|
||||
@@ -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'])
|
||||
|
||||
+241
-14
@@ -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'))
|
||||
|
||||
+98
-97
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user