feat(downloads): queue season episode downloads (#1357)

* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
This commit is contained in:
4gray authored and GitHub committed 2026-08-03 08:53:44 +02:00
1 parent d3cc18dc72
commit 96facd6f49
79 files changed
+8352 -1255

No files matched your search

@@ -25,6 +25,7 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
}
function rebuildDownloadsInElectron(metadataSnapshot: string): {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -51,6 +52,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -84,20 +86,22 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
content_type,
title,
url,
metadata_snapshot
) VALUES (?, ?, ?, ?, ?, ?)
metadata_snapshot,
episode_identity_scope
) VALUES (?, ?, ?, ?, ?, ?, ?)
\`).run(
'playlist-1',
42,
'vod',
'Offline title',
'https://example.com/movie',
metadataSnapshot
metadataSnapshot,
'stalker-lazy-vod'
);
console.log = () => undefined;
__databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite);
const row = sqlite.prepare(
'SELECT metadata_snapshot FROM downloads WHERE id = 1'
'SELECT metadata_snapshot, episode_identity_scope FROM downloads WHERE id = 1'
).get();
const table = sqlite.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
@@ -109,6 +113,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
).get());
sqlite.close();
process.stdout.write(JSON.stringify({
episodeIdentityScope: row.episode_identity_scope,
metadataSnapshot: row.metadata_snapshot,
retainedAfterPlaylistDelete,
schemaSql: table.sql,
@@ -129,6 +134,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
);
return JSON.parse(output) as {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -240,6 +246,8 @@ describe('database schema statements', () => {
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain('metadata_snapshot TEXT');
expect(downloadColumns).toContain('metadata_snapshot');
expect(schemaSql).toContain('episode_identity_scope TEXT');
expect(downloadColumns).toContain('episode_identity_scope');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
@@ -288,6 +296,9 @@ describe('database schema statements', () => {
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT'
);
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT'
);
});
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
@@ -658,6 +669,7 @@ describe('database schema statements', () => {
expect(rebuilt.schemaSql).toContain(`'paused'`);
expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists');
expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot);
expect(rebuilt.episodeIdentityScope).toBe('stalker-lazy-vod');
expect(rebuilt.retainedAfterPlaylistDelete).toBe(true);
});
@@ -98,6 +98,7 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -381,6 +382,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`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`,
// Series queue: scope coordinate compatibility across provider series modes
`ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -1010,6 +1013,12 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
const legacyResumeValidatorSelect = hasResumeValidator
? 'resume_validator'
: 'NULL AS resume_validator';
const hasEpisodeIdentityScope = row.sql.includes(
'episode_identity_scope'
);
const legacyEpisodeIdentityScopeSelect = hasEpisodeIdentityScope
? 'episode_identity_scope'
: 'NULL AS episode_identity_scope';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
@@ -1036,6 +1045,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
episode_identity_scope,
title,
url,
file_name,
@@ -1059,6 +1069,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
${legacyEpisodeIdentityScopeSelect},
title,
url,
file_name,
+1
View File
@@ -323,6 +323,7 @@ export const downloads = sqliteTable(
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
episodeIdentityScope: text('episode_identity_scope'),
// Download metadata
title: text('title').notNull(),
url: text('url').notNull(),