fix(downloads): preserve offline library ownership

This commit is contained in:
4gray committed 2026-08-01 16:29:51 +02:00
1 parent 81e098d52c
commit d35c255157
25 files changed
+846 -227

No files matched your search

@@ -3,8 +3,9 @@ import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { getTableColumns } from 'drizzle-orm';
import { getTableConfig } from 'drizzle-orm/sqlite-core';
import { __databaseConnectionTestHooks } from './connection';
import { downloads } from './schema';
import { categories, downloads } from './schema';
function compactSql(statement: string): string {
return statement.replace(/\s+/g, ' ').trim();
@@ -25,6 +26,7 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
function rebuildDownloadsInElectron(metadataSnapshot: string): {
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
} {
const electronPath = createRequire(__filename)('electron') as string;
@@ -38,6 +40,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
)});
const metadataSnapshot = ${JSON.stringify(metadataSnapshot)};
const sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
sqlite.exec(\`
CREATE TABLE playlists (id TEXT PRIMARY KEY);
CREATE TABLE downloads (
@@ -68,6 +71,8 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
error_message TEXT,
created_at TEXT,
updated_at TEXT
, FOREIGN KEY (playlist_id) REFERENCES playlists (id)
ON DELETE CASCADE
);
\`);
sqlite.prepare('INSERT INTO playlists (id) VALUES (?)')
@@ -97,9 +102,15 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
const table = sqlite.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
).get();
sqlite.prepare('DELETE FROM playlists WHERE id = ?')
.run('playlist-1');
const retainedAfterPlaylistDelete = Boolean(sqlite.prepare(
'SELECT id FROM downloads WHERE id = 1'
).get());
sqlite.close();
process.stdout.write(JSON.stringify({
metadataSnapshot: row.metadata_snapshot,
retainedAfterPlaylistDelete,
schemaSql: table.sql,
}));
`;
@@ -118,6 +129,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
return JSON.parse(output) as {
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
};
}
@@ -190,9 +202,17 @@ describe('database schema statements', () => {
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
const schemaSql = createTableStatements.map(compactSql).join('\n');
const downloadsSchemaSql =
createTableStatements
.map(compactSql)
.find((statement) =>
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
) ?? '';
const downloadColumns = Object.values(getTableColumns(downloads)).map(
(column) => column.name
);
const categoryForeignKeys = getTableConfig(categories).foreignKeys;
const downloadForeignKeys = getTableConfig(downloads).foreignKeys;
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
@@ -210,6 +230,11 @@ describe('database schema statements', () => {
'CREATE TABLE IF NOT EXISTS playback_positions'
);
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
expect(downloadsSchemaSql).not.toContain(
'FOREIGN KEY (playlist_id) REFERENCES playlists'
);
expect(categoryForeignKeys).toHaveLength(1);
expect(downloadForeignKeys).toHaveLength(0);
expect(schemaSql).toContain('request_headers TEXT');
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain('metadata_snapshot TEXT');
@@ -602,7 +627,7 @@ describe('database schema statements', () => {
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');
expect(statements[copyIndex]).toContain('NULL AS resume_validator');
});
it('copies stored headers when only the paused status is missing', () => {
@@ -630,7 +655,30 @@ describe('database schema statements', () => {
const rebuilt = rebuildDownloadsInElectron(metadataSnapshot);
expect(rebuilt.schemaSql).toContain(`'paused'`);
expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists');
expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot);
expect(rebuilt.retainedAfterPlaylistDelete).toBe(true);
});
it('rebuilds a current downloads table that still cascades with its source playlist', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
request_headers TEXT,
resume_validator TEXT,
status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled'))
)`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
const replacement = sqlite.statements.find((statement) =>
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
);
expect(replacement).toBeDefined();
expect(replacement).not.toContain('REFERENCES playlists');
});
it('skips the downloads rebuild when the table already has the paused contract', () => {
+16 -8
View File
@@ -111,8 +111,7 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
updated_at TEXT DEFAULT (datetime('now'))
)`;
const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
@@ -976,9 +975,9 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
}
/**
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
* alter CHECK constraints in place, so rebuild only when the table SQL still
* reflects the old contract or lacks request_headers.
* Downloads remain locally owned after their source playlist is removed.
* SQLite cannot alter CHECK or foreign-key constraints in place, so rebuild
* when the table still has the old pause/header contract or source cascade.
*/
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
try {
@@ -993,7 +992,10 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
const hasPausedStatus = row.sql.includes(`'paused'`);
const hasRequestHeaders = row.sql.includes('request_headers');
if (hasPausedStatus && hasRequestHeaders) {
const hasPlaylistForeignKey = /\bREFERENCES\s+["`[]?playlists\b/i.test(
row.sql
);
if (hasPausedStatus && hasRequestHeaders && !hasPlaylistForeignKey) {
return;
}
@@ -1004,6 +1006,10 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
const legacyMetadataSnapshotSelect = hasMetadataSnapshot
? 'metadata_snapshot'
: 'NULL AS metadata_snapshot';
const hasResumeValidator = row.sql.includes('resume_validator');
const legacyResumeValidatorSelect = hasResumeValidator
? 'resume_validator'
: 'NULL AS resume_validator';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
@@ -1036,6 +1042,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
file_path,
poster_url,
request_headers,
resume_validator,
metadata_snapshot,
status,
bytes_downloaded,
@@ -1058,6 +1065,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
file_path,
poster_url,
${legacyHeadersSelect},
${legacyResumeValidatorSelect},
${legacyMetadataSnapshotSelect},
status,
bytes_downloaded,
@@ -1075,9 +1083,9 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
});
rebuild();
console.log('[DB] Rebuilt downloads table with pause/resume schema');
console.log('[DB] Rebuilt downloads table with local ownership schema');
} catch (error) {
console.warn('[DB] downloads pause/resume migration failed:', error);
console.warn('[DB] downloads ownership migration failed:', error);
}
}
+1 -3
View File
@@ -313,9 +313,7 @@ export const downloads = sqliteTable(
'downloads',
{
id: integer('id').primaryKey({ autoIncrement: true }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
playlistId: text('playlist_id').notNull(),
// Content identifiers
xtreamId: integer('xtream_id').notNull(),
contentType: text('content_type', {