mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(downloads): preserve offline library ownership
This commit is contained in:
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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in new issue
Block a user