From 0c13c3ad8c7436bcf7dc1ee0aefd9542ccd60af2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 8 Sep 2026 03:48:01 +0200 Subject: [PATCH] fix(downloads): recover proven archive completions before retry --- .../src/xtream-catchup-timezone.e2e.ts | 23 +++- .../download-catchup-recover-completion.ts | 59 ++++++++ .../events/database/download-requests.spec.ts | 126 ++++++++++++++++-- .../app/events/database/download-requests.ts | 16 ++- .../database/download-resume-requests.ts | 29 +++- docs/architecture/download-manager.md | 6 +- 6 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/download-catchup-recover-completion.ts diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts index 651efe54d..01b57eb37 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -395,7 +395,28 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l readFileSync('apps/xtream-mock-server/src/fixtures/live.mpegts') ); expect(captured).toEqual([]); - // Repeated clicks on the same programme reuse its identity. + // A failed completion status write must recover the proven file in place + // on a repeated EPG submission, without a duplicate transfer. + await app.electronApp.evaluate( + (_electron, { dependency, file, id }) => { + const Database = process + .getBuiltinModule('module') + .createRequire(dependency)(dependency); + const db = new Database(file); + try { + db.prepare( + "UPDATE downloads SET status='failed' WHERE id=?" + ).run(id); + } finally { + db.close(); + } + }, + { + dependency: join(workspaceRoot, 'node_modules/better-sqlite3'), + file: join(dataDir, 'databases/iptvnator.db'), + id: row.id, + } + ); await block.locator('.epg-timeline__info').click(); await app.mainWindow .getByRole('dialog') diff --git a/apps/electron-backend/src/app/events/database/download-catchup-recover-completion.ts b/apps/electron-backend/src/app/events/database/download-catchup-recover-completion.ts new file mode 100644 index 000000000..28435c744 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-catchup-recover-completion.ts @@ -0,0 +1,59 @@ +import { and, eq, sql } from 'drizzle-orm'; +import * as schema from '../../database/schema'; +import { broadcastDownloadUpdate } from './download-broadcast'; +import { + readArchiveFinalizations, + recordArchiveCleanupPath, + verifiedArchiveSize, +} from './download-catchup-journal'; +import { removeJournaledCatchupPartial } from './download-catchup-removal'; +import type { DownloadsDatabase } from './download-task'; + +/** Restore a proven final before a user request can detach it or transfer again. */ +export async function recoverStoredCatchupCompletion( + db: DownloadsDatabase, + item: Pick, + isBusy: () => boolean +): Promise { + if (item.contentType !== 'catchup' || !item.filePath) return false; + const proof = (await readArchiveFinalizations(db, [item.id])).get(item.id); + const assertIdle = () => { + if (isBusy()) throw new Error('Download already in progress'); + }; + assertIdle(); + const size = verifiedArchiveSize(item.filePath, proof); + if (size === null) return false; + // A failed source cleanup keeps its journal for later Remove/Clear retry. + try { + removeJournaledCatchupPartial(item.filePath, proof, (path) => { + if (proof) recordArchiveCleanupPath(db, item.id, proof, path); + }); + } catch (error) { + console.error( + '[Downloads] Retaining recovered archive cleanup for retry:', + error + ); + } + if (verifiedArchiveSize(item.filePath, proof) !== size) return false; + const result = await db + .update(schema.downloads) + .set({ + status: 'completed', + bytesDownloaded: size, + totalBytes: size, + errorMessage: null, + resumeValidator: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and( + eq(schema.downloads.id, item.id), + eq(schema.downloads.status, item.status), + eq(schema.downloads.filePath, item.filePath) + ) + ); + if (result && 'changes' in result && result.changes === 0) + throw new Error('Download changed during completion recovery'); + broadcastDownloadUpdate(); + return true; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 5b460fde3..653bad146 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -72,6 +72,7 @@ async function setupStartMetadataRequest( })); jest.doMock('./download-runtime', () => ({ enqueueDownload, + hasRuntimeDownload: jest.fn().mockReturnValue(false), })); jest.doMock('./download-file-availability', () => ({ getDownloadFileAvailabilityAsync, @@ -1111,20 +1112,23 @@ describe('catch-up submissions restarting terminal rows', () => { programmeStart: 100, }) ); - await expect( - h.startDownloadRequest( - { - contentType: 'catchup', - catchup, - playlistId: 'playlist-1', - xtreamId: 77, - title: 'Show', - url: 'https://provider.test/archive.ts', - downloadFolder: directory, - }, - h.authorizer - ) - ).resolves.toMatchObject({ success: !locked }); + const result = h.startDownloadRequest( + { + contentType: 'catchup', + catchup, + playlistId: 'playlist-1', + xtreamId: 77, + title: 'Show', + url: 'https://provider.test/archive.ts', + downloadFolder: directory, + }, + h.authorizer + ); + if (locked) await expect(result).rejects.toThrow('SQLITE_BUSY'); + else + await expect(result).resolves.toMatchObject({ + success: true, + }); expect(await readFile(filePath + '.part', 'utf8')).toBe( 'unrelated replacement' ); @@ -1149,3 +1153,97 @@ describe('catch-up submissions restarting terminal rows', () => { } ); }); + +it.each([ + ['failed', 'start'], + ['canceled', 'start'], + ['failed', 'retry'], + ['canceled', 'retry'], + ['paused', 'resume'], +] as const)( + 'restores a journal-proven %s archive on %s instead of downloading again', + async (status, action) => { + const directory = await mkdtemp( + join(tmpdir(), 'archive-completed-retry-') + ); + const filePath = join(directory, 'show.ts'); + try { + await writeFile(filePath, 'complete archive'); + const identity = await lstat(filePath); + const catchup = { + channelName: 'News', + startTimestamp: 100, + stopTimestamp: 200, + }; + jest.doMock('./download-catchup-journal', () => ({ + ...jest.requireActual('./download-catchup-journal'), + readArchiveFinalizations: jest.fn( + async () => + new Map([ + [ + 42, + { + version: 1, + filePath, + size: identity.size, + partialIdentity: identity, + finalIdentity: identity, + }, + ], + ]) + ), + })); + const h = await setupStartMetadataRequest( + createStartDownloadRow({ + id: 42, + contentType: 'catchup', + status, + filePath, + catchup, + programmeStart: 100, + }) + ); + const result = + action === 'start' + ? h.startDownloadRequest( + { + contentType: 'catchup', + catchup, + playlistId: 'playlist-1', + xtreamId: 77, + title: 'Show', + url: 'https://provider.test/archive.ts', + downloadFolder: directory, + }, + h.authorizer + ) + : action === 'retry' + ? ( + await import('./download-resume-requests') + ).retryDownloadRequest(42, directory, h.authorizer) + : ( + await import('./download-resume-requests') + ).resumeDownloadRequest(42, directory, h.authorizer); + await expect(result).resolves.toMatchObject( + action === 'start' + ? { success: false, reason: 'already-downloaded' } + : { success: true } + ); + expect(h.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + bytesDownloaded: identity.size, + totalBytes: identity.size, + }) + ); + expect(h.set).not.toHaveBeenCalledWith( + expect.objectContaining({ filePath: null }) + ); + expect(h.enqueueDownload).not.toHaveBeenCalled(); + expect(await readFile(filePath, 'utf8')).toBe('complete archive'); + } finally { + jest.dontMock('./download-catchup-journal'); + await rm(directory, { recursive: true, force: true }); + } + } +); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 409050c06..5a543ddf4 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -6,6 +6,7 @@ import { type StartDownloadRequest, } from './download-request-options'; export type { StartDownloadRequest } from './download-request-options'; +import { recoverStoredCatchupCompletion } from './download-catchup-recover-completion'; import { cleanupStoredCatchupPartial } from './download-catchup-removal'; import { catchupForDownload } from './download-catchup'; import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces'; @@ -24,7 +25,7 @@ import { decodeDownloadMetadataSnapshot, encodeDownloadMetadataSnapshot, } from './download-metadata-snapshot'; -import { enqueueDownload } from './download-runtime'; +import { enqueueDownload, hasRuntimeDownload } from './download-runtime'; export async function startDownloadRequest( data: StartDownloadRequest, @@ -119,6 +120,19 @@ export async function startDownloadRequest( }; } + if ( + await recoverStoredCatchupCompletion(db, item, () => + hasRuntimeDownload(item.id) + ) + ) { + return { + id: item.id, + success: false, + error: 'Download already completed', + reason: ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyDownloaded, + }; + } + if ( ['completed', 'failed', 'canceled'].includes(item.status) && item.filePath diff --git a/apps/electron-backend/src/app/events/database/download-resume-requests.ts b/apps/electron-backend/src/app/events/database/download-resume-requests.ts index 59bc3b7e4..9e996517c 100644 --- a/apps/electron-backend/src/app/events/database/download-resume-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-resume-requests.ts @@ -1,3 +1,4 @@ +import { recoverStoredCatchupCompletion } from './download-catchup-recover-completion'; import { and, eq, sql } from 'drizzle-orm'; import { basename, dirname } from 'node:path'; import { getDatabase } from '../../database/connection'; @@ -7,7 +8,7 @@ import { DownloadDirectoryAuthorizer } from './download-directory-authorization' import { catchupForDownload } from './download-catchup'; import { sanitizeFilename, createFileName } from './download-request-options'; import { resolveStoredDownloadHeaders } from './download-request-headers'; -import { enqueueDownload } from './download-runtime'; +import { enqueueDownload, hasRuntimeDownload } from './download-runtime'; export async function retryDownloadRequest( downloadId: number, @@ -27,8 +28,6 @@ export async function retryDownloadRequest( } const item = existing[0]; - const catchup = catchupForDownload(item); - await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); if (!['failed', 'canceled'].includes(item.status)) { return { error: 'Can only retry failed or canceled downloads', @@ -36,8 +35,19 @@ export async function retryDownloadRequest( }; } + if ( + await recoverStoredCatchupCompletion(db, item, () => + hasRuntimeDownload(item.id) + ) + ) + return { success: true }; + const catchup = catchupForDownload(item); + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + const retainedFilePath = - item.status === 'failed' && item.filePath ? item.filePath : null; + (item.status === 'failed' || catchup) && item.filePath + ? item.filePath + : null; // A retained filePath was written by the main process after its folder // was authorized; requiring the folder to still be the CURRENT selection // would strand the retry after the user switches download folders. @@ -103,8 +113,6 @@ export async function resumeDownloadRequest( } const item = existing[0]; - const catchup = catchupForDownload(item); - await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); if (item.status !== 'paused') { return { error: 'Can only resume paused downloads', @@ -112,6 +120,15 @@ export async function resumeDownloadRequest( }; } + if ( + await recoverStoredCatchupCompletion(db, item, () => + hasRuntimeDownload(item.id) + ) + ) + return { success: true }; + const catchup = catchupForDownload(item); + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + // See retryDownloadRequest: DB-recorded retained paths stay usable after // the user switches download folders. const directory = item.filePath diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 4fbd0a4d4..c02ee50f7 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -93,7 +93,9 @@ canceled first, and a concurrent new runtime attempt blocks removal. A settled archive still marked downloading after a failed status write also retries journal cleanup before row deletion. Remove/Clear preserve a final file whose journaled identity and full size prove completed promotion, even when completion -status writes failed and its stored status is stale. Remove, Clear completed and missing-file +status writes failed and its stored status is stale. Repeat submissions, Retry +and Resume restore such a journal-proven completion in place before any new +transfer or ownership reset; retained cleanup failures keep their journal. Remove, Clear completed and missing-file re-download and repeated programme submissions use journal-backed private capture for archive partial cleanup; unknown or replaced entries are preserved. Before capture, a synchronous SQLite @@ -148,7 +150,7 @@ available after restart and after the source archive expires. - **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)** `DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, retry/resume flows in `download-resume-requests.ts`, removal/terminal cleanup in `download-removal-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts` (ordinary file promotion in `download-file-finalize.ts`, archive completion in - `download-catchup-completion.ts`, target reservation in `download-runtime-reservation.ts`), cancellation/pause persistence in `download-runtime-persistence.ts`, and the renderer update broadcast in `download-broadcast.ts`. + `download-catchup-completion.ts`, proven completion recovery in `download-catchup-recover-completion.ts`, target reservation in `download-runtime-reservation.ts`), cancellation/pause persistence in `download-runtime-persistence.ts`, and the renderer update broadcast in `download-broadcast.ts`. - **Range-aware transfer (`download-transfer.ts`)** The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`, and always requests `Accept-Encoding: identity`: Range offsets, totals, and the persisted `.part` must describe the same representation, and Axios's transparent gzip/brotli decoding would put decoded bytes on disk while every counter speaks encoded bytes. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; a partial carrying that validator resumes with `Range: bytes=-` plus `If-Range`, so the server itself proves the entity is unchanged. A retained partial **without** a validator resumes through overlap verification instead (`download-overlap.ts`): the `Range` request rewinds by up to 256 KiB (`OVERLAP_VERIFICATION_BYTES`) and a transform stream compares that replayed window byte-for-byte against the partial's tail before anything is appended — a self-made validator for the many Xtream panels that send neither header. A mismatching overlap truncates the `.part` and restarts the transfer from byte zero (`OverlapMismatchError`); a partial smaller than the overlap window is verified in full from byte zero over a plain request and appended to — never rewritten in place, so a reconnect that dies early can only grow the file. Success requires the verifier to have consumed its ENTIRE window: a response that ends inside the overlap is an ordinary retained interruption when the stream died early, but a response that delivered its complete AUTHORITATIVE total inside the window — whether it then closed cleanly or reset — proves the remote entity shrank and restarts from scratch; the old suffix is never finalized as a completed file. An HTTP 416 answer to a resume request is classified by `classifyRangeNotSatisfiable()`: it COMPLETES only an exact-EOF request with identity proof (`If-Range`-backed, or the EOF probe that follows a fully verified overlap replay) whose stated `bytes */N` equals the partial — a bare length match on a rewound request proves nothing about whose bytes are on disk; it RESTARTS only when a STATED total proves the entity shrank — the total sits below a rewound request's first byte, or at it (the rewound range beginning exactly at the new EOF), or below the partial at an exact-EOF request; every length-less, ambiguous, or contradictory 416 RETAINS the partial, and none of these paths ever reaches generic cleanup. A validator promoted by a complete overlap match survives mid-append failures too: the promotion also runs on the error path, and retained-failure and pause persistence write `resume_validator` from the task, so later attempts resume via `If-Range` instead of replaying the window — without this, a server whose per-connection cap barely exceeds the window would stall out on sub-threshold progress. A verify-append attempt promotes the response's `ETag`/`Last-Modified` onto the row only after the complete overlap matched; until then the retained bytes are unproven and blessing them with a validator would let the next resume `If-Range`-append onto a foreign prefix. The response's TOTAL stays equally uncommitted (task and row) until the overlap matched — a persisted total equal to the unverified partial's size would let the completed-partial shortcut finalize unproven bytes after a pause, crash, or retained failure. Retained-interruption persistence keeps the live task in sync with the row (a stale falsified total would make the next reconnect's resume-offset guard reject the partial). Overlap replay re-counts bytes from the rewound offset, so reported progress is floored at the partial's retained size whenever the transfer appends — a response that ends inside the overlap can never move displayed progress backwards. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download. - **Destination collision policy**