mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(downloads): recover proven archive completions before retry
This commit is contained in:
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
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in new issue
Block a user