feat(downloads): pause and resume support for the download manager (#1147)

Adds a paused state to the Electron download manager with a full partial-file lifecycle:

- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.

Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
genrichh93-ui authored and GitHub committed 2026-07-24 18:40:30 +02:00
1 parent 0273ded8e2
commit 188f5c4b56
63 files changed
+4986 -683

No files matched your search

@@ -25,6 +25,39 @@ describe('database schema statements', () => {
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
} = __databaseConnectionTestHooks;
const ensureDownloadsPauseResumeSchema = (
__databaseConnectionTestHooks as unknown as {
ensureDownloadsPauseResumeSchema: (sqlite: {
prepare: (statement: string) => {
get?: () => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).ensureDownloadsPauseResumeSchema;
function createRebuildSqlite(legacyTableSql: string | undefined) {
const statements: string[] = [];
const transaction = jest.fn((callback: () => void) => callback);
const prepare = jest.fn((statement: string) => {
if (statement.includes('FROM sqlite_master')) {
return {
get: () =>
legacyTableSql === undefined
? undefined
: { sql: legacyTableSql },
};
}
return {
run: () => {
statements.push(compactSql(statement));
},
};
});
return { prepare, statements, transaction };
}
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
const schemaSql = createTableStatements.map(compactSql).join('\n');
@@ -45,6 +78,9 @@ describe('database schema statements', () => {
'CREATE TABLE IF NOT EXISTS playback_positions'
);
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
expect(schemaSql).toContain('request_headers TEXT');
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
);
@@ -383,6 +419,80 @@ describe('database schema statements', () => {
);
});
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
const { statements } = sqlite;
const renameIndex = statements.findIndex((statement) =>
statement.startsWith('ALTER TABLE downloads RENAME TO')
);
const createIndex = statements.findIndex((statement) =>
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
);
const copyIndex = statements.findIndex((statement) =>
statement.startsWith('INSERT INTO downloads')
);
const dropLegacyIndex = statements.findIndex((statement) =>
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
);
expect(renameIndex).toBeGreaterThanOrEqual(0);
expect(createIndex).toBeGreaterThan(renameIndex);
expect(copyIndex).toBeGreaterThan(createIndex);
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
// Indexes on the old table are dropped before the rename and
// recreated after the copy.
expect(
statements.filter((statement) =>
statement.startsWith('DROP INDEX IF EXISTS')
).length
).toBe(3);
expect(
statements
.slice(dropLegacyIndex + 1)
.filter((statement) => statement.includes('CREATE')).length
).toBe(3);
// The rebuilt table carries the new contract; legacy rows get NULL
// stored headers, and the copy never references resume_validator so
// it defaults to NULL.
expect(statements[createIndex]).toContain(`'paused'`);
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]).not.toContain('resume_validator');
});
it('copies stored headers when only the paused status is missing', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
const copy = sqlite.statements.find((statement) =>
statement.startsWith('INSERT INTO downloads')
);
expect(copy).toBeDefined();
expect(copy).not.toContain('NULL AS request_headers');
expect(copy).toContain('request_headers');
});
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')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).not.toHaveBeenCalled();
expect(sqlite.statements).toHaveLength(0);
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
+136 -24
View File
@@ -85,6 +85,34 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
request_headers TEXT,
resume_validator TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
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
)`;
const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
@@ -293,30 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC)`,
// Downloads table
`CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
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
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
DOWNLOADS_TABLE_SQL,
...DOWNLOADS_INDEX_STATEMENTS,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
@@ -354,6 +360,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`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`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -372,6 +380,7 @@ export const __databaseConnectionTestHooks = {
createTableStatements: CREATE_TABLE_STATEMENTS,
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
ensureDownloadsPauseResumeSchema,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
@@ -820,12 +829,115 @@ 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.
*/
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
try {
const row = sqliteDb
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
)
.get() as { sql?: string } | undefined;
if (!row?.sql) {
return;
}
const hasPausedStatus = row.sql.includes(`'paused'`);
const hasRequestHeaders = row.sql.includes('request_headers');
if (hasPausedStatus && hasRequestHeaders) {
return;
}
const legacyHeadersSelect = hasRequestHeaders
? 'request_headers'
: 'NULL AS request_headers';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
);
if (match?.[1]) {
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
}
}
sqliteDb
.prepare(
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
)
.run();
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
sqliteDb
.prepare(
`INSERT INTO downloads (
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
request_headers,
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
)
SELECT
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
${legacyHeadersSelect},
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
FROM downloads_pause_resume_legacy`
)
.run();
sqliteDb
.prepare(`DROP TABLE downloads_pause_resume_legacy`)
.run();
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
sqliteDb.prepare(statement).run();
}
});
rebuild();
console.log('[DB] Rebuilt downloads table with pause/resume schema');
} catch (error) {
console.warn('[DB] downloads pause/resume migration failed:', error);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
cleanupLegacyTmdbSearchCache(sqliteDb);
ensureDownloadsPauseResumeSchema(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
+10 -1
View File
@@ -332,9 +332,18 @@ export const downloads = sqliteTable(
fileName: text('file_name'),
filePath: text('file_path'),
posterUrl: text('poster_url'),
requestHeaders: text('request_headers'),
resumeValidator: text('resume_validator'),
// Download progress
status: text('status', {
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
enum: [
'queued',
'downloading',
'paused',
'completed',
'failed',
'canceled',
],
})
.notNull()
.default('queued'),