From a3d774f05a566d46fff769973c896b65b996aa2a Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 09:40:17 +0200 Subject: [PATCH] fix(downloads): complete metadata snapshot bridge contract --- .../src/app/api/main.preload.spec.ts | 23 ++- .../src/app/api/main.preload.ts | 10 ++ .../database/src/lib/connection.spec.ts | 159 ++++++++++++------ 3 files changed, 141 insertions(+), 51 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index b624e65fa..7397dbe61 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -13,7 +13,10 @@ import { ANNOUNCE_PLAYLIST_OPEN_LISTENER, OPEN_FILE, } from '@iptvnator/shared/interfaces'; -import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import type { + DownloadMetadataSnapshot, + ElectronBridgeApi, +} from '@iptvnator/shared/interfaces'; import { dbPreloadCases, epgPreloadCases, @@ -278,6 +281,24 @@ describe('main preload DB IPC contract', () => { ); }); + it('forwards download metadata updates with the download id', async () => { + const api = getExposedApi(); + const metadataSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Offline title', + }; + + await api.downloadsUpdateMetadata(42, metadataSnapshot); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'DOWNLOADS_UPDATE_METADATA', + 42, + metadataSnapshot + ); + }); + it('keeps the legacy save-content progress bridge scoped to progress events', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 14b8976c1..e62eaaf3b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -24,6 +24,7 @@ import { toXtreamPreloadPerformanceTargetMethod, } from './xtream-preload-performance-capture'; import type { + DownloadMetadataSnapshot, EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, @@ -986,6 +987,15 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId), downloadsGet: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_GET', downloadId), + downloadsUpdateMetadata: ( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot + ) => + ipcRenderer.invoke( + 'DOWNLOADS_UPDATE_METADATA', + downloadId, + metadataSnapshot + ), downloadsGetDefaultFolder: () => ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'), downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'), diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 3e1925a52..729717dad 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -1,3 +1,7 @@ +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { getTableColumns } from 'drizzle-orm'; import { __databaseConnectionTestHooks } from './connection'; import { downloads } from './schema'; @@ -19,6 +23,105 @@ function createdObjectNames(prefix: string, statements: readonly string[]) { .filter((name): name is string => Boolean(name)); } +function rebuildDownloadsInElectron(metadataSnapshot: string): { + metadataSnapshot: string; + schemaSql: string; +} { + const electronPath = createRequire(__filename)('electron') as string; + const connectionUrl = pathToFileURL( + resolve(__dirname, 'connection.ts') + ).href; + const script = ` + const { default: Database } = await import('better-sqlite3'); + const { __databaseConnectionTestHooks } = await import(${JSON.stringify( + connectionUrl + )}); + const metadataSnapshot = ${JSON.stringify(metadataSnapshot)}; + const sqlite = new Database(':memory:'); + sqlite.exec(\` + CREATE TABLE playlists (id TEXT PRIMARY KEY); + CREATE TABLE downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + content_type TEXT NOT NULL, + series_xtream_id INTEGER, + season_number INTEGER, + episode_number INTEGER, + title TEXT NOT NULL, + url TEXT NOT NULL, + file_name TEXT, + file_path TEXT, + poster_url TEXT, + request_headers TEXT, + metadata_snapshot TEXT, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ( + 'queued', + 'downloading', + 'completed', + 'failed', + 'canceled' + )), + bytes_downloaded INTEGER, + total_bytes INTEGER, + error_message TEXT, + created_at TEXT, + updated_at TEXT + ); + \`); + sqlite.prepare('INSERT INTO playlists (id) VALUES (?)') + .run('playlist-1'); + sqlite.prepare(\` + INSERT INTO downloads ( + playlist_id, + xtream_id, + content_type, + title, + url, + metadata_snapshot + ) VALUES (?, ?, ?, ?, ?, ?) + \`).run( + 'playlist-1', + 42, + 'vod', + 'Offline title', + 'https://example.com/movie', + metadataSnapshot + ); + console.log = () => undefined; + __databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite); + const row = sqlite.prepare( + 'SELECT metadata_snapshot FROM downloads WHERE id = 1' + ).get(); + const table = sqlite.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'" + ).get(); + sqlite.close(); + process.stdout.write(JSON.stringify({ + metadataSnapshot: row.metadata_snapshot, + schemaSql: table.sql, + })); + `; + const output = execFileSync( + electronPath, + ['--import', 'tsx', '--eval', script], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + }, + } + ); + + return JSON.parse(output) as { + metadataSnapshot: string; + schemaSql: string; + }; +} + describe('database schema statements', () => { afterEach(() => { jest.restoreAllMocks(); @@ -63,12 +166,8 @@ describe('database schema statements', () => { } }); - function createRebuildSqlite( - legacyTableSql: string | undefined, - legacyMetadataSnapshot?: string - ) { + function createRebuildSqlite(legacyTableSql: string | undefined) { const statements: string[] = []; - let rebuiltMetadataSnapshot: string | undefined; const transaction = jest.fn((callback: () => void) => callback); const prepare = jest.fn((statement: string) => { if (statement.includes('FROM sqlite_master')) { @@ -81,29 +180,12 @@ describe('database schema statements', () => { } return { run: () => { - const compactStatement = compactSql(statement); - statements.push(compactStatement); - if ( - compactStatement.startsWith( - 'INSERT INTO downloads' - ) && - compactStatement.includes('metadata_snapshot') && - !compactStatement.includes( - 'NULL AS metadata_snapshot' - ) - ) { - rebuiltMetadataSnapshot = legacyMetadataSnapshot; - } + statements.push(compactSql(statement)); }, }; }); - return { - getRebuiltMetadataSnapshot: () => rebuiltMetadataSnapshot, - prepare, - statements, - transaction, - }; + return { prepare, statements, transaction }; } it('defines the core fresh-install tables, indexes, and FTS triggers', () => { @@ -547,33 +629,10 @@ describe('database schema statements', () => { mediaKind: 'movie', title: 'Offline title', }); - const sqlite = createRebuildSqlite( - `CREATE TABLE downloads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_headers TEXT, - metadata_snapshot TEXT, - status TEXT CHECK ( - status IN ( - 'queued', - 'downloading', - 'completed', - 'failed', - 'canceled' - ) - ) - )`, - metadataSnapshot - ); + const rebuilt = rebuildDownloadsInElectron(metadataSnapshot); - ensureDownloadsPauseResumeSchema(sqlite); - - const copy = sqlite.statements.find((statement) => - statement.startsWith('INSERT INTO downloads') - ); - expect(copy).toBeDefined(); - expect(copy).toContain('metadata_snapshot'); - expect(copy).not.toContain('NULL AS metadata_snapshot'); - expect(sqlite.getRebuiltMetadataSnapshot()).toBe(metadataSnapshot); + expect(rebuilt.schemaSql).toContain(`'paused'`); + expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot); }); it('skips the downloads rebuild when the table already has the paused contract', () => {