feat(downloads): persist offline metadata snapshots

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 2e243cc4af
commit 664d333f0f
6 files changed
+128 -3

No files matched your search

@@ -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')))`
@@ -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,
+1
View File
@@ -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: [
+1
View File
@@ -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';
@@ -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;
}
@@ -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<ElectronBridgeErrorResult>;
downloadsGetList: (playlistId?: string) => Promise<ElectronDownloadItem[]>;
downloadsGet: (downloadId: number) => Promise<ElectronDownloadItem | null>;
downloadsUpdateMetadata: (
downloadId: number,
metadataSnapshot: DownloadMetadataSnapshot
) => Promise<ElectronBridgeErrorResult>;
downloadsGetDefaultFolder: () => Promise<string>;
downloadsSelectFolder: () => Promise<string | null>;
downloadsRevealFile: (