feat(downloads): redesign download manager (#1313)

* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

+178 -3
View File
@@ -1,4 +1,11 @@
import { execFileSync } from 'node:child_process';
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 { categories, downloads } from './schema';
function compactSql(statement: string): string {
return statement.replace(/\s+/g, ' ').trim();
@@ -17,6 +24,117 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
.filter((name): name is string => Boolean(name));
}
function rebuildDownloadsInElectron(metadataSnapshot: string): {
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
} {
const electronPath = createRequire(__filename)('electron') as string;
const connectionUrl = pathToFileURL(
resolve(__dirname, 'connection.ts')
).href;
const script = `
const { default: Database } = await import('better-sqlite3');
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(
connectionUrl
)});
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL,
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,
metadata_snapshot TEXT,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN (
'queued',
'downloading',
'completed',
'failed',
'canceled'
)),
bytes_downloaded INTEGER,
total_bytes INTEGER,
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 (?)')
.run('playlist-1');
sqlite.prepare(\`
INSERT INTO downloads (
playlist_id,
xtream_id,
content_type,
title,
url,
metadata_snapshot
) VALUES (?, ?, ?, ?, ?, ?)
\`).run(
'playlist-1',
42,
'vod',
'Offline title',
'https://example.com/movie',
metadataSnapshot
);
console.log = () => undefined;
__databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite);
const row = sqlite.prepare(
'SELECT metadata_snapshot FROM downloads WHERE id = 1'
).get();
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,
}));
`;
const output = execFileSync(
electronPath,
['--import', 'tsx', '--eval', script],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
},
}
);
return JSON.parse(output) as {
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
};
}
describe('database schema statements', () => {
afterEach(() => {
jest.restoreAllMocks();
@@ -85,6 +203,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');
@@ -102,8 +231,15 @@ 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');
expect(downloadColumns).toContain('metadata_snapshot');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
@@ -149,6 +285,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,7 +627,8 @@ 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]).not.toContain('resume_validator');
expect(statements[copyIndex]).toContain('NULL AS metadata_snapshot');
expect(statements[copyIndex]).toContain('NULL AS resume_validator');
});
it('copies stored headers when only the paused status is missing', () => {
@@ -506,6 +646,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 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', () => {
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')))`
@@ -518,8 +694,7 @@ describe('database schema statements', () => {
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
const { backfillEpgProgramSourceUrls } = __databaseConnectionTestHooks;
let updateStatement = '';
const backfillRun = jest
.fn()
+31 -21
View File
@@ -105,13 +105,13 @@ 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,
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)`,
@@ -379,6 +379,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 = [
@@ -579,8 +581,7 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
{ value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
@@ -697,8 +698,7 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
{ value?: unknown } | undefined;
// The marker alone is not evidence. `createTables` declares this table
// too, with the plain tokenizer, so a table recreated by that path
@@ -759,8 +759,7 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(CONTENT_TITLE_FTS_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
{ value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
@@ -803,8 +802,7 @@ function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
{ value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
@@ -938,8 +936,7 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
{ value?: unknown } | undefined;
if (migrationState?.value === 'done') {
return;
@@ -978,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 {
@@ -995,13 +992,24 @@ 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;
}
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 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(
@@ -1034,6 +1042,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
file_path,
poster_url,
request_headers,
resume_validator,
metadata_snapshot,
status,
bytes_downloaded,
total_bytes,
@@ -1055,6 +1065,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
file_path,
poster_url,
${legacyHeadersSelect},
${legacyResumeValidatorSelect},
${legacyMetadataSnapshotSelect},
status,
bytes_downloaded,
total_bytes,
@@ -1064,18 +1076,16 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
FROM downloads_pause_resume_legacy`
)
.run();
sqliteDb
.prepare(`DROP TABLE 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');
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);
}
}
+6 -7
View File
@@ -115,9 +115,7 @@ export const content = sqliteTable(
categoryIdx: index('idx_content_category').on(table.categoryId),
titleIdx: index('idx_content_title').on(table.title),
xtreamIdx: index('idx_content_xtream').on(table.xtreamId),
epgChannelIdx: index('idx_content_epg_channel').on(
table.epgChannelId
),
epgChannelIdx: index('idx_content_epg_channel').on(table.epgChannelId),
categoryTypeXtreamUnique: uniqueIndex(
'content_category_type_xtream_unique'
).on(table.categoryId, table.type, table.xtreamId),
@@ -315,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', {
@@ -335,6 +331,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: [
@@ -376,7 +373,9 @@ export const tmdbMetadata = sqliteTable(
'tmdb_metadata',
{
id: integer('id').primaryKey({ autoIncrement: true }),
mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(),
mediaType: text('media_type', {
enum: ['movie', 'tv', 'person'],
}).notNull(),
lookupKey: text('lookup_key').notNull(),
language: text('language').notNull(),
tmdbId: integer('tmdb_id'),