feat(downloads): manage offline metadata snapshots

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent a3d774f05a
commit b23b5aabff
10 files changed
+1162 -19

No files matched your search

@@ -78,6 +78,12 @@ describe('download file availability', () => {
const row = Object.freeze({
filePath: '/downloads/movie.mp4',
id: 42,
metadataSnapshot: JSON.stringify({
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Movie',
}),
status: 'completed' as const,
title: 'Movie',
});
@@ -86,6 +92,12 @@ describe('download file availability', () => {
decorateDownloadItem(row, lstatResult({ isFile: true }))
).toEqual({
...row,
metadataSnapshot: {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Movie',
},
fileAvailability: 'available',
});
expect(row).not.toHaveProperty('fileAvailability');
@@ -1,11 +1,14 @@
import {
type DownloadMetadataSnapshot,
type ElectronBridgeDownloadStatus,
type ElectronDownloadFileAvailability,
} from '@iptvnator/shared/interfaces';
import { lstatSync, type Stats } from 'node:fs';
import { decodeDownloadMetadataSnapshot } from './download-metadata-snapshot';
interface DownloadFileRow {
filePath?: string | null;
metadataSnapshot?: string | null;
status: ElectronBridgeDownloadStatus;
}
@@ -45,9 +48,15 @@ export function getDownloadFileAvailability(
export function decorateDownloadItem<T extends DownloadFileRow>(
download: T,
lstat: DownloadLstat = lstatSync
): T & { fileAvailability: ElectronDownloadFileAvailability } {
): Omit<T, 'metadataSnapshot'> & {
metadataSnapshot: DownloadMetadataSnapshot | undefined;
fileAvailability: ElectronDownloadFileAvailability;
} {
return {
...download,
metadataSnapshot: decodeDownloadMetadataSnapshot(
download.metadataSnapshot
),
fileAvailability: getDownloadFileAvailability(download, lstat),
};
}
@@ -0,0 +1,167 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import {
decodeDownloadMetadataSnapshot,
DOWNLOAD_METADATA_MAX_BYTES,
encodeDownloadMetadataSnapshot,
} from './download-metadata-snapshot';
const validSnapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'The Example',
plot: 'A bounded offline summary.',
genres: ['Drama', 'Mystery'],
rating: 8.2,
cast: [
{
tmdbPersonId: 7,
name: 'Example Actor',
role: 'Lead',
profileUrl: 'https://image.tmdb.org/example.jpg',
},
],
};
describe('download metadata snapshot', () => {
it('encodes a valid normalized snapshot without changing its shape', () => {
expect(encodeDownloadMetadataSnapshot(validSnapshot)).toBe(
JSON.stringify(validSnapshot)
);
});
it('rejects an unsupported snapshot version', () => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
version: 2,
} as never)
).toThrow('Invalid download metadata snapshot');
});
it('rejects an unsupported media kind', () => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
mediaKind: 'live',
} as never)
).toThrow('Invalid download metadata snapshot');
});
it('rejects an empty display title', () => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
title: ' ',
})
).toThrow('Invalid download metadata snapshot');
});
it('rejects an empty snapshot language', () => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
language: ' ',
})
).toThrow('Invalid download metadata snapshot');
});
it('bounds cast and creator arrays to 30 people', () => {
const people = Array.from({ length: 35 }, (_, index) => ({
name: `Person ${index}`,
}));
const decoded = JSON.parse(
encodeDownloadMetadataSnapshot({
...validSnapshot,
cast: people,
creators: people,
})
) as DownloadMetadataSnapshot;
expect(decoded.cast).toHaveLength(30);
expect(decoded.creators).toHaveLength(30);
expect(decoded.cast?.at(-1)?.name).toBe('Person 29');
});
it('bounds genres to 20 entries', () => {
const decoded = JSON.parse(
encodeDownloadMetadataSnapshot({
...validSnapshot,
genres: Array.from(
{ length: 25 },
(_, index) => `Genre ${index}`
),
})
) as DownloadMetadataSnapshot;
expect(decoded.genres).toHaveLength(20);
expect(decoded.genres?.at(-1)).toBe('Genre 19');
});
it('rejects an allowed field beyond the fixed UTF-8 byte ceiling', () => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
plot: '🛰️'.repeat(DOWNLOAD_METADATA_MAX_BYTES),
})
).toThrow('Download metadata snapshot is too large');
});
it.each(['url', 'headers', 'password', 'macAddress', 'cookie'])(
'rejects a nested credential-bearing %s key',
(forbiddenKey) => {
const unsafe = {
...validSnapshot,
ignored: {
deeper: [{ [forbiddenKey]: 'must-not-persist' }],
},
};
expect(() =>
encodeDownloadMetadataSnapshot(unsafe as never)
).toThrow('Invalid download metadata snapshot');
}
);
it.each([NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
'rejects unsafe numeric metadata %s',
(year) => {
expect(() =>
encodeDownloadMetadataSnapshot({
...validSnapshot,
year,
})
).toThrow('Invalid download metadata snapshot');
}
);
it('trims display strings and drops unknown harmless fields', () => {
const encoded = encodeDownloadMetadataSnapshot({
...validSnapshot,
language: ' en ',
title: ' The Example ',
genres: [' Drama '],
ignored: 'not persisted',
} as never);
expect(JSON.parse(encoded)).toEqual({
...validSnapshot,
language: 'en',
title: 'The Example',
genres: ['Drama'],
});
});
it('decodes malformed persisted JSON as absent metadata', () => {
expect(decodeDownloadMetadataSnapshot('{not-json')).toBeUndefined();
});
it('decodes invalid persisted metadata as absent metadata', () => {
expect(
decodeDownloadMetadataSnapshot(
JSON.stringify({ ...validSnapshot, title: ' ' })
)
).toBeUndefined();
});
});
@@ -0,0 +1,255 @@
import type {
DownloadEpisodeMetadata,
DownloadMetadataPerson,
DownloadMetadataSnapshot,
} from '@iptvnator/shared/interfaces';
export const DOWNLOAD_METADATA_MAX_BYTES = 128 * 1024;
const MAX_PEOPLE = 30;
const MAX_GENRES = 20;
const FORBIDDEN_KEYS = new Set([
'accesstoken',
'apikey',
'authorization',
'cookie',
'cookies',
'credential',
'credentials',
'deviceid',
'headers',
'macaddress',
'origin',
'password',
'playbackurl',
'portalauth',
'portalurl',
'proxyauthorization',
'referer',
'refreshtoken',
'requestheaders',
'secret',
'serialnumber',
'serverurl',
'streamurl',
'token',
'url',
'useragent',
'username',
]);
function invalidSnapshot(): never {
throw new Error('Invalid download metadata snapshot');
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalidSnapshot();
}
return value as Record<string, unknown>;
}
function assertNoForbiddenKeys(input: unknown): void {
const pending: unknown[] = [input];
const visited = new WeakSet<object>();
while (pending.length > 0) {
const value = pending.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (visited.has(value)) {
continue;
}
visited.add(value);
for (const key of Object.keys(value)) {
const normalizedKey = key.toLowerCase().replace(/[-_]/g, '');
if (FORBIDDEN_KEYS.has(normalizedKey)) {
invalidSnapshot();
}
pending.push((value as Record<string, unknown>)[key]);
}
}
}
function requiredString(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
return invalidSnapshot();
}
return value.trim();
}
function optionalString(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
return invalidSnapshot();
}
return value.trim();
}
function optionalInteger(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
return invalidSnapshot();
}
return value;
}
function optionalFiniteNumber(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value === 'number' &&
Number.isFinite(value) &&
Math.abs(value) <= Number.MAX_SAFE_INTEGER
) {
return value;
}
return invalidSnapshot();
}
function normalizeGenres(value: unknown): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
return invalidSnapshot();
}
return value.slice(0, MAX_GENRES).map(requiredString);
}
function normalizePerson(value: unknown): DownloadMetadataPerson {
const person = asRecord(value);
const tmdbPersonId = optionalInteger(person.tmdbPersonId);
const role = optionalString(person.role);
const profileUrl = optionalString(person.profileUrl);
return {
...(tmdbPersonId === undefined ? {} : { tmdbPersonId }),
name: requiredString(person.name),
...(role === undefined ? {} : { role }),
...(profileUrl === undefined ? {} : { profileUrl }),
};
}
function normalizePeople(value: unknown): DownloadMetadataPerson[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
return invalidSnapshot();
}
return value.slice(0, MAX_PEOPLE).map(normalizePerson);
}
function normalizeEpisode(value: unknown): DownloadEpisodeMetadata | undefined {
if (value === undefined) {
return undefined;
}
const episode = asRecord(value);
const title = optionalString(episode.title);
const plot = optionalString(episode.plot);
const stillUrl = optionalString(episode.stillUrl);
const seasonNumber = optionalInteger(episode.seasonNumber);
const episodeNumber = optionalInteger(episode.episodeNumber);
if (seasonNumber === undefined || episodeNumber === undefined) {
return invalidSnapshot();
}
return {
...(title === undefined ? {} : { title }),
...(plot === undefined ? {} : { plot }),
...(stillUrl === undefined ? {} : { stillUrl }),
seasonNumber,
episodeNumber,
};
}
function normalizeDownloadMetadataSnapshot(
input: unknown
): DownloadMetadataSnapshot {
assertNoForbiddenKeys(input);
const source = asRecord(input);
if (
source.version !== 1 ||
(source.mediaKind !== 'movie' && source.mediaKind !== 'series')
) {
return invalidSnapshot();
}
const originalTitle = optionalString(source.originalTitle);
const plot = optionalString(source.plot);
const releaseDate = optionalString(source.releaseDate);
const year = optionalInteger(source.year);
const durationMinutes = optionalInteger(source.durationMinutes);
const genres = normalizeGenres(source.genres);
const rating = optionalFiniteNumber(source.rating);
const status = optionalString(source.status);
const posterUrl = optionalString(source.posterUrl);
const backdropUrl = optionalString(source.backdropUrl);
const tmdbId = optionalInteger(source.tmdbId);
const providerCategoryId = optionalString(source.providerCategoryId);
const cast = normalizePeople(source.cast);
const creators = normalizePeople(source.creators);
const episode = normalizeEpisode(source.episode);
const enrichedAt = optionalString(source.enrichedAt);
return {
version: 1,
language: requiredString(source.language),
mediaKind: source.mediaKind,
title: requiredString(source.title),
...(originalTitle === undefined ? {} : { originalTitle }),
...(plot === undefined ? {} : { plot }),
...(releaseDate === undefined ? {} : { releaseDate }),
...(year === undefined ? {} : { year }),
...(durationMinutes === undefined ? {} : { durationMinutes }),
...(genres === undefined ? {} : { genres }),
...(rating === undefined ? {} : { rating }),
...(status === undefined ? {} : { status }),
...(posterUrl === undefined ? {} : { posterUrl }),
...(backdropUrl === undefined ? {} : { backdropUrl }),
...(tmdbId === undefined ? {} : { tmdbId }),
...(providerCategoryId === undefined ? {} : { providerCategoryId }),
...(cast === undefined ? {} : { cast }),
...(creators === undefined ? {} : { creators }),
...(episode === undefined ? {} : { episode }),
...(enrichedAt === undefined ? {} : { enrichedAt }),
};
}
export function encodeDownloadMetadataSnapshot(
input: DownloadMetadataSnapshot
): string {
let snapshot: DownloadMetadataSnapshot;
try {
snapshot = normalizeDownloadMetadataSnapshot(input);
} catch {
return invalidSnapshot();
}
const encoded = JSON.stringify(snapshot);
if (Buffer.byteLength(encoded, 'utf8') > DOWNLOAD_METADATA_MAX_BYTES) {
throw new Error('Download metadata snapshot is too large');
}
return encoded;
}
export function decodeDownloadMetadataSnapshot(
value: string | null | undefined
): DownloadMetadataSnapshot | undefined {
if (
!value ||
Buffer.byteLength(value, 'utf8') > DOWNLOAD_METADATA_MAX_BYTES
) {
return undefined;
}
try {
return normalizeDownloadMetadataSnapshot(JSON.parse(value));
} catch {
return undefined;
}
}
@@ -0,0 +1,128 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { and, eq, sql } from 'drizzle-orm';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import {
decodeDownloadMetadataSnapshot,
encodeDownloadMetadataSnapshot,
} from './download-metadata-snapshot';
interface DownloadMetadataResult {
success: boolean;
error?: string;
}
const VALIDATION_ERRORS = new Set([
'Invalid download metadata snapshot',
'Download metadata snapshot is too large',
]);
function getSafeUpdateError(error: unknown): string {
if (error instanceof Error && VALIDATION_ERRORS.has(error.message)) {
return error.message;
}
return 'Could not update download metadata';
}
function isValidSeriesId(value: number | null): value is number {
return Number.isSafeInteger(value) && (value ?? 0) > 0;
}
function getStoredEpisode(
member: typeof schema.downloads.$inferSelect
): DownloadMetadataSnapshot['episode'] {
const current = decodeDownloadMetadataSnapshot(member.metadataSnapshot);
if (current?.episode) {
return current.episode;
}
if (
member.seasonNumber === null ||
member.episodeNumber === null ||
!Number.isSafeInteger(member.seasonNumber) ||
!Number.isSafeInteger(member.episodeNumber)
) {
return undefined;
}
return {
episodeNumber: member.episodeNumber,
seasonNumber: member.seasonNumber,
title: member.title,
};
}
export async function updateDownloadMetadataRequest(
downloadId: number,
metadataSnapshot: DownloadMetadataSnapshot
): Promise<DownloadMetadataResult> {
try {
const encoded = encodeDownloadMetadataSnapshot(metadataSnapshot);
const normalizedMetadataSnapshot =
decodeDownloadMetadataSnapshot(encoded);
if (!normalizedMetadataSnapshot) {
throw new Error('Invalid download metadata snapshot');
}
const db = await getDatabase();
const [row] = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (!row) {
return { error: 'Download not found', success: false };
}
if (
row.contentType !== 'episode' ||
!isValidSeriesId(row.seriesXtreamId)
) {
await db
.update(schema.downloads)
.set({
metadataSnapshot: encoded,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, row.id));
return { success: true };
}
const members = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, row.playlistId),
eq(schema.downloads.seriesXtreamId, row.seriesXtreamId),
eq(schema.downloads.contentType, row.contentType)
)
);
const writes = members.map((member) => {
const episode = getStoredEpisode(member);
return {
id: member.id,
metadataSnapshot: encodeDownloadMetadataSnapshot({
...normalizedMetadataSnapshot,
episode,
}),
};
});
await db.transaction((tx) => {
for (const write of writes) {
tx.update(schema.downloads)
.set({
metadataSnapshot: write.metadataSnapshot,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, write.id))
.run();
}
});
return { success: true };
} catch (error) {
const safeError = getSafeUpdateError(error);
if (safeError === 'Could not update download metadata') {
console.error('[Downloads] Error updating metadata:', error);
}
return { error: safeError, success: false };
}
}
@@ -0,0 +1,369 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import {
getHandler,
mockEq,
mockGetDatabase,
setupDownloadsEventsHarness,
} from './downloads.test-helpers';
const movieSnapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Refreshed Movie',
plot: 'Stored for offline details.',
};
function createSingleRowDatabase(row: Record<string, unknown> | undefined) {
const updateWhere = jest.fn().mockResolvedValue(undefined);
const set = jest.fn<
{ where: typeof updateWhere },
[Record<string, unknown>]
>(() => ({ where: updateWhere }));
const update = jest.fn(() => ({ set }));
const limit = jest.fn().mockResolvedValue(row ? [row] : []);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update,
};
mockGetDatabase.mockResolvedValue(db);
return { db, limit, set, updateWhere };
}
function createGroupedDatabase(
representative: Record<string, unknown>,
members: Array<Record<string, unknown>>
) {
const limit = jest.fn().mockResolvedValue([representative]);
const groupWhere = jest.fn().mockResolvedValue(members);
const select = jest
.fn()
.mockImplementationOnce(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
}))
.mockImplementationOnce(() => ({
from: jest.fn(() => ({ where: groupWhere })),
}));
const run = jest.fn();
const updateWhere = jest.fn<{ run: typeof run }, [unknown]>(() => ({
run,
}));
const set = jest.fn<
{ where: typeof updateWhere },
[Record<string, unknown>]
>(() => ({ where: updateWhere }));
const transactionUpdate = jest.fn(() => ({ set }));
const transaction = jest.fn(
(operation: (tx: { update: typeof transactionUpdate }) => void) =>
operation({ update: transactionUpdate })
);
const update = jest.fn();
const db = { select, transaction, update };
mockGetDatabase.mockResolvedValue(db);
return {
db,
groupWhere,
run,
set,
transaction,
transactionUpdate,
update,
updateWhere,
};
}
describe('downloads events: managed metadata updates', () => {
beforeEach(async () => {
await setupDownloadsEventsHarness();
});
it('updates only the managed movie id', async () => {
const schema = await import('../../database/schema');
const { set, updateWhere } = createSingleRowDatabase({
contentType: 'vod',
id: 42,
playlistId: 'playlist-a',
seriesXtreamId: null,
});
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, movieSnapshot)
).resolves.toEqual({ success: true });
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
metadataSnapshot: JSON.stringify(movieSnapshot),
updatedAt: expect.anything(),
})
);
expect(mockEq).toHaveBeenCalledWith(schema.downloads.id, 42);
expect(mockEq).toHaveBeenCalledTimes(2);
expect(updateWhere).toHaveBeenCalledTimes(1);
});
it('updates the authoritative episode group while preserving each episode', async () => {
const schema = await import('../../database/schema');
const representative = {
contentType: 'episode',
id: 41,
playlistId: 'stored-playlist',
seriesXtreamId: 700,
};
const members = [
{
episodeNumber: 1,
id: 41,
metadataSnapshot: JSON.stringify({
...movieSnapshot,
mediaKind: 'series',
title: 'Old Parent',
episode: {
episodeNumber: 1,
seasonNumber: 2,
title: 'Member One',
},
}),
seasonNumber: 2,
title: 'Stored One',
},
{
episodeNumber: 2,
id: 42,
metadataSnapshot: JSON.stringify({
...movieSnapshot,
mediaKind: 'series',
title: 'Old Parent',
episode: {
episodeNumber: 2,
seasonNumber: 2,
title: 'Member Two',
},
}),
seasonNumber: 2,
title: 'Stored Two',
},
];
const database = createGroupedDatabase(representative, members);
const refreshed: DownloadMetadataSnapshot = {
...movieSnapshot,
mediaKind: 'series',
title: 'Refreshed Parent',
episode: {
episodeNumber: 99,
seasonNumber: 9,
title: 'Representative Episode',
},
};
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, refreshed)
).resolves.toEqual({ success: true });
expect(database.groupWhere).toHaveBeenCalledTimes(1);
expect(mockEq).toHaveBeenCalledWith(
schema.downloads.playlistId,
'stored-playlist'
);
expect(mockEq).toHaveBeenCalledWith(
schema.downloads.contentType,
'episode'
);
expect(mockEq).toHaveBeenCalledWith(
schema.downloads.seriesXtreamId,
700
);
expect(database.transaction).toHaveBeenCalledTimes(1);
expect(database.update).not.toHaveBeenCalled();
expect(database.transactionUpdate).toHaveBeenCalledTimes(2);
expect(database.run).toHaveBeenCalledTimes(2);
for (const [write] of database.set.mock.calls) {
expect(write).toEqual(
expect.objectContaining({ updatedAt: expect.anything() })
);
}
const encodedWrites = database.set.mock.calls.map(
([write]) =>
JSON.parse(
(write as { metadataSnapshot: string }).metadataSnapshot
) as DownloadMetadataSnapshot
);
expect(encodedWrites).toEqual([
{
...refreshed,
episode: {
episodeNumber: 1,
seasonNumber: 2,
title: 'Member One',
},
},
{
...refreshed,
episode: {
episodeNumber: 2,
seasonNumber: 2,
title: 'Member Two',
},
},
]);
});
it('synthesizes missing episode metadata from authoritative row fields', async () => {
const member = {
episodeNumber: 3,
id: 43,
metadataSnapshot: '{corrupt',
seasonNumber: 4,
title: 'Stored Episode Title',
};
const database = createGroupedDatabase(
{
contentType: 'episode',
id: 43,
playlistId: 'playlist-a',
seriesXtreamId: 700,
},
[member]
);
const refreshed: DownloadMetadataSnapshot = {
...movieSnapshot,
mediaKind: 'series',
title: 'Refreshed Parent',
};
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 43, refreshed)
).resolves.toEqual({ success: true });
const write = database.set.mock.calls[0][0] as {
metadataSnapshot: string;
};
expect(JSON.parse(write.metadataSnapshot)).toEqual({
...refreshed,
episode: {
episodeNumber: 3,
seasonNumber: 4,
title: 'Stored Episode Title',
},
});
});
it('updates only an ungrouped episode', async () => {
const { set, updateWhere } = createSingleRowDatabase({
contentType: 'episode',
id: 42,
playlistId: 'playlist-a',
seriesXtreamId: null,
});
const snapshot: DownloadMetadataSnapshot = {
...movieSnapshot,
mediaKind: 'series',
};
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, snapshot)
).resolves.toEqual({ success: true });
expect(set).toHaveBeenCalledTimes(1);
expect(updateWhere).toHaveBeenCalledTimes(1);
});
it('returns a stable missing-row failure without writing', async () => {
const { db } = createSingleRowDatabase(undefined);
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 404, movieSnapshot)
).resolves.toEqual({
error: 'Download not found',
success: false,
});
expect(db.update).not.toHaveBeenCalled();
});
it('rejects invalid and unsafe snapshots before accessing the database', async () => {
const invalid = { ...movieSnapshot, title: ' ' };
const unsafe = {
...movieSnapshot,
cast: [{ name: 'Actor', private: { cookie: 'secret' } }],
};
for (const snapshot of [invalid, unsafe]) {
const result = await getHandler('DOWNLOADS_UPDATE_METADATA')(
null,
42,
snapshot
);
expect(result).toEqual({
error: 'Invalid download metadata snapshot',
success: false,
});
}
expect(mockGetDatabase).not.toHaveBeenCalled();
});
it('does not commit a partial group when a member write fails', async () => {
const representative = {
contentType: 'episode',
id: 41,
playlistId: 'playlist-a',
seriesXtreamId: 700,
};
const members = [
{
episodeNumber: 1,
id: 41,
metadataSnapshot: null,
seasonNumber: 1,
title: 'One',
},
{
episodeNumber: 2,
id: 42,
metadataSnapshot: null,
seasonNumber: 1,
title: 'Two',
},
];
const database = createGroupedDatabase(representative, members);
const pending: number[] = [];
const committed: number[] = [];
database.updateWhere.mockImplementation((predicate) => ({
run: jest.fn(() => {
pending.push(predicate as number);
if (pending.length === 2) {
throw new Error('disk full');
}
}),
}));
database.transaction.mockImplementation((operation) => {
operation({ update: database.transactionUpdate });
committed.push(...pending);
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, {
...movieSnapshot,
mediaKind: 'series',
})
).resolves.toEqual({
error: 'Could not update download metadata',
success: false,
});
} finally {
consoleError.mockRestore();
}
expect(database.transaction).toHaveBeenCalledTimes(1);
expect(committed).toEqual([]);
});
});
@@ -1,5 +1,168 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
const metadataSnapshot: DownloadMetadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Offline Movie',
};
async function setupStartMetadataRequest(
existing: Record<string, unknown> | undefined
) {
jest.resetModules();
const limit = jest
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce(existing ? [existing] : []);
const insertValues = jest.fn().mockResolvedValue({ lastInsertRowid: 84 });
const set = jest.fn<{ where: jest.Mock }, [Record<string, unknown>]>(
() => ({
where: jest.fn().mockResolvedValue(undefined),
})
);
const db = {
insert: jest.fn(() => ({ values: insertValues })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { startDownloadRequest } = await import('./download-requests');
return {
authorizer,
db,
enqueueDownload,
insertValues,
set,
startDownloadRequest,
};
}
function startPayload(snapshot?: DownloadMetadataSnapshot) {
return {
contentType: 'vod' as const,
downloadFolder: '/downloads',
metadataSnapshot: snapshot,
playlistId: 'playlist-1',
title: 'Offline Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
};
}
describe('download request metadata snapshots', () => {
it('persists an encoded snapshot for a new download', async () => {
const request = await setupStartMetadataRequest(undefined);
await expect(
request.startDownloadRequest(
startPayload(metadataSnapshot),
request.authorizer
)
).resolves.toEqual({ id: 84, success: true });
expect(request.insertValues).toHaveBeenCalledWith(
expect.objectContaining({
metadataSnapshot: JSON.stringify(metadataSnapshot),
})
);
});
it('preserves stored metadata when a restart omits a snapshot', async () => {
const request = await setupStartMetadataRequest({
contentType: 'vod',
filePath: null,
id: 42,
metadataSnapshot: JSON.stringify(metadataSnapshot),
playlistId: 'playlist-1',
status: 'canceled',
title: 'Offline Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
});
await expect(
request.startDownloadRequest(startPayload(), request.authorizer)
).resolves.toEqual({ id: 42, success: true });
expect(request.set).toHaveBeenCalledTimes(1);
expect(request.set.mock.calls[0][0]).not.toHaveProperty(
'metadataSnapshot'
);
});
it('replaces stored metadata when a restart supplies a snapshot', async () => {
const request = await setupStartMetadataRequest({
contentType: 'vod',
filePath: null,
id: 42,
metadataSnapshot: null,
playlistId: 'playlist-1',
status: 'canceled',
title: 'Offline Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
});
await request.startDownloadRequest(
startPayload(metadataSnapshot),
request.authorizer
);
expect(request.set).toHaveBeenCalledWith(
expect.objectContaining({
metadataSnapshot: JSON.stringify(metadataSnapshot),
})
);
});
it('rejects invalid metadata before mutating a download row', async () => {
const request = await setupStartMetadataRequest({
contentType: 'vod',
filePath: null,
id: 42,
playlistId: 'playlist-1',
status: 'canceled',
title: 'Offline Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
});
await expect(
request.startDownloadRequest(
startPayload({
...metadataSnapshot,
title: ' ',
}),
request.authorizer
)
).rejects.toThrow('Invalid download metadata snapshot');
expect(request.db.update).not.toHaveBeenCalled();
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
});
});
describe('download requests resume', () => {
it('enqueues a paused download with stored headers and original target path', async () => {
jest.resetModules();
@@ -129,12 +292,11 @@ describe('download requests resume', () => {
url: 'https://example.test/movie.mp4',
};
const limit = jest.fn().mockResolvedValue([row]);
const set = jest.fn<
{ where: jest.Mock },
[Record<string, unknown>]
>(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const set = jest.fn<{ where: jest.Mock }, [Record<string, unknown>]>(
() => ({
where: jest.fn().mockResolvedValue(undefined),
})
);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
@@ -205,12 +367,11 @@ describe('download requests resume', () => {
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce([failedRow]);
const set = jest.fn<
{ where: jest.Mock },
[Record<string, unknown>]
>(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const set = jest.fn<{ where: jest.Mock }, [Record<string, unknown>]>(
() => ({
where: jest.fn().mockResolvedValue(undefined),
})
);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
@@ -319,9 +480,8 @@ describe('download requests resume', () => {
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const { startDownloadRequest } = await import(
'./download-requests'
);
const { startDownloadRequest } =
await import('./download-requests');
await expect(
startDownloadRequest(
@@ -1,3 +1,4 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { and, eq, sql } from 'drizzle-orm';
import { basename, dirname, extname } from 'node:path';
import { getDatabase } from '../../database/connection';
@@ -5,6 +6,7 @@ import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { removePartialDownloadFile } from './download-file-path';
import { encodeDownloadMetadataSnapshot } from './download-metadata-snapshot';
import { enqueueDownload } from './download-runtime';
export interface StartDownloadRequest {
@@ -14,6 +16,7 @@ export interface StartDownloadRequest {
title: string;
url: string;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
@@ -110,6 +113,10 @@ export async function startDownloadRequest(
data: StartDownloadRequest,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string; id?: number }> {
const encodedMetadataSnapshot =
data.metadataSnapshot === undefined
? undefined
: encodeDownloadMetadataSnapshot(data.metadataSnapshot);
console.log('[Downloads] Enqueue download:', data.title);
const directory = await authorizer.requireAuthorized(data.downloadFolder);
await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true });
@@ -190,6 +197,9 @@ export async function startDownloadRequest(
errorMessage: null,
fileName,
filePath: null,
...(encodedMetadataSnapshot === undefined
? {}
: { metadataSnapshot: encodedMetadataSnapshot }),
requestHeaders: serializeHeaders(headers),
resumeValidator: null,
status: 'queued',
@@ -212,6 +222,7 @@ export async function startDownloadRequest(
contentType: data.contentType,
episodeNumber: data.episodeNumber,
fileName,
metadataSnapshot: encodedMetadataSnapshot,
playlistId: data.playlistId,
posterUrl: data.posterUrl,
requestHeaders: serializeHeaders(headers),
@@ -18,10 +18,17 @@ describe('downloads events: file availability', () => {
});
it('decorates every download in the list from the current filesystem state', async () => {
const metadataSnapshot = {
version: 1,
language: 'en',
mediaKind: 'movie',
title: 'Available Movie',
};
const rows = [
{
filePath: '/downloads/available.mp4',
id: 1,
metadataSnapshot: JSON.stringify(metadataSnapshot),
status: 'completed',
},
{
@@ -49,9 +56,21 @@ describe('downloads events: file availability', () => {
});
await expect(getHandler('DOWNLOADS_GET_LIST')(null)).resolves.toEqual([
{ ...rows[0], fileAvailability: 'available' },
{ ...rows[1], fileAvailability: 'missing' },
{ ...rows[2], fileAvailability: 'not-applicable' },
{
...rows[0],
metadataSnapshot,
fileAvailability: 'available',
},
{
...rows[1],
metadataSnapshot: undefined,
fileAvailability: 'missing',
},
{
...rows[2],
metadataSnapshot: undefined,
fileAvailability: 'not-applicable',
},
]);
expect(mockLstatSync).toHaveBeenCalledTimes(2);
});
@@ -60,6 +79,7 @@ describe('downloads events: file availability', () => {
const row = {
filePath: '/downloads/missing.mp4',
id: 2,
metadataSnapshot: '{corrupt',
status: 'completed',
};
mockGetDatabase.mockResolvedValue({
@@ -77,6 +97,7 @@ describe('downloads events: file availability', () => {
await expect(getHandler('DOWNLOADS_GET')(null, 2)).resolves.toEqual({
...row,
metadataSnapshot: undefined,
fileAvailability: 'missing',
});
});
@@ -1,3 +1,4 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { and, eq, inArray } from 'drizzle-orm';
import { app, dialog, ipcMain, shell } from 'electron';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@@ -10,6 +11,7 @@ import {
isAvailableDownloadFile,
} from './download-file-availability';
import { removePartialDownloadFile } from './download-file-path';
import { updateDownloadMetadataRequest } from './download-metadata-update';
import {
resumeDownloadRequest,
retryDownloadRequest,
@@ -243,6 +245,15 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
}
});
ipcMain.handle(
'DOWNLOADS_UPDATE_METADATA',
async (
_event,
downloadId: number,
metadataSnapshot: DownloadMetadataSnapshot
) => updateDownloadMetadataRequest(downloadId, metadataSnapshot)
);
ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => {
return downloadDirectoryAuthorizer.getPreferredDirectory();
});