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: [