mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(downloads): manage offline metadata snapshots
This commit is contained in:
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),
|
||||
|
||||
+24
-3
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user