From 664d333f0f7ba34f80ae3d68e384657b80cd38d0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 09:27:55 +0200 Subject: [PATCH] feat(downloads): persist offline metadata snapshots --- .../database/src/lib/connection.spec.ts | 76 ++++++++++++++++++- libs/shared/database/src/lib/connection.ts | 9 +++ libs/shared/database/src/lib/schema.ts | 1 + libs/shared/interfaces/src/index.ts | 1 + .../src/lib/download-metadata.interface.ts | 37 +++++++++ .../src/lib/electron-api.interface.ts | 7 ++ 6 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/download-metadata.interface.ts diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 031faf932..3e1925a52 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -1,4 +1,6 @@ +import { getTableColumns } from 'drizzle-orm'; import { __databaseConnectionTestHooks } from './connection'; +import { downloads } from './schema'; function compactSql(statement: string): string { return statement.replace(/\s+/g, ' ').trim(); @@ -61,8 +63,12 @@ describe('database schema statements', () => { } }); - function createRebuildSqlite(legacyTableSql: string | undefined) { + function createRebuildSqlite( + legacyTableSql: string | undefined, + legacyMetadataSnapshot?: string + ) { 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')) { @@ -75,16 +81,36 @@ describe('database schema statements', () => { } return { run: () => { - statements.push(compactSql(statement)); + 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; + } }, }; }); - return { prepare, statements, transaction }; + return { + getRebuiltMetadataSnapshot: () => rebuiltMetadataSnapshot, + prepare, + statements, + transaction, + }; } it('defines the core fresh-install tables, indexes, and FTS triggers', () => { const schemaSql = createTableStatements.map(compactSql).join('\n'); + const downloadColumns = Object.values(getTableColumns(downloads)).map( + (column) => column.name + ); expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists'); expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories'); @@ -104,6 +130,8 @@ describe('database schema statements', () => { expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads'); expect(schemaSql).toContain('request_headers TEXT'); expect(schemaSql).toContain('resume_validator TEXT'); + expect(schemaSql).toContain('metadata_snapshot TEXT'); + expect(downloadColumns).toContain('metadata_snapshot'); expect(schemaSql).toContain("'paused'"); expect(schemaSql).toContain( 'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique' @@ -149,6 +177,9 @@ describe('database schema statements', () => { 'ALTER TABLE content ADD COLUMN backdrop_url TEXT', ]) ); + expect(columnMigrationStatements).toContain( + 'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT' + ); }); it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => { @@ -488,6 +519,9 @@ describe('database schema statements', () => { expect(statements[createIndex]).toContain('request_headers TEXT'); expect(statements[createIndex]).toContain('resume_validator TEXT'); expect(statements[copyIndex]).toContain('NULL AS request_headers'); + expect(statements[copyIndex]).toContain( + 'NULL AS metadata_snapshot' + ); expect(statements[copyIndex]).not.toContain('resume_validator'); }); @@ -506,6 +540,42 @@ describe('database schema statements', () => { expect(copy).toContain('request_headers'); }); + it('preserves metadata snapshots when rebuilding the pause/resume schema', () => { + const metadataSnapshot = JSON.stringify({ + version: 1, + language: 'en', + 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 + ); + + 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); + }); + it('skips the downloads rebuild when the table already has the paused contract', () => { const sqlite = createRebuildSqlite( `CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))` diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 0721cf11f..2b473c445 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -105,6 +105,7 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads ( poster_url TEXT, request_headers TEXT, resume_validator TEXT, + metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')), bytes_downloaded INTEGER DEFAULT 0, total_bytes INTEGER, @@ -379,6 +380,8 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE epg_programs ADD COLUMN source_url TEXT`, // Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume `ALTER TABLE downloads ADD COLUMN resume_validator TEXT`, + // Offline details: provider-neutral display metadata captured at download time + `ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`, ]; const INDEX_MIGRATION_STATEMENTS = [ @@ -1002,6 +1005,10 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { const legacyHeadersSelect = hasRequestHeaders ? 'request_headers' : 'NULL AS request_headers'; + const hasMetadataSnapshot = row.sql.includes('metadata_snapshot'); + const legacyMetadataSnapshotSelect = hasMetadataSnapshot + ? 'metadata_snapshot' + : 'NULL AS metadata_snapshot'; const rebuild = sqliteDb.transaction(() => { for (const statement of DOWNLOADS_INDEX_STATEMENTS) { const match = statement.match( @@ -1034,6 +1041,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { file_path, poster_url, request_headers, + metadata_snapshot, status, bytes_downloaded, total_bytes, @@ -1055,6 +1063,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { file_path, poster_url, ${legacyHeadersSelect}, + ${legacyMetadataSnapshotSelect}, status, bytes_downloaded, total_bytes, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index e87f3cbb4..3207cf302 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -335,6 +335,7 @@ export const downloads = sqliteTable( posterUrl: text('poster_url'), requestHeaders: text('request_headers'), resumeValidator: text('resume_validator'), + metadataSnapshot: text('metadata_snapshot'), // Download progress status: text('status', { enum: [ diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 290bea8ca..83bbe81d4 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/channel-drm.interface'; export * from './lib/channel.interface'; export * from './lib/channel.model'; export * from './lib/dev-logger.util'; +export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; diff --git a/libs/shared/interfaces/src/lib/download-metadata.interface.ts b/libs/shared/interfaces/src/lib/download-metadata.interface.ts new file mode 100644 index 000000000..4d6d8193b --- /dev/null +++ b/libs/shared/interfaces/src/lib/download-metadata.interface.ts @@ -0,0 +1,37 @@ +export interface DownloadMetadataPerson { + tmdbPersonId?: number; + name: string; + role?: string; + profileUrl?: string; +} + +export interface DownloadEpisodeMetadata { + title?: string; + plot?: string; + stillUrl?: string; + seasonNumber: number; + episodeNumber: number; +} + +export interface DownloadMetadataSnapshot { + version: 1; + language: string; + mediaKind: 'movie' | 'series'; + title: string; + originalTitle?: string; + plot?: string; + releaseDate?: string; + year?: number; + durationMinutes?: number; + genres?: string[]; + rating?: number; + status?: string; + posterUrl?: string; + backdropUrl?: string; + tmdbId?: number; + providerCategoryId?: string; + cast?: DownloadMetadataPerson[]; + creators?: DownloadMetadataPerson[]; + episode?: DownloadEpisodeMetadata; + enrichedAt?: string; +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 7b7c4704a..44023a9cc 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -4,6 +4,7 @@ import { EmbeddedMpvSession, EmbeddedMpvSupport, } from './embedded-mpv-session.interface'; +import { DownloadMetadataSnapshot } from './download-metadata.interface'; import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; import { ExternalPlayerSession } from './external-player-session.interface'; @@ -516,6 +517,7 @@ export interface ElectronBridgeDownloadStartPayload { title: string; url: string; posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; downloadFolder: string; headers?: ElectronBridgeDownloadHeaders; seriesXtreamId?: number; @@ -549,6 +551,7 @@ export interface ElectronDownloadItem { fileName?: string; filePath?: string; posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; status: ElectronBridgeDownloadStatus; fileAvailability: ElectronDownloadFileAvailability; bytesDownloaded?: number; @@ -1083,6 +1086,10 @@ export interface ElectronBridgeApi { downloadsRemove: (downloadId: number) => Promise; downloadsGetList: (playlistId?: string) => Promise; downloadsGet: (downloadId: number) => Promise; + downloadsUpdateMetadata: ( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot + ) => Promise; downloadsGetDefaultFolder: () => Promise; downloadsSelectFolder: () => Promise; downloadsRevealFile: (