diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts index 0bcccee8f..d23b32f22 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts @@ -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'); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts index 9cfd1eb24..08e9e8eab 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -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( download: T, lstat: DownloadLstat = lstatSync -): T & { fileAvailability: ElectronDownloadFileAvailability } { +): Omit & { + metadataSnapshot: DownloadMetadataSnapshot | undefined; + fileAvailability: ElectronDownloadFileAvailability; +} { return { ...download, + metadataSnapshot: decodeDownloadMetadataSnapshot( + download.metadataSnapshot + ), fileAvailability: getDownloadFileAvailability(download, lstat), }; } diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts new file mode 100644 index 000000000..e07db4903 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts @@ -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(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts new file mode 100644 index 000000000..fcff57d53 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalidSnapshot(); + } + return value as Record; +} + +function assertNoForbiddenKeys(input: unknown): void { + const pending: unknown[] = [input]; + const visited = new WeakSet(); + + 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)[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; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata-update.ts b/apps/electron-backend/src/app/events/database/download-metadata-update.ts new file mode 100644 index 000000000..c7a8bbe38 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-update.ts @@ -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 { + 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 }; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts new file mode 100644 index 000000000..ec21b1da6 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts @@ -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 | undefined) { + const updateWhere = jest.fn().mockResolvedValue(undefined); + const set = jest.fn< + { where: typeof updateWhere }, + [Record] + >(() => ({ 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, + members: Array> +) { + 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] + >(() => ({ 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([]); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 68835167e..f1740848b 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -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 | 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]>( + () => ({ + 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] - >(() => ({ - where: jest.fn().mockResolvedValue(undefined), - })); + const set = jest.fn<{ where: jest.Mock }, [Record]>( + () => ({ + 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] - >(() => ({ - where: jest.fn().mockResolvedValue(undefined), - })); + const set = jest.fn<{ where: jest.Mock }, [Record]>( + () => ({ + 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( diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 622bf686e..4a532d8f7 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -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), diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts index 747939561..9a2c3ae8d 100644 --- a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -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', }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index c1ad4f7be..ac1ba8fc0 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -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(); });