mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user