fix(downloads): recover proven archive completions before retry

This commit is contained in:
4gray committed 2026-09-08 03:48:01 +02:00
1 parent 24ee7eac4b
commit 0c13c3ad8c
6 files changed
+235 -24

No files matched your search

@@ -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')
@@ -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<schema.Download, 'id' | 'contentType' | 'filePath' | 'status'>,
isBusy: () => boolean
): Promise<boolean> {
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;
}
@@ -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 });
}
}
);
@@ -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
@@ -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
+4 -2
View File
@@ -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=<offset>-` 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 Line truncated
- **Destination collision policy**