feat(downloads): download completed Xtream catch-up programmes as TS (#1572)

* feat(epg): copy catch-up programme URLs without changing playback

* feat(downloads): save completed Xtream archive programmes as TS

* fix(epg): let newer archive copy requests supersede pending work

* fix(downloads): protect archive partials and independent submissions

* fix(downloads): verify archive identity through finalization

* fix(downloads): bound archive storage and capture cleanup entries

* fix(downloads): preserve archive ownership across failure paths

* fix(downloads): recover explicitly verified archive completions

* fix(downloads): journal archive promotion before publishing files

* fix(downloads): reset archive proof before an explicit restart

* fix(downloads): preserve archive recovery ownership and interruption

* fix(downloads): verify durable archive identity at resume open

* fix(downloads): fence archive commands during completion commit

* fix(downloads): persist archive ownership throughout its lifecycle

* fix(downloads): protect archive removal and missing-file recovery

* fix(downloads): journal private cleanup captures for recovery

* fix(downloads): journal active archive cleanup before removal

* fix(downloads): clean settled archives before deleting stale rows

* fix(downloads): preserve archive ownership on removal and resubmission

* fix(downloads): recover proven archive completions before retry

* fix(downloads): recover local archives before remote transfer checks

* test(downloads): resolve archive fixture from workspace root

* fix(downloads): distinguish reused archive inodes by creation time

* fix(downloads): bind fresh archive reservations to owned files

* fix(downloads): clean reservations when ownership writes fail

* fix(downloads): commit archive reservation and ownership atomically

* fix(downloads): retain captures until replacement restoration succeeds

* fix(downloads): require durable ownership before cleanup relocation

* fix(downloads): preserve 64-bit archive file identities on Windows

* refactor(release): keep capture fixture constants in their shared module

* fix(downloads): preserve the last link of captured foreign files

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
This commit is contained in:
4gray authored and GitHub committed 2026-09-08 20:33:05 +02:00
1 parent a7f3860102
commit bad8a0991e
117 files changed
+7510 -880

No files matched your search

@@ -1,3 +1,17 @@
import {
getDownloadPlayPaths,
installDownloadPlayCapture,
} from './downloads.e2e-support';
import {
mkdirSync,
linkSync,
readFileSync,
readdirSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
@@ -15,6 +29,7 @@ import {
switchUnifiedCollectionScope,
test,
waitForXtreamWorkspaceReady,
workspaceRoot,
} from './electron-test-fixtures';
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
@@ -308,3 +323,462 @@ test('@epg @xtream @electron copies the archive URL without starting archive pla
await closeElectronApp(app);
}
});
test('@downloads @epg @xtream @electron downloads a completed archive into the local library', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
const credentials = { username: 'epg', password: 'epg' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
let app = await launchElectronApp(dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
try {
const folder = join(dataDir, 'archive-downloads');
mkdirSync(folder, { recursive: true });
await app.electronApp.evaluate(({ dialog }, target) => {
dialog.showOpenDialog = async () =>
({ canceled: false, filePaths: [target] }) as Awaited<
ReturnType<typeof dialog.showOpenDialog>
>;
}, folder);
await app.mainWindow.evaluate(async () => {
await window.electron.downloadsSelectFolder();
});
await addXtreamPortal(app.mainWindow, {
name: 'Archive downloads',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
const captured = captureTimeshiftRequests(app.mainWindow);
const block = app.mainWindow
.locator('app-epg-timeline .epg-timeline__block')
.filter({ hasText: PAST_PROGRAM })
.first();
await expect(block).toBeVisible({ timeout: 20000 });
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText('Programme added to Downloads', {
exact: true,
})
).toBeVisible();
await expect
.poll(
async () => {
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
return rows.find((row) => row.contentType === 'catchup')
?.status;
},
{ timeout: 30000 }
)
.toBe('completed');
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
const row = rows.find((entry) => entry.contentType === 'catchup');
expect(row?.catchup?.channelName).toBe(CHANNEL);
expect(row?.title).toBe(PAST_PROGRAM);
expect(row?.filePath).toMatch(/archive-downloads.*\.ts$/);
if (!row?.filePath) throw new Error('No archive file');
expect(readFileSync(row.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
expect(captured).toEqual([]);
const ownership = await app.electronApp.evaluate(
(_electron, { dependency, file, id, mediaPath }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const proof = JSON.parse(
db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id).proof
);
const stats = process
.getBuiltinModule('fs')
.lstatSync(mediaPath, { bigint: true });
return {
recorded: proof.finalIdentity,
actual: {
dev: String(stats.dev),
ino: String(stats.ino),
},
};
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
mediaPath: row.filePath,
}
);
expect(ownership.recorded).toMatchObject(ownership.actual);
// 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')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText(
'This programme is already in Downloads.',
{ exact: true }
)
).toBeVisible();
expect(
await app.mainWindow.evaluate(
async () => (await window.electron.downloadsGetList()).length
)
).toBe(1);
// Submitting the programme again after its file disappears must use
// journal ownership too, not delete a replacement at the old .part path.
const firstPath = row.filePath;
unlinkSync(firstPath);
writeFileSync(firstPath + '.part', 'unrelated submission partial');
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect
.poll(async () => {
const current = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
return (
current?.status === 'completed' &&
!!current.filePath &&
current.filePath !== firstPath
);
})
.toBe(true);
const resubmitted = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!resubmitted?.filePath)
throw new Error('Missing resubmitted archive');
expect(resubmitted.filePath).not.toBe(firstPath);
expect(readFileSync(firstPath + '.part', 'utf8')).toBe(
'unrelated submission partial'
);
row.filePath = resubmitted.filePath;
expect(readFileSync(row.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
const card = app.mainWindow.getByTestId(
`download-library-catchup-${row.id}`
);
await expect(card).toBeVisible();
await expect(card).toContainText('TV programme');
await expect(card).toContainText(CHANNEL);
await app.mainWindow.screenshot({
path: test.info().outputPath('archive-download-library.png'),
});
await installDownloadPlayCapture(app);
await card
.getByRole('button', { name: `Play: ${PAST_PROGRAM}`, exact: true })
.click();
await expect
.poll(() => getDownloadPlayPaths(app))
.toEqual([row.filePath]);
await expect(app.mainWindow).toHaveURL(
/\/workspace\/downloads(?:\?.*)?$/
);
// Model termination after verified promotion but before the completion
// DB write, including a response without Content-Length. Keep only the
// real SQLite journal written by the transfer; the next process has no task.
const durableProof = await app.electronApp.evaluate(
(_electron, { dependency, file, id }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const journal = db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id) as { proof: string } | undefined;
if (!journal)
throw new Error(
'Archive promotion journal was not persisted'
);
db.prepare(
"UPDATE downloads SET status='downloading', total_bytes=NULL WHERE id=?"
).run(id);
return JSON.parse(journal.proof) as {
version: number;
filePath: string;
size: number;
};
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
}
);
expect(durableProof).toEqual(
expect.objectContaining({
version: 1,
filePath: row.filePath,
size: readFileSync(row.filePath).length,
})
);
// Library remains usable after recovery, including archive metadata.
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
await expect(
app.mainWindow.getByTestId(`download-library-catchup-${row.id}`)
).toBeVisible();
const recovered = await app.mainWindow.evaluate(async () =>
(await window.electron.downloadsGetList()).find(
(entry) => entry.contentType === 'catchup'
)
);
expect(recovered).toEqual(
expect.objectContaining({
id: row.id,
status: 'completed',
filePath: row.filePath,
totalBytes: durableProof.size,
})
);
expect(
readdirSync(folder).filter((name) => name.endsWith('.ts'))
).toHaveLength(1);
// Missing-file recovery must reserve fresh output and preserve a foreign
// entry at the old partial name, even with a completed journal present.
unlinkSync(row.filePath);
writeFileSync(row.filePath + '.part', 'unrelated retained file');
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRedownloadMissing(id),
row.id
)
).toEqual({ success: true });
await expect
.poll(
async () =>
(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id)?.status
)
.toBe('completed');
const redownloaded = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!redownloaded?.filePath)
throw new Error('Missing re-downloaded archive');
expect(redownloaded.filePath).not.toBe(row.filePath);
expect(readFileSync(row.filePath + '.part', 'utf8')).toBe(
'unrelated retained file'
);
expect(readFileSync(redownloaded.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
// Fail owned cleanup after private capture. Its recovery pointer must
// survive a process restart without relying on hardlink restoration.
linkSync(redownloaded.filePath, redownloaded.filePath + '.part');
await app.electronApp.evaluate(() => {
const fs = process.getBuiltinModule('fs');
const unlink = fs.unlinkSync;
fs.unlinkSync = (path) => {
if (String(path).includes('.iptvnator-cleanup-'))
throw Object.assign(new Error('simulated locked capture'), {
code: 'EACCES',
});
return unlink(path);
};
});
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRemove(id),
row.id
)
).toEqual({
success: false,
error: 'Could not delete the partial file',
});
const capturedPath = await app.electronApp.evaluate(
(_electron, { dependency, file, id }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const record = db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id) as { proof: string };
return (
JSON.parse(record.proof) as {
partialCleanupPath: string;
}
).partialCleanupPath;
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
}
);
expect(capturedPath).toContain('.iptvnator-cleanup-');
expect(readFileSync(capturedPath)).toEqual(
readFileSync(redownloaded.filePath)
);
// A foreign replacement in the retained capture must survive restart
// and be discoverable without backend logs. Retain the original too.
renameSync(capturedPath, capturedPath + '.owned');
writeFileSync(capturedPath, 'foreign recovery content');
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
await app.mainWindow
.getByRole('button', { name: 'Clear finished', exact: true })
.click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', { name: 'Clear finished', exact: true })
.click();
const recoveryDialog = app.mainWindow.getByRole('dialog');
await expect(recoveryDialog).toContainText('File recovery needed');
await expect(recoveryDialog).toContainText(capturedPath);
await expect(
recoveryDialog.getByRole('button', {
name: 'Copy recovery path',
exact: true,
})
).toBeVisible();
await app.mainWindow.evaluate(() => {
Object.defineProperty(navigator.clipboard, 'writeText', {
configurable: true,
value: () =>
Promise.reject(new Error('simulated clipboard failure')),
});
});
await recoveryDialog
.getByRole('button', { name: 'Copy recovery path', exact: true })
.click();
await expect(recoveryDialog).toBeVisible();
await expect(recoveryDialog).toContainText(capturedPath);
expect(readFileSync(capturedPath, 'utf8')).toBe(
'foreign recovery content'
);
await recoveryDialog
.getByRole('button', { name: 'Close', exact: true })
.click();
// Simulate explicit user recovery; the app never deletes this copy.
unlinkSync(capturedPath);
renameSync(capturedPath + '.owned', capturedPath);
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
writeFileSync(
redownloaded.filePath + '.part',
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRemove(id),
row.id
)
).toEqual({ success: true });
expect(readFileSync(redownloaded.filePath + '.part', 'utf8')).toBe(
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
@@ -16,6 +16,7 @@ export {
epgPrograms,
playbackPositions,
downloads,
downloadArchiveFinalizations,
recordings,
appState,
// Types
@@ -0,0 +1,86 @@
import { readArchiveStatsSync } from './download-catchup-stats';
import { linkSync, rmdirSync, unlinkSync } from 'node:fs';
import { dirname } from 'node:path';
import {
sameArchiveFileIdentity,
type ArchiveFileIdentity,
} from './download-catchup-output';
import type { ArchiveDownloadProof } from './download-catchup-journal';
export class ArchiveRecoveryRequiredError extends Error {
constructor(
readonly recoveryPath: string,
reason?: unknown
) {
super(
`Archive cleanup preserved an unrelated recovery file: ${recoveryPath}${reason instanceof Error ? `. ${reason.message}` : ''}`
);
}
}
/** Retry a journaled private capture without ever deleting a replacement. */
export function cleanupArchiveCapture(
proof: ArchiveDownloadProof | undefined
): void {
if (!proof) return;
cleanupCapture(
proof.partialCleanupPath,
proof.partialIdentity,
proof.filePath + '.part'
);
if (proof.phase !== 'transfer')
cleanupCapture(
proof.finalCleanupPath,
proof.finalIdentity,
proof.filePath
);
}
function cleanupCapture(
path: string | undefined,
identity: ArchiveFileIdentity,
publicPath: string
): void {
if (!path) return;
let file;
try {
file = readArchiveStatsSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (file) {
if (!(file.isFile() && sameArchiveFileIdentity(file, identity))) {
restoreArchiveReplacement(path, publicPath);
}
unlinkSync(path);
}
try {
rmdirSync(dirname(path));
} catch {
/* never recursively delete captures */
}
}
/** Never unlink a foreign capture after publishing a raceable public link. */
export function restoreArchiveReplacement(
captured: string,
publicPath: string
): never {
try {
const file = readArchiveStatsSync(captured);
try {
linkSync(captured, publicPath);
} catch (error) {
if (
(error as NodeJS.ErrnoException).code !== 'EEXIST' ||
!sameArchiveFileIdentity(readArchiveStatsSync(publicPath), file)
)
throw error;
}
} catch (error) {
throw new ArchiveRecoveryRequiredError(captured, error);
}
// A second process can remove the public link at any time. Only an explicit
// user cleanup of this recovery copy may release its durable journal entry.
throw new ArchiveRecoveryRequiredError(captured);
}
@@ -0,0 +1,108 @@
import {
link,
lstat,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
cleanupCatchupFile,
cleanupCatchupPartial,
} from './download-catchup-cleanup';
jest.mock('node:fs/promises', () => {
const actual = jest.requireActual('node:fs/promises');
return {
...actual,
rename: jest.fn(actual.rename),
link: jest.fn(actual.link),
};
});
const actual =
jest.requireActual<typeof import('node:fs/promises')>('node:fs/promises');
let directory: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'archive-cleanup-'));
jest.mocked(rename).mockReset().mockImplementation(actual.rename);
jest.mocked(link).mockReset().mockImplementation(actual.link);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
async function prepare() {
const path = join(directory, 'show.ts.part');
await writeFile(path, 'archive');
return { path, identity: await lstat(path) };
}
it('removes the captured owned entry and its empty quarantine', async () => {
const { path, identity } = await prepare();
await cleanupCatchupFile(path, identity);
expect(await readdir(directory)).toEqual([]);
});
it('preserves a replacement at the public pathname after atomic capture', async () => {
const { path, identity } = await prepare();
jest.mocked(rename).mockImplementationOnce(async (from, to) => {
await actual.rename(from, to);
await writeFile(from, 'replacement');
});
await cleanupCatchupFile(path, identity);
expect(await readFile(path, 'utf8')).toBe('replacement');
expect(await readdir(directory)).toEqual(['show.ts.part']);
});
it('restores a replacement that arrives before atomic capture', async () => {
const { path, identity } = await prepare();
jest.mocked(rename).mockImplementationOnce(async (from, to) => {
await actual.rename(from, join(directory, 'original'));
await writeFile(from, 'replacement');
await actual.rename(from, to);
});
await expect(cleanupCatchupFile(path, identity)).rejects.toThrow(
'preserved an unrelated recovery file'
);
expect(await readFile(path, 'utf8')).toBe('replacement');
expect(await readFile(join(directory, 'original'), 'utf8')).toBe('archive');
});
it.each(['EEXIST', 'ENOTSUP'])(
'retains a captured replacement if restoring it fails with %s',
async (code) => {
const { path, identity } = await prepare();
await actual.rename(path, join(directory, 'original'));
await writeFile(path, 'replacement');
jest.mocked(link).mockRejectedValueOnce(
Object.assign(new Error('cannot restore'), { code })
);
const warning = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
await expect(cleanupCatchupFile(path, identity)).rejects.toThrow(
'cannot restore'
);
const quarantine = (await readdir(directory)).find((entry) =>
entry.startsWith('.iptvnator-cleanup-')
);
if (!quarantine)
throw new Error('Expected a retained recovery directory');
expect(
await readFile(join(directory, quarantine, 'entry'), 'utf8')
).toBe('replacement');
expect(warning).toHaveBeenCalled();
} finally {
warning.mockRestore();
}
}
);
it('preserves an unopened partial and removes it only with matching ownership', async () => {
const { path } = await prepare();
const final = path.slice(0, -'.part'.length);
expect(await cleanupCatchupPartial(final, undefined)).toBe(false);
expect(await readFile(path, 'utf8')).toBe('archive');
expect(await cleanupCatchupPartial(final, await lstat(path))).toBe(true);
await expect(lstat(path)).rejects.toMatchObject({ code: 'ENOENT' });
});
@@ -0,0 +1,60 @@
import { readArchiveStats } from './download-catchup-stats';
import { link, mkdtemp, rename, rmdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
sameArchiveFileIdentity,
type ArchiveFileIdentity,
} from './download-catchup-output';
/** Capture the directory entry atomically before inspecting or removing it. */
export async function cleanupCatchupFile(
path: string,
identity: ArchiveFileIdentity
): Promise<void> {
// mkdtemp creates an unpredictable, private directory on the same volume.
// Other writers of the public .part/final path cannot race this entry.
const directory = await mkdtemp(join(dirname(path), '.iptvnator-cleanup-'));
const captured = join(directory, 'entry');
try {
await rename(path, captured);
const stats = await readArchiveStats(captured);
if (stats.isFile() && sameArchiveFileIdentity(stats, identity)) {
await unlink(captured);
} else {
// A replacement was captured. Restore without clobbering any newer
// public entry. If restoration is unavailable, retain it privately.
try {
await link(captured, path);
throw new Error(
`Archive cleanup preserved an unrelated recovery file: ${captured}`
);
} catch (error) {
console.warn(
'[Downloads] Replaced file retained for recovery:',
captured
);
throw error;
}
}
} finally {
// Never recursively remove the quarantine: it may hold a replacement or
// a file whose verification/cleanup failed. Empty directories only.
await rmdir(directory).catch(() => undefined);
}
}
/** Failed/canceled transfers may only remove the partial that they opened. */
export async function cleanupCatchupPartial(
filePath: string | null | undefined,
identity: ArchiveFileIdentity | undefined
): Promise<boolean> {
if (!filePath) return true;
const path = filePath + '.part';
try {
if (identity) await cleanupCatchupFile(path, identity);
await readArchiveStats(path);
return false;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}
@@ -0,0 +1,51 @@
import { finalizeCatchupPartial } from './download-catchup-finalize';
import { recordArchiveFinalization } from './download-catchup-journal';
import {
cleanupStoredCatchupPartial,
cleanupStoredCatchupFinal,
} from './download-catchup-removal';
import type { ReservedPartialDownloadFile } from './download-file-path';
import type {
DownloadsDatabase,
DownloadTask,
TransferProgress,
} from './download-task';
export async function promoteCatchupDownload(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile,
progress: TransferProgress
): Promise<number> {
const partialIdentity = task.catchupPartialIdentity;
if (!partialIdentity)
throw new Error('Archive transfer identity is unavailable');
const finalized = await finalizeCatchupPartial(
reservation,
partialIdentity,
progress.bytesDownloaded,
(finalIdentity) =>
recordArchiveFinalization(db, task.id, {
version: 1,
filePath: reservation.path,
size: progress.bytesDownloaded,
partialIdentity,
finalIdentity,
}),
() => !!(task.cancelRequested || task.pauseRequested),
() => (task.catchupCommitStarted = true),
{
partial: () =>
cleanupStoredCatchupPartial(db, task.id, reservation.path),
final: (identity) =>
cleanupStoredCatchupFinal(
db,
task.id,
reservation.path,
identity
),
}
);
task.catchupFinalized = { ...finalized, filePath: reservation.path };
return finalized.size;
}
@@ -0,0 +1,220 @@
import { archiveFileStats } from './download-catchup-stats';
import {
mkdtemp,
lstat,
link,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { finalizeCatchupPartial } from './download-catchup-finalize';
jest.mock('node:fs/promises', () => {
const actual = jest.requireActual('node:fs/promises');
return { ...actual, link: jest.fn(actual.link) };
});
const actualLink =
jest.requireActual<typeof import('node:fs/promises')>(
'node:fs/promises'
).link;
describe('archive file promotion', () => {
let directory: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'archive-promotion-'));
jest.mocked(link).mockImplementation(actualLink);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
async function prepare() {
const path = join(directory, 'show.ts');
const reservation = {
path,
partialPath: path + '.part',
filename: 'show.ts',
};
await writeFile(reservation.partialPath, 'validated bytes');
const identity = await lstat(reservation.partialPath);
return { reservation, identity };
}
it('rejects a same-sized replacement between transfer and promotion', async () => {
const { reservation, identity } = await prepare();
await rename(reservation.partialPath, join(directory, 'original'));
await writeFile(reservation.partialPath, 'untrusted bytes');
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).rejects.toThrow('changed');
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'untrusted bytes'
);
await expect(lstat(reservation.path)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('rejects a replacement during link promotion without deleting the unowned entry', async () => {
const { reservation, identity } = await prepare();
jest.mocked(link).mockImplementationOnce(async (from, to) => {
await rename(from, join(directory, 'original'));
await writeFile(from, 'untrusted bytes');
await actualLink(from, to);
});
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).rejects.toThrow('changed');
expect(await readFile(reservation.path, 'utf8')).toBe(
'untrusted bytes'
);
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'untrusted bytes'
);
});
it('does not claim the identity of a destination replaced after link succeeds', async () => {
const { reservation, identity } = await prepare();
jest.mocked(link).mockImplementationOnce(async (from, to) => {
await actualLink(from, to);
await rename(to, join(directory, 'linked-original'));
await writeFile(to, 'untrusted bytes');
});
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).rejects.toThrow('changed');
expect(await readFile(reservation.path, 'utf8')).toBe(
'untrusted bytes'
);
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'validated bytes'
);
});
it.each(['ENOTSUP', 'EACCES'])(
'copies from the verified descriptor when hardlinks fail with %s',
async (code) => {
const { reservation, identity } = await prepare();
jest.mocked(link).mockImplementationOnce(async (from) => {
await rename(from, join(directory, 'original'));
await writeFile(from, 'untrusted bytes');
throw Object.assign(new Error('unsupported'), { code });
});
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).resolves.toEqual({
size: identity.size,
identity: expect.objectContaining({
dev: expect.any(String),
ino: expect.any(String),
}),
});
expect(await readFile(reservation.path, 'utf8')).toBe(
'validated bytes'
);
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'untrusted bytes'
);
}
);
it('promotes the verified file and removes its partial', async () => {
const { reservation, identity } = await prepare();
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).resolves.toEqual({
size: identity.size,
identity: expect.objectContaining({
dev: expect.any(String),
ino: expect.any(String),
}),
});
expect(await readFile(reservation.path, 'utf8')).toBe(
'validated bytes'
);
await expect(lstat(reservation.partialPath)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it.each(['hardlink', 'copy'])(
'records write-ahead identity before %s can publish complete bytes',
async (mode) => {
const { reservation, identity } = await prepare();
if (mode === 'copy')
jest.mocked(link).mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { code: 'ENOTSUP' })
);
let checkpoints = 0;
await finalizeCatchupPartial(
reservation,
identity,
identity.size,
async (expected) => {
checkpoints++;
if (checkpoints === 1) {
expect(expected).toEqual(
expect.objectContaining({
dev: identity.dev,
ino: identity.ino,
})
);
await expect(
lstat(reservation.path)
).rejects.toMatchObject({ code: 'ENOENT' });
} else {
const created = archiveFileStats(
await lstat(reservation.path)
);
expect(created).toEqual(
expect.objectContaining({
dev: expected.dev,
ino: expected.ino,
size: 0,
})
);
}
}
);
expect(checkpoints).toBe(mode === 'copy' ? 2 : 1);
}
);
it('never overwrites an occupied final destination', async () => {
const { reservation, identity } = await prepare();
await writeFile(reservation.path, 'keep me');
await expect(
finalizeCatchupPartial(reservation, identity, identity.size)
).rejects.toMatchObject({ code: 'EEXIST' });
expect(await readFile(reservation.path, 'utf8')).toBe('keep me');
});
it.each(['pause', 'cancel'])(
'stops an in-progress fallback copy on %s',
async () => {
const { reservation } = await prepare();
const bytes = Buffer.alloc(192 * 1024, 0x47);
await writeFile(reservation.partialPath, bytes);
const identity = await lstat(reservation.partialPath);
jest.mocked(link).mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { code: 'ENOTSUP' })
);
let observedBytes = 0;
await expect(
finalizeCatchupPartial(
reservation,
identity,
bytes.length,
undefined,
() => {
observedBytes = existsSync(reservation.path)
? statSync(reservation.path).size
: 0;
return observedBytes > 0;
}
)
).rejects.toThrow('interrupted');
expect(observedBytes).toBeGreaterThan(0);
expect(observedBytes).toBeLessThan(bytes.length);
expect(await readFile(reservation.partialPath)).toEqual(bytes);
await expect(lstat(reservation.path)).rejects.toMatchObject({
code: 'ENOENT',
});
}
);
});
@@ -0,0 +1,178 @@
import {
archiveFileStats,
readArchiveStats,
type ArchiveFileStats,
} from './download-catchup-stats';
import type { DownloadTask, CompletedPartialProgress } from './download-task';
import { cleanupCatchupFile } from './download-catchup-cleanup';
import { constants } from 'node:fs';
import { link, open } from 'node:fs/promises';
import {
archiveFileIdentity,
sameArchiveFileIdentity,
type ArchiveFileIdentity,
} from './download-catchup-output';
import type { ReservedPartialDownloadFile } from './download-file-path';
function verify(
stats: ArchiveFileStats,
identity: ArchiveFileIdentity,
size: number
): void {
if (
!stats.isFile() ||
!sameArchiveFileIdentity(stats, identity) ||
stats.size !== size
) {
throw new Error('Archive partial changed before promotion');
}
}
/** Verify both sides of promotion; fallback copying reads a verified descriptor. */
export async function finalizeCatchupPartial(
reservation: ReservedPartialDownloadFile,
identity: ArchiveFileIdentity | undefined,
size: number,
recordProof?: (identity: ArchiveFileIdentity) => Promise<void>,
shouldInterrupt: () => boolean = () => false,
beginCommit: () => void = () => undefined,
cleanup?: {
partial: () => Promise<unknown>;
final: (identity: ArchiveFileIdentity) => Promise<unknown>;
}
): Promise<{ size: number; identity: ArchiveFileIdentity }> {
const checkInterruption = () => {
if (shouldInterrupt()) throw new Error('Archive promotion interrupted');
};
checkInterruption();
if (!identity) throw new Error('Archive transfer identity is unavailable');
verify(await readArchiveStats(reservation.partialPath), identity, size);
const source = await open(
reservation.partialPath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
);
let created: ArchiveFileIdentity | undefined;
let sourceClosed = false;
try {
verify(
archiveFileStats(await source.stat({ bigint: true })),
identity,
size
);
// A hardlink can become complete immediately: persist its expected
// identity before publishing it, while the verified partial still exists.
await recordProof?.(identity);
checkInterruption();
try {
await link(reservation.partialPath, reservation.path);
// The link we created belongs to the verified source, even if
// another writer replaces its public name before lstat completes.
created = identity;
const promoted = await readArchiveStats(reservation.path);
verify(promoted, identity, size);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (
created ||
![
'EACCES',
'EPERM',
'EXDEV',
'ENOSYS',
'ENOTSUP',
'EOPNOTSUPP',
].includes(code ?? '')
)
throw error;
// FAT/network filesystems may not support links. Never reopen the
// source by pathname for this copy: it could have been replaced.
const target = await open(reservation.path, 'wx', 0o600);
try {
created = archiveFileStats(await target.stat({ bigint: true }));
// For a copy, record the exclusively created target identity
// before the first byte, so a complete file never lacks proof.
await recordProof?.(created);
const buffer = Buffer.alloc(64 * 1024);
let position = 0;
while (position < size) {
checkInterruption();
const { bytesRead } = await source.read(
buffer,
0,
Math.min(buffer.length, size - position),
position
);
if (bytesRead === 0)
throw new Error(
'Archive partial ended during promotion'
);
let written = 0;
while (written < bytesRead) {
checkInterruption();
const { bytesWritten } = await target.write(
buffer,
written,
bytesRead - written,
position + written
);
if (bytesWritten === 0)
throw new Error(
'Archive promotion made no progress'
);
written += bytesWritten;
}
position += bytesRead;
}
} finally {
await target.close();
}
}
await source.close();
sourceClosed = true;
checkInterruption();
verify(await readArchiveStats(reservation.path), created, size);
checkInterruption();
// Publication is verified. Fence new commands before awaited cleanup
// and the completion write; accepted commands were handled above.
beginCommit();
await (
cleanup
? cleanup.partial()
: cleanupCatchupFile(reservation.partialPath, identity)
).catch(() => undefined);
return { size, identity: archiveFileIdentity(created) };
} catch (error) {
if (created) {
await (
cleanup
? cleanup.final(created)
: cleanupCatchupFile(reservation.path, created)
).catch(() => undefined);
}
throw error;
} finally {
if (!sourceClosed) await source.close();
}
}
/** A DB retry can reuse completion only with explicit, still-current proof. */
export async function recoverCatchupCompletion(
task: DownloadTask
): Promise<CompletedPartialProgress | null> {
const proof = task.catchupFinalized;
if (!proof || proof.filePath !== task.filePath) return null;
try {
const file = await readArchiveStats(proof.filePath);
return file.isFile() &&
sameArchiveFileIdentity(file, proof.identity) &&
file.size === proof.size
? {
filePath: proof.filePath,
bytesDownloaded: proof.size,
totalBytes: proof.size,
}
: null;
} catch {
return null;
}
}
@@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
function runReservationScenario(scenario: string): {
row: { file_path: string | null; file_name: string };
proofs: { download_id: number; proof: string }[];
error?: string;
} {
const journalUrl = pathToFileURL(
resolve(__dirname, 'download-catchup-journal.ts')
).href;
const script = `
const { default: Database } = await import('better-sqlite3');
const { drizzle } = await import('drizzle-orm/better-sqlite3');
const { recordArchiveReservation } = await import(${JSON.stringify(journalUrl)});
const sqlite = new Database(':memory:');
sqlite.exec(\`
CREATE TABLE downloads (id INTEGER PRIMARY KEY, file_path TEXT, file_name TEXT, updated_at TEXT);
CREATE TABLE download_archive_finalizations (download_id INTEGER PRIMARY KEY REFERENCES downloads(id), proof TEXT NOT NULL);
INSERT INTO downloads (id, file_name) VALUES (1, 'show.ts');
\`);
const db = drizzle(sqlite);
const identity = { dev: 1, ino: 2, birthtimeMs: 1000 };
let error;
${scenario}
console.log(JSON.stringify({ error,
row: sqlite.prepare('SELECT file_path, file_name FROM downloads WHERE id=1').get(),
proofs: sqlite.prepare('SELECT * FROM download_archive_finalizations').all()
}));
sqlite.close();
`;
return JSON.parse(
execFileSync(
createRequire(__filename)('electron'),
['--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'
),
},
}
)
);
}
it('commits the reservation path and ownership together before transfer setup', () => {
const result = runReservationScenario(`
await recordArchiveReservation(db, 1, '/archive/show (1).ts', identity, 'show (1).ts');
`);
expect(result.row).toEqual({
file_path: '/archive/show (1).ts',
file_name: 'show (1).ts',
});
expect(JSON.parse(result.proofs[0].proof)).toMatchObject({
phase: 'transfer',
filePath: result.row.file_path,
partialIdentity: { dev: '1', ino: '2', birthtimeMs: 1000 },
});
});
it('rolls back the row path when the ownership write fails', () => {
const result = runReservationScenario(`
sqlite.exec("CREATE TRIGGER reject_proof BEFORE INSERT ON download_archive_finalizations BEGIN SELECT RAISE(ABORT, 'journal unavailable'); END");
try { await recordArchiveReservation(db, 1, '/archive/new.ts', identity, 'new.ts'); }
catch (failure) { error = failure.message; }
`);
expect(result.error).toContain('journal unavailable');
expect(result.row).toEqual({ file_path: null, file_name: 'show.ts' });
expect(result.proofs).toEqual([]);
});
it('rejects a reservation whose download row disappeared', () => {
const result = runReservationScenario(`
try { await recordArchiveReservation(db, 2, '/archive/new.ts', identity, 'new.ts'); }
catch (failure) { error = failure.message; }
`);
expect(result.error).toContain('row is unavailable');
expect(result.proofs).toEqual([]);
expect(result.row.file_path).toBeNull();
});
@@ -0,0 +1,233 @@
import { readArchiveStatsSync } from './download-catchup-stats';
import { cleanupArchiveCapture } from './download-catchup-capture';
import { eq, inArray, sql } from 'drizzle-orm';
import { isAbsolute } from 'node:path';
import * as schema from '../../database/schema';
import {
archiveFileIdentity,
isArchiveFileId,
sameArchiveFileIdentity,
type ArchiveFileIdentity,
} from './download-catchup-output';
import type { DownloadsDatabase } from './download-task';
export interface ArchiveFinalizationProof {
version: 1;
phase?: 'finalization';
filePath: string;
size: number;
partialIdentity: ArchiveFileIdentity;
partialCleanupPath?: string;
finalCleanupPath?: string;
finalIdentity: ArchiveFileIdentity;
}
export interface ArchivePartialProof {
version: 1;
phase: 'transfer';
filePath: string;
partialIdentity: ArchiveFileIdentity;
partialCleanupPath?: string;
finalCleanupPath?: string;
}
export type ArchiveDownloadProof =
ArchiveFinalizationProof | ArchivePartialProof;
export async function recordArchivePartial(
db: DownloadsDatabase,
downloadId: number,
filePath: string,
partialIdentity: ArchiveFileIdentity
): Promise<void> {
await writeArchiveProof(db, downloadId, {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
});
}
/** The row path and its ownership proof must become recoverable together. */
export async function recordArchiveReservation(
db: DownloadsDatabase,
downloadId: number,
filePath: string,
partialIdentity: ArchiveFileIdentity,
fileName: string
): Promise<void> {
db.transaction((tx) => {
const result = tx
.update(schema.downloads)
.set({
filePath,
fileName,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId))
.run();
if (result.changes !== 1)
throw new Error('Archive reservation row is unavailable');
writeArchiveProof(tx, downloadId, {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
}).run();
});
}
export async function recordArchiveFinalization(
db: DownloadsDatabase,
downloadId: number,
proof: ArchiveFinalizationProof
): Promise<void> {
await writeArchiveProof(db, downloadId, proof);
}
function writeArchiveProof(
db: Pick<DownloadsDatabase, 'insert'>,
downloadId: number,
proof: ArchiveDownloadProof
) {
const serialized = JSON.stringify({
...proof,
partialIdentity: archiveFileIdentity(proof.partialIdentity),
...(proof.phase !== 'transfer'
? {
finalIdentity: archiveFileIdentity(proof.finalIdentity),
}
: {}),
});
return db
.insert(schema.downloadArchiveFinalizations)
.values({ downloadId, proof: serialized })
.onConflictDoUpdate({
target: schema.downloadArchiveFinalizations.downloadId,
set: { proof: serialized },
});
}
/** Commit the recovery pointer synchronously before a public entry is captured. */
export function recordArchiveCleanupPath(
db: DownloadsDatabase,
downloadId: number,
proof: ArchiveDownloadProof,
path: string,
kind: 'partial' | 'final' = 'partial'
): void {
const result = db
.update(schema.downloadArchiveFinalizations)
.set({
proof: JSON.stringify({
...proof,
[kind === 'partial'
? 'partialCleanupPath'
: 'finalCleanupPath']: path,
}),
})
.where(eq(schema.downloadArchiveFinalizations.downloadId, downloadId))
.run();
if (result.changes !== 1)
throw new Error('Archive cleanup ownership is unavailable');
}
/** Fresh reservations must never inherit an earlier attempt's proof. */
export async function clearArchiveFinalization(
db: DownloadsDatabase,
downloadId: number
): Promise<void> {
cleanupArchiveCapture(
(await readArchiveFinalizations(db, [downloadId])).get(downloadId)
);
await db
.delete(schema.downloadArchiveFinalizations)
.where(eq(schema.downloadArchiveFinalizations.downloadId, downloadId));
}
function identity(value: unknown): value is ArchiveFileIdentity {
if (!value || typeof value !== 'object') return false;
const candidate = value as ArchiveFileIdentity;
return (
isArchiveFileId(candidate.dev) &&
isArchiveFileId(candidate.ino) &&
Number.isFinite(candidate.birthtimeMs) &&
candidate.birthtimeMs > 0
);
}
export function parseArchiveFinalization(
value: string
): ArchiveDownloadProof | undefined {
try {
const proof = JSON.parse(value) as ArchiveDownloadProof;
if (
!proof ||
proof.version !== 1 ||
typeof proof.filePath !== 'string' ||
!isAbsolute(proof.filePath) ||
!identity(proof.partialIdentity) ||
(proof.partialCleanupPath !== undefined &&
(typeof proof.partialCleanupPath !== 'string' ||
!isAbsolute(proof.partialCleanupPath)))
)
return undefined;
if (
proof.finalCleanupPath !== undefined &&
(typeof proof.finalCleanupPath !== 'string' ||
!isAbsolute(proof.finalCleanupPath))
)
return undefined;
if (proof.phase === 'transfer')
return proof.finalCleanupPath === undefined ? proof : undefined;
return (proof.phase === undefined || proof.phase === 'finalization') &&
Number.isSafeInteger(proof.size) &&
proof.size > 0 &&
identity(proof.finalIdentity)
? proof
: undefined;
} catch {
return undefined;
}
}
export async function readArchiveFinalizations(
db: DownloadsDatabase,
ids: number[]
): Promise<Map<number, ArchiveDownloadProof>> {
if (ids.length === 0) return new Map();
const result = new Map<number, ArchiveDownloadProof>();
for (let offset = 0; offset < ids.length; offset += 500) {
const rows = await db
.select()
.from(schema.downloadArchiveFinalizations)
.where(
inArray(
schema.downloadArchiveFinalizations.downloadId,
ids.slice(offset, offset + 500)
)
);
for (const row of rows) {
const proof = parseArchiveFinalization(row.proof);
if (proof) result.set(row.downloadId, proof);
}
}
return result;
}
export function verifiedArchiveSize(
filePath: string | null,
proof: ArchiveDownloadProof | undefined
): number | null {
if (!proof || proof.phase === 'transfer' || proof.filePath !== filePath)
return null;
try {
const file = readArchiveStatsSync(proof.filePath);
return file.isFile() &&
sameArchiveFileIdentity(file, proof.finalIdentity) &&
file.size === proof.size
? proof.size
: null;
} catch {
return null;
}
}
@@ -0,0 +1,83 @@
import { statfs } from 'node:fs/promises';
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
ARCHIVE_DISK_RESERVE,
assertArchiveCopyHeadroom,
createArchiveByteGuard,
getArchiveByteLimit,
} from './download-catchup-limits';
jest.mock('node:fs/promises', () => ({ statfs: jest.fn() }));
const disk = (bytes: number) =>
({ bavail: bytes, bsize: 1 }) as Awaited<ReturnType<typeof statfs>>;
beforeEach(() => jest.mocked(statfs).mockReset());
it('bounds even undeclared streams by duration, a hard cap and free space', async () => {
jest.mocked(statfs).mockResolvedValue(disk(1000 * 1024 ** 3));
expect(await getArchiveByteLimit('/downloads', 60, null)).toBe(
120 * 12_500_000
);
expect(await getArchiveByteLimit('/downloads', 86400, null)).toBe(
64 * 1024 ** 3
);
jest.mocked(statfs).mockResolvedValue(disk(ARCHIVE_DISK_RESERVE + 2000));
expect(await getArchiveByteLimit('/downloads', 60, null)).toBe(1000);
});
it('refuses insufficient space and oversized Content-Length before writing', async () => {
jest.mocked(statfs).mockResolvedValue(disk(ARCHIVE_DISK_RESERVE));
await expect(getArchiveByteLimit('/downloads', 60, null)).rejects.toThrow(
'limit'
);
jest.mocked(statfs).mockResolvedValue(disk(ARCHIVE_DISK_RESERVE + 2000));
await expect(getArchiveByteLimit('/downloads', 60, 1001)).rejects.toThrow(
'limit'
);
});
it('never forwards a chunk that exceeds the byte budget', async () => {
let written = 0;
await expect(
pipeline(
Readable.from([Buffer.alloc(600), Buffer.alloc(600)]),
createArchiveByteGuard('/downloads', 1000),
new Writable({
write(chunk, _encoding, callback) {
written += chunk.length;
callback();
},
})
)
).rejects.toThrow('limit');
expect(written).toBe(600);
});
it('stops when other disk activity consumes the reserve during transfer', async () => {
jest.mocked(statfs).mockResolvedValue(disk(ARCHIVE_DISK_RESERVE));
let written = 0;
await expect(
pipeline(
Readable.from([Buffer.alloc(16 * 1024 ** 2)]),
createArchiveByteGuard('/downloads', 64 * 1024 ** 3),
new Writable({
write(chunk, _encoding, callback) {
written += chunk.length;
callback();
},
})
)
).rejects.toThrow('limit');
expect(written).toBe(0);
});
it('rechecks copy headroom at EOF, including tails below the periodic checkpoint', async () => {
jest.mocked(statfs).mockResolvedValue(disk(ARCHIVE_DISK_RESERVE + 1000));
await expect(assertArchiveCopyHeadroom('/downloads', 1001)).rejects.toThrow(
'limit'
);
await expect(
assertArchiveCopyHeadroom('/downloads', 1000)
).resolves.toBeUndefined();
});
@@ -0,0 +1,72 @@
import { statfs } from 'node:fs/promises';
import { Transform } from 'node:stream';
const GiB = 1024 ** 3;
export const ARCHIVE_DISK_RESERVE = GiB;
const DISK_CHECK_INTERVAL = 16 * 1024 ** 2;
const limitError = () =>
new Error('Archive download exceeds the size or free-space limit');
async function availableBytes(directory: string): Promise<number> {
const stats = await statfs(directory);
return Math.max(0, stats.bavail * stats.bsize - ARCHIVE_DISK_RESERVE);
}
/** A generous 100 Mbit/s ceiling, with a minute of padding and a 64 GiB cap. */
export async function getArchiveByteLimit(
directory: string,
durationSeconds: number,
declaredBytes: number | null
): Promise<number> {
const limit = Math.floor(
Math.min(
(durationSeconds + 60) * 12_500_000,
64 * GiB,
(await availableBytes(directory)) / 2
)
);
if (limit < 188 * 3 || (declaredBytes !== null && declaredBytes > limit))
throw limitError();
return limit;
}
/** Count before forwarding any bytes, including unknown-length TS responses. */
export function createArchiveByteGuard(
directory: string,
limit: number
): Transform {
let received = 0;
let sinceDiskCheck = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
sinceDiskCheck += chunk.length;
if (received > limit) {
callback(limitError());
return;
}
if (sinceDiskCheck >= DISK_CHECK_INTERVAL) {
sinceDiskCheck = 0;
availableBytes(directory).then(
(available) => {
callback(
available < received + chunk.length
? limitError()
: null,
chunk
);
},
(error: Error) => callback(error)
);
} else callback(null, chunk);
},
});
}
/** Recheck after the output stream closes, including a sub-checkpoint tail. */
export async function assertArchiveCopyHeadroom(
directory: string,
finalSize: number
): Promise<void> {
if ((await availableBytes(directory)) < finalSize) throw limitError();
}
@@ -0,0 +1,107 @@
import { archiveFileStats, readArchiveStats } from './download-catchup-stats';
import { constants, type WriteStream } from 'node:fs';
import { open } from 'node:fs/promises';
export interface ArchiveFileIdentity {
readonly dev: number | string;
readonly ino: number | string;
readonly birthtimeMs: number;
}
export function isArchiveFileId(value: unknown): value is number | string {
return typeof value === 'string'
? /^(0|[1-9][0-9]{0,19})$/.test(value) &&
BigInt(value) <= BigInt('18446744073709551615')
: typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0;
}
/** Inodes can be reused after unlink; creation time identifies the generation. */
export function sameArchiveFileIdentity(
a: ArchiveFileIdentity,
b: ArchiveFileIdentity
): boolean {
return (
isArchiveFileId(a.dev) &&
isArchiveFileId(a.ino) &&
isArchiveFileId(b.dev) &&
isArchiveFileId(b.ino) &&
String(a.dev) === String(b.dev) &&
String(a.ino) === String(b.ino) &&
Number.isFinite(a.birthtimeMs) &&
a.birthtimeMs > 0 &&
a.birthtimeMs === b.birthtimeMs
);
}
export function archiveFileIdentity(
file: ArchiveFileIdentity
): ArchiveFileIdentity {
if (!isArchiveFileId(file.dev) || !isArchiveFileId(file.ino))
throw new Error('Archive filesystem identity is unavailable');
if (!Number.isFinite(file.birthtimeMs) || file.birthtimeMs <= 0)
throw new Error('Archive filesystem creation time is unavailable');
return {
dev: String(file.dev),
ino: String(file.ino),
birthtimeMs: file.birthtimeMs,
};
}
export class ArchivePartialReplacedError extends Error {
constructor() {
super('Archive partial changed before opening');
}
}
/** Open without truncation first: a replaced partial must not damage its target. */
export async function openCatchupOutput(
partialPath: string,
expectedIdentity?: ArchiveFileIdentity,
beforeTruncate?: (identity: ArchiveFileIdentity) => Promise<void>
): Promise<{ stream: WriteStream; identity: ArchiveFileIdentity }> {
const before = await readArchiveStats(partialPath).catch(
(error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
}
);
if (before && (!before.isFile() || before.nlink !== 1)) {
throw new ArchivePartialReplacedError();
}
const handle = await open(
partialPath,
constants.O_WRONLY |
(constants.O_NOFOLLOW ?? 0) |
(before ? 0 : constants.O_CREAT | constants.O_EXCL),
0o600
);
try {
const current = archiveFileStats(await handle.stat({ bigint: true }));
const identity = archiveFileIdentity(current);
// Descriptor identity also protects platforms without O_NOFOLLOW from
// a link/file replacement between lstat and open. Missing files use wx.
if (
!current.isFile() ||
current.nlink !== 1 ||
(before && !sameArchiveFileIdentity(current, before)) ||
(before &&
(!expectedIdentity ||
!sameArchiveFileIdentity(current, expectedIdentity)))
) {
throw new ArchivePartialReplacedError();
}
// Keep durable ownership evidence until the actual descriptor passes.
await beforeTruncate?.(identity);
await handle.truncate(0);
// All writes use this verified descriptor; never reopen by pathname.
return {
stream: handle.createWriteStream({ autoClose: true }),
identity,
};
} catch (error) {
await handle.close();
throw error;
}
}
@@ -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;
}
@@ -0,0 +1,338 @@
import {
lstat,
mkdtemp,
readFile,
rename,
rm,
writeFile,
link,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { finalizeCatchupPartial } from './download-catchup-finalize';
import {
recordArchiveFinalization,
recordArchivePartial,
recordArchiveCleanupPath,
parseArchiveFinalization,
} from './download-catchup-journal';
import { resetStaleDownloads } from './download-recovery';
import type { DownloadsDatabase } from './download-task';
jest.mock('../../database/connection', () => ({ getDatabase: jest.fn() }));
jest.mock('node:fs/promises', () => {
const actual = jest.requireActual('node:fs/promises');
return { ...actual, link: jest.fn(actual.link) };
});
let directory: string;
let filePath: string;
let rows: Record<string, unknown>[];
let journals: { downloadId: number; proof: string }[];
let updates: Record<string, unknown>[];
let db: DownloadsDatabase;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'archive-recovery-'));
filePath = join(directory, 'show.ts');
rows = [
{
id: 1,
filePath,
contentType: 'catchup',
status: 'downloading',
totalBytes: null,
},
];
journals = [];
updates = [];
db = {
select: () => ({
from: (table: unknown) => ({
where: async () =>
table === schema.downloads ? rows : journals,
}),
}),
insert: () => ({
values: (row: (typeof journals)[number]) => ({
onConflictDoUpdate: async () => {
journals = [JSON.parse(JSON.stringify(row))];
},
}),
}),
update: (table: unknown) => ({
set: (value: Record<string, unknown>) => ({
where: () => ({
then: (resolve: (value: undefined) => unknown) => {
updates.push(value);
return Promise.resolve(resolve(undefined));
},
run: () => {
if (
table === schema.downloadArchiveFinalizations &&
journals.length
) {
journals[0].proof = value.proof as string;
return { changes: 1 };
}
return { changes: 0 };
},
}),
}),
}),
} as unknown as DownloadsDatabase;
jest.mocked(getDatabase).mockResolvedValue(db);
jest.mocked(link)
.mockReset()
.mockImplementation(jest.requireActual('node:fs/promises').link);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
async function prepare() {
await writeFile(filePath + '.part', 'verified archive');
const partial = await lstat(filePath + '.part');
return {
partial,
reservation: {
path: filePath,
partialPath: filePath + '.part',
filename: 'show.ts',
},
};
}
it.each(['hardlink', 'copy'])(
'recovers an unknown-length archive from the durable %s journal after restart',
async (mode) => {
const { partial, reservation } = await prepare();
if (mode === 'copy')
jest.mocked(link).mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { code: 'ENOTSUP' })
);
await finalizeCatchupPartial(
reservation,
partial,
partial.size,
(finalIdentity) =>
recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity,
})
);
// No in-memory DownloadTask or completion DB update survives this restart.
await resetStaleDownloads();
expect(updates).toContainEqual(
expect.objectContaining({
status: 'completed',
bytesDownloaded: partial.size,
totalBytes: partial.size,
})
);
expect(await readFile(filePath, 'utf8')).toBe('verified archive');
}
);
it('pauses verified bytes when termination precedes promotion', async () => {
const { partial, reservation } = await prepare();
await expect(
finalizeCatchupPartial(
reservation,
partial,
partial.size,
async (finalIdentity) => {
await recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity,
});
throw new Error('simulated termination');
}
)
).rejects.toThrow('termination');
await resetStaleDownloads();
expect(updates).toContainEqual(
expect.objectContaining({
status: 'paused',
bytesDownloaded: partial.size,
})
);
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'verified archive'
);
});
it('refuses same-size replacements and preserves unrelated partials during startup cleanup', async () => {
const { partial, reservation } = await prepare();
await finalizeCatchupPartial(
reservation,
partial,
partial.size,
(finalIdentity) =>
recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity,
})
);
await rename(filePath, join(directory, 'original'));
await writeFile(filePath, 'untrusted bytes!');
await writeFile(filePath + '.part', 'leave this alone');
await resetStaleDownloads();
expect(updates.some((value) => value.status === 'completed')).toBe(false);
expect(await readFile(filePath + '.part', 'utf8')).toBe('leave this alone');
expect(await readFile(filePath, 'utf8')).toBe('untrusted bytes!');
});
it('removes an owned incomplete copy before pausing its retained source', async () => {
const { partial, reservation } = await prepare();
await writeFile(filePath, 'half');
await recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity: await lstat(filePath),
});
await resetStaleDownloads();
expect(updates).toContainEqual(
expect.objectContaining({
status: 'paused',
bytesDownloaded: partial.size,
})
);
await expect(lstat(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'verified archive'
);
});
it.each([
'{}',
'null',
'bad json',
JSON.stringify({
version: 1,
filePath: '/tmp/a',
size: -1,
partialIdentity: { dev: 1, ino: 1, birthtimeMs: 1 },
finalIdentity: { dev: 1, ino: 1, birthtimeMs: 1 },
}),
])('ignores malformed journal %s', (value) => {
expect(parseArchiveFinalization(value)).toBeUndefined();
});
it('does not remove a valid journaled final when a retry was queued at shutdown', async () => {
const { partial, reservation } = await prepare();
await finalizeCatchupPartial(
reservation,
partial,
partial.size,
(finalIdentity) =>
recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity,
})
);
rows[0].status = 'queued';
await resetStaleDownloads();
expect(await readFile(filePath, 'utf8')).toBe('verified archive');
expect(updates).toContainEqual(
expect.objectContaining({ status: 'paused' })
);
});
it.each(['downloading', 'queued'])(
'detaches a replaced journaled partial instead of making %s recoverable',
async (status) => {
const { partial } = await prepare();
await recordArchiveFinalization(db, 1, {
version: 1,
filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity: partial,
});
await rename(filePath + '.part', join(directory, 'original'));
await writeFile(filePath + '.part', 'unrelated user file');
rows[0].status = status;
await resetStaleDownloads();
expect(updates).toContainEqual(
expect.objectContaining({
status: 'failed',
filePath: null,
})
);
expect(updates.some((value) => value.status === 'paused')).toBe(false);
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'unrelated user file'
);
}
);
it.each([false, true])(
'recovers transfer-phase identity without treating it as completion (replaced=%s)',
async (replaced) => {
const { partial } = await prepare();
await recordArchivePartial(db, 1, filePath, partial);
// A same-sized final is insufficient without a finalization-phase journal.
await writeFile(filePath, 'verified archive');
if (replaced) {
await rename(filePath + '.part', join(directory, 'original'));
await writeFile(filePath + '.part', 'unrelated bytes!');
}
await resetStaleDownloads();
expect(updates).toContainEqual(
expect.objectContaining(
replaced
? { status: 'failed', filePath: null }
: { status: 'paused' }
)
);
expect(updates.some((value) => value.status === 'completed')).toBe(
false
);
expect(await readFile(filePath, 'utf8')).toBe('verified archive');
expect(await readFile(filePath + '.part', 'utf8')).toBe(
replaced ? 'unrelated bytes!' : 'verified archive'
);
}
);
it.each([0, 1])(
'requires a durable row for the write-ahead capture pointer (changes=%s)',
async (changes) => {
const { partial } = await prepare();
const proof = {
version: 1 as const,
phase: 'transfer' as const,
filePath,
partialIdentity: partial,
};
const run = jest.fn(() => ({ changes }));
const set = jest.fn((_value: { proof: string }) => ({
where: () => ({ run }),
}));
const database = {
update: () => ({ set }),
} as unknown as DownloadsDatabase;
const capturePath = join(directory, '.iptvnator-cleanup-test/entry');
const record = () =>
recordArchiveCleanupPath(database, 1, proof, capturePath);
if (changes === 0) expect(record).toThrow('ownership');
else expect(record).not.toThrow();
const saved = JSON.parse(set.mock.calls[0][0].proof);
expect(saved).toEqual(
expect.objectContaining({
phase: 'transfer',
partialCleanupPath: capturePath,
})
);
expect(run).toHaveBeenCalledTimes(1);
}
);
@@ -0,0 +1,399 @@
import { openCatchupOutput } from './download-catchup-output';
import {
verifiedArchiveSize,
parseArchiveFinalization,
} from './download-catchup-journal';
import {
mkdtempSync,
lstatSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
unlinkSync,
linkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
removeJournaledCatchupPartial,
cleanupStoredCatchupFinal,
cleanupStoredCatchupPartial,
} from './download-catchup-removal';
import type {
ArchivePartialProof,
ArchiveFinalizationProof,
} from './download-catchup-journal';
jest.mock('node:fs', () => {
const actual = jest.requireActual('node:fs');
return {
...actual,
renameSync: jest.fn(actual.renameSync),
unlinkSync: jest.fn(actual.unlinkSync),
linkSync: jest.fn(actual.linkSync),
};
});
const actual = jest.requireActual<typeof import('node:fs')>('node:fs');
let directory: string, filePath: string, proof: ArchivePartialProof;
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'archive-remove-'));
filePath = join(directory, 'show.ts');
writeFileSync(filePath + '.part', 'owned bytes');
proof = {
version: 1,
phase: 'transfer',
filePath,
partialIdentity: lstatSync(filePath + '.part'),
};
jest.mocked(renameSync).mockReset().mockImplementation(actual.renameSync);
jest.mocked(unlinkSync).mockReset().mockImplementation(actual.unlinkSync);
jest.mocked(linkSync).mockReset().mockImplementation(actual.linkSync);
});
afterEach(() => rmSync(directory, { recursive: true, force: true }));
function recordCapture(path: string) {
proof = { ...proof, partialCleanupPath: path };
}
it('removes only the journaled partial', () => {
removeJournaledCatchupPartial(filePath, proof, recordCapture);
expect(() => lstatSync(filePath + '.part')).toThrow();
});
it('preserves entries without proof', () => {
removeJournaledCatchupPartial(filePath, undefined, recordCapture);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it('preserves a replaced regular partial in place', () => {
renameSync(filePath + '.part', join(directory, 'original'));
writeFileSync(filePath + '.part', 'unrelated bytes');
removeJournaledCatchupPartial(filePath, proof, recordCapture);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('unrelated bytes');
});
it('restores a replacement captured at the cleanup boundary', () => {
jest.mocked(renameSync).mockImplementationOnce((from, to) => {
actual.renameSync(from, join(directory, 'original'));
writeFileSync(from, 'unrelated bytes');
actual.renameSync(from, to);
});
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('preserved an unrelated recovery file');
expect(readFileSync(filePath + '.part', 'utf8')).toBe('unrelated bytes');
expect(readFileSync(proof.partialCleanupPath!, 'utf8')).toBe(
'unrelated bytes'
);
});
it('retains a replacement capture until explicit recovery-copy cleanup', () => {
jest.mocked(renameSync).mockImplementationOnce((from, to) => {
actual.renameSync(from, join(directory, 'original'));
writeFileSync(from, 'foreign bytes');
actual.renameSync(from, to);
});
jest.mocked(linkSync).mockImplementation(() => {
throw Object.assign(new Error('hardlinks unavailable'), {
code: 'ENOTSUP',
});
});
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('hardlinks unavailable');
const capture = proof.partialCleanupPath!;
expect(readFileSync(capture, 'utf8')).toBe('foreign bytes');
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('hardlinks unavailable');
expect(proof.partialCleanupPath).toBe(capture);
jest.mocked(linkSync).mockImplementationOnce(() => {
throw Object.assign(new Error('destination parent missing'), {
code: 'ENOENT',
});
});
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('destination parent missing');
expect(readFileSync(capture, 'utf8')).toBe('foreign bytes');
jest.mocked(linkSync).mockImplementation(actual.linkSync);
writeFileSync(filePath + '.part', 'newer public bytes');
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('newer public bytes');
expect(readFileSync(capture, 'utf8')).toBe('foreign bytes');
actual.unlinkSync(filePath + '.part');
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('preserved an unrelated recovery file');
expect(readFileSync(filePath + '.part', 'utf8')).toBe('foreign bytes');
expect(readFileSync(capture, 'utf8')).toBe('foreign bytes');
// The restored public entry may disappear immediately; its recovery copy
// must survive and remain journaled across another cleanup attempt.
actual.unlinkSync(filePath + '.part');
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('preserved an unrelated recovery file');
expect(readFileSync(capture, 'utf8')).toBe('foreign bytes');
actual.unlinkSync(capture); // explicit recovery-copy cleanup by the user
removeJournaledCatchupPartial(filePath, proof, recordCapture);
expect(() => lstatSync(capture)).toThrow();
});
it('retries a durable capture after an I/O error without needing hardlinks', () => {
jest.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error('locked'), { code: 'EACCES' });
});
jest.mocked(linkSync).mockImplementation(() => {
throw Object.assign(new Error('unsupported'), { code: 'ENOTSUP' });
});
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('locked');
expect(() => lstatSync(filePath + '.part')).toThrow();
expect(proof.partialCleanupPath).toBeDefined();
expect(readFileSync(proof.partialCleanupPath!, 'utf8')).toBe('owned bytes');
removeJournaledCatchupPartial(filePath, proof, recordCapture);
expect(() => lstatSync(proof.partialCleanupPath!)).toThrow();
expect(linkSync).not.toHaveBeenCalled();
});
it('does not capture the entry when write-ahead persistence fails', () => {
expect(() =>
removeJournaledCatchupPartial(filePath, proof, (capture) => {
expect(readFileSync(filePath + '.part', 'utf8')).toBe(
'owned bytes'
);
expect(() => lstatSync(capture)).toThrow();
throw new Error('SQLITE_BUSY');
})
).toThrow('SQLITE_BUSY');
expect(renameSync).not.toHaveBeenCalled();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it('journals a failed final-file capture without confusing it with the source', async () => {
writeFileSync(filePath, 'partial final copy');
let journal: ArchiveFinalizationProof = {
version: 1,
filePath,
partialIdentity: proof.partialIdentity,
finalIdentity: lstatSync(filePath),
size: 100,
};
const db = {
select: () => ({
from: () => ({
where: async () => [
{ downloadId: 1, proof: JSON.stringify(journal) },
],
}),
}),
update: () => ({
set: (value: { proof: string }) => ({
where: () => ({
run: () => {
journal = JSON.parse(value.proof);
return { changes: 1 };
},
}),
}),
}),
};
jest.mocked(unlinkSync).mockImplementationOnce(() => {
throw new Error('locked copy');
});
await expect(
cleanupStoredCatchupFinal(
db as never,
1,
filePath,
journal.finalIdentity
)
).resolves.toBe(false);
expect(journal.finalCleanupPath).toBeDefined();
expect(readFileSync(journal.finalCleanupPath!, 'utf8')).toBe(
'partial final copy'
);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
await expect(
cleanupStoredCatchupFinal(db as never, 1, filePath)
).resolves.toBe(true);
expect(() => lstatSync(journal.finalCleanupPath!)).toThrow();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it.each([false, true])(
'cleans an owned final only for an abandoned attempt (removeFinal=%s)',
(removeFinal) => {
writeFileSync(filePath, 'unfinished copy');
let journal: ArchiveFinalizationProof = {
...proof,
phase: 'finalization',
size: 100,
finalIdentity: lstatSync(filePath),
};
const record = (path: string, kind = 'partial') => {
journal = {
...journal,
[kind === 'final' ? 'finalCleanupPath' : 'partialCleanupPath']:
path,
};
};
if (removeFinal) {
jest.mocked(unlinkSync).mockImplementationOnce(() => {
throw new Error('locked final');
});
expect(() =>
removeJournaledCatchupPartial(filePath, journal, record, true)
).toThrow('locked final');
expect(readFileSync(journal.finalCleanupPath!, 'utf8')).toBe(
'unfinished copy'
);
expect(readFileSync(filePath + '.part', 'utf8')).toBe(
'owned bytes'
);
}
removeJournaledCatchupPartial(filePath, journal, record, removeFinal);
expect(() => lstatSync(filePath + '.part')).toThrow();
if (removeFinal) {
expect(() => lstatSync(filePath)).toThrow();
expect(() => lstatSync(journal.finalCleanupPath!)).toThrow();
} else expect(readFileSync(filePath, 'utf8')).toBe('unfinished copy');
}
);
it.each([false, true])(
'restart cleanup preserves completed promotion and removes unfinished copies (complete=%s)',
async (complete) => {
writeFileSync(filePath, 'archive');
let journal: ArchiveFinalizationProof = {
...proof,
phase: 'finalization',
size: complete ? 7 : 100,
finalIdentity: lstatSync(filePath),
};
const db = {
select: () => ({
from: () => ({
where: async () => [
{ downloadId: 1, proof: JSON.stringify(journal) },
],
}),
}),
update: () => ({
set: (value: { proof: string }) => ({
where: () => ({
run: () => {
journal = JSON.parse(value.proof);
return { changes: 1 };
},
}),
}),
}),
};
await expect(
cleanupStoredCatchupPartial(db as never, 1, filePath, 'incomplete')
).resolves.toBe(true);
expect(() => lstatSync(filePath + '.part')).toThrow();
if (complete) expect(readFileSync(filePath, 'utf8')).toBe('archive');
else expect(() => lstatSync(filePath)).toThrow();
}
);
it('preserves a replacement when the filesystem reuses its predecessor inode', () => {
proof.partialIdentity = {
...proof.partialIdentity,
birthtimeMs: proof.partialIdentity.birthtimeMs - 1,
};
removeJournaledCatchupPartial(filePath, proof, recordCapture);
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
expect(renameSync).not.toHaveBeenCalled();
});
it('rejects a reused inode before truncating or replacing the journal proof', async () => {
const previous = {
...proof.partialIdentity,
birthtimeMs: proof.partialIdentity.birthtimeMs - 1,
};
const record = jest.fn();
await expect(
openCatchupOutput(filePath + '.part', previous, record)
).rejects.toThrow('changed');
expect(record).not.toHaveBeenCalled();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it('does not recover a same-size final from a reused inode or a legacy proof without creation time', () => {
writeFileSync(filePath, 'foreign archive');
const final = lstatSync(filePath);
const journal: ArchiveFinalizationProof = {
...proof,
phase: 'finalization',
size: final.size,
finalIdentity: { ...final, birthtimeMs: final.birthtimeMs - 1 },
};
expect(verifiedArchiveSize(filePath, journal)).toBeNull();
expect(
parseArchiveFinalization(
JSON.stringify({
...journal,
finalIdentity: { dev: final.dev, ino: final.ino },
})
)
).toBeUndefined();
});
it('never truncates a preexisting file without expected ownership', async () => {
const record = jest.fn();
await expect(
openCatchupOutput(filePath + '.part', undefined, record)
).rejects.toThrow('changed');
expect(record).not.toHaveBeenCalled();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
it.each([false, true])(
'does not relocate an unproved fallback target (replaced=%s)',
async (replaced) => {
writeFileSync(filePath, '');
const createdIdentity = lstatSync(filePath);
if (replaced) {
renameSync(filePath, join(directory, 'empty-original'));
writeFileSync(filePath, 'foreign final');
}
const db = {
select: () => ({ from: () => ({ where: async () => [] }) }),
};
jest.mocked(renameSync).mockClear();
await expect(
cleanupStoredCatchupFinal(db as never, 1, filePath, createdIdentity)
).resolves.toBe(false);
expect(readFileSync(filePath, 'utf8')).toBe(
replaced ? 'foreign final' : ''
);
expect(renameSync).not.toHaveBeenCalled();
}
);
it.each([false, true])(
'preserves the last foreign link if the restored public entry is immediately removed (retry=%s)',
(retry) => {
if (retry) {
const capture = join(directory, 'foreign-capture');
writeFileSync(capture, 'foreign bytes');
proof.partialCleanupPath = capture;
actual.unlinkSync(filePath + '.part');
} else {
jest.mocked(renameSync).mockImplementationOnce((from, to) => {
actual.renameSync(from, join(directory, 'original'));
writeFileSync(from, 'foreign bytes');
actual.renameSync(from, to);
});
}
jest.mocked(linkSync).mockImplementationOnce((from, to) => {
actual.linkSync(from, to);
actual.unlinkSync(to);
});
expect(() =>
removeJournaledCatchupPartial(filePath, proof, recordCapture)
).toThrow('preserved an unrelated recovery file');
expect(readFileSync(proof.partialCleanupPath!, 'utf8')).toBe(
'foreign bytes'
);
}
);
@@ -0,0 +1,138 @@
import {
readArchiveStatsSync,
type ArchiveFileStats,
} from './download-catchup-stats';
import {
cleanupArchiveCapture,
restoreArchiveReplacement,
} from './download-catchup-capture';
import { mkdtempSync, renameSync, unlinkSync, rmdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import {
readArchiveFinalizations,
verifiedArchiveSize,
recordArchiveCleanupPath,
type ArchiveDownloadProof,
} from './download-catchup-journal';
import type { DownloadsDatabase } from './download-task';
import {
sameArchiveFileIdentity,
type ArchiveFileIdentity,
} from './download-catchup-output';
/** IPC removal stays synchronous after its runtime guard, like VOD cleanup. */
export function removeJournaledCatchupPartial(
filePath: string | null,
proof: ArchiveDownloadProof | undefined,
recordCapture: (path: string, kind?: 'partial' | 'final') => void,
removeFinal = false
): void {
// No proof means no authority to remove the retained entry.
if (!filePath || !proof || proof.filePath !== filePath) return;
cleanupArchiveCapture(proof);
if (removeFinal && proof.phase !== 'transfer')
removeOwnedEntry(filePath, proof.finalIdentity, (path) =>
recordCapture(path, 'final')
);
removeOwnedEntry(`${filePath}.part`, proof.partialIdentity, (path) =>
recordCapture(path, 'partial')
);
}
function removeOwnedEntry(
path: string,
identity: ArchiveFileIdentity,
recordCapture: (path: string) => void
): void {
const matches = (file: ArchiveFileStats, identity: ArchiveFileIdentity) =>
file.isFile() && sameArchiveFileIdentity(file, identity);
try {
if (!matches(readArchiveStatsSync(path), identity)) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
const directory = mkdtempSync(join(dirname(path), '.iptvnator-cleanup-'));
const captured = join(directory, 'entry');
try {
recordCapture(captured);
renameSync(path, captured);
if (matches(readArchiveStatsSync(captured), identity)) {
unlinkSync(captured);
} else {
restoreArchiveReplacement(captured, path);
}
} finally {
try {
rmdirSync(directory);
} catch {
/* never recursively remove a capture */
}
}
}
/** Queue/paused cancellation uses the same durable ownership as explicit Remove. */
export async function cleanupStoredCatchupPartial(
db: DownloadsDatabase,
downloadId: number,
filePath: string | null | undefined,
removeFinal: boolean | 'incomplete' = false
): Promise<boolean> {
if (!filePath) return true;
try {
const proof = (await readArchiveFinalizations(db, [downloadId])).get(
downloadId
);
removeJournaledCatchupPartial(
filePath,
proof,
(path, kind) => {
if (proof)
recordArchiveCleanupPath(db, downloadId, proof, path, kind);
},
removeFinal === true ||
(removeFinal === 'incomplete' &&
verifiedArchiveSize(filePath, proof) === null)
);
return true;
} catch (error) {
console.error(
'[Downloads] Failed to clean canceled archive partial:',
error
);
return false;
}
}
/** Failed promotion/startup cleanup retains every owned nonempty final capture. */
export async function cleanupStoredCatchupFinal(
db: DownloadsDatabase,
downloadId: number,
filePath: string,
createdIdentity?: ArchiveFileIdentity
): Promise<boolean> {
try {
const proof = (await readArchiveFinalizations(db, [downloadId])).get(
downloadId
);
if (
!proof ||
proof.phase === 'transfer' ||
proof.filePath !== filePath ||
(createdIdentity &&
!sameArchiveFileIdentity(createdIdentity, proof.finalIdentity))
) {
// No copy bytes precede final proof. Preserve an unproved empty
// destination in place: relocation needs a durable capture pointer.
return !createdIdentity;
}
cleanupArchiveCapture(proof);
removeOwnedEntry(filePath, proof.finalIdentity, (path) =>
recordArchiveCleanupPath(db, downloadId, proof, path, 'final')
);
return true;
} catch (error) {
console.error('[Downloads] Failed to clean archive promotion:', error);
return false;
}
}
@@ -0,0 +1,292 @@
import { archiveFileStats } from './download-catchup-stats';
import { openCatchupOutput } from './download-catchup-output';
import {
mkdtemp,
lstat,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveTarget } from './download-runtime-reservation';
import { reserveFreshCatchupTarget } from './download-catchup-reservation';
import {
clearArchiveFinalization,
recordArchiveReservation,
readArchiveFinalizations,
} from './download-catchup-journal';
import type { DownloadsDatabase, DownloadTask } from './download-task';
jest.mock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
clearArchiveFinalization: jest.fn().mockResolvedValue(undefined),
readArchiveFinalizations: jest.fn(),
recordArchiveCleanupPath: jest.fn(),
recordArchiveReservation: jest.fn().mockResolvedValue(undefined),
}));
it.each([false, true])(
'handles an occupied final without relocating a partial (replaced=%s)',
async (replaced) => {
const directory = await mkdtemp(join(tmpdir(), 'archive-collision-'));
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath, 'unrelated final');
await writeFile(filePath + '.part', 'owned bytes');
const task: DownloadTask = {
id: 1,
filePath,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchupExpectedPartialIdentity: await lstat(filePath + '.part'),
};
jest.mocked(readArchiveFinalizations).mockResolvedValue(
new Map([
[
1,
{
version: 1,
phase: 'transfer',
filePath,
partialIdentity:
task.catchupExpectedPartialIdentity!,
},
],
])
);
if (replaced) {
await rename(
filePath + '.part',
join(directory, 'owned-original')
);
await writeFile(filePath + '.part', 'unrelated partial');
}
jest.mocked(clearArchiveFinalization).mockClear();
const reserve = reserveFreshCatchupTarget(
{} as DownloadsDatabase,
task
);
if (replaced) {
await expect(reserve).rejects.toThrow('changed');
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'unrelated partial'
);
await expect(
lstat(join(directory, 'show (1).ts.part'))
).rejects.toMatchObject({ code: 'ENOENT' });
expect(clearArchiveFinalization).not.toHaveBeenCalled();
} else {
await expect(reserve).resolves.toEqual({
path: join(directory, 'show (1).ts'),
partialPath: join(directory, 'show (1).ts.part'),
filename: 'show (1).ts',
});
await expect(lstat(filePath + '.part')).rejects.toMatchObject({
code: 'ENOENT',
});
expect(
archiveFileStats(
await lstat(join(directory, 'show (1).ts.part'))
)
).toEqual(
expect.objectContaining(
task.catchupExpectedPartialIdentity!
)
);
}
expect(await readFile(filePath, 'utf8')).toBe('unrelated final');
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
it('removes a journaled incomplete final before retrying the retained archive', async () => {
const directory = await mkdtemp(join(tmpdir(), 'archive-retry-copy-'));
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath, 'incomplete');
await writeFile(filePath + '.part', 'complete source');
const proof = {
version: 1 as const,
filePath,
size: 100,
partialIdentity: await lstat(filePath + '.part'),
finalIdentity: await lstat(filePath),
};
jest.mocked(readArchiveFinalizations).mockResolvedValue(
new Map([[1, proof]])
);
const task: DownloadTask = {
id: 1,
filePath,
fileName: 'show.ts',
directory,
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
await expect(
reserveTarget({} as DownloadsDatabase, task)
).resolves.toMatchObject({ path: filePath });
await expect(lstat(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'complete source'
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('binds a fresh reservation before a replacement can arrive during the HTTP wait', async () => {
const directory = await mkdtemp(join(tmpdir(), 'archive-fresh-owner-'));
const task: DownloadTask = {
id: 19,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
try {
const reservation = await reserveTarget({} as DownloadsDatabase, task);
expect(recordArchiveReservation).toHaveBeenCalledWith(
expect.anything(),
task.id,
reservation.path,
task.catchupExpectedPartialIdentity,
reservation.filename
);
await rename(reservation.partialPath, join(directory, 'original'));
await writeFile(
reservation.partialPath,
'foreign file created before the response'
);
await expect(
openCatchupOutput(
reservation.partialPath,
task.catchupExpectedPartialIdentity
)
).rejects.toThrow('changed');
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'foreign file created before the response'
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it.each([false, true])(
'cleans a failed reservation journal without deleting a replacement (replaced=%s)',
async (replaced) => {
const directory = await mkdtemp(
join(tmpdir(), 'archive-reservation-failure-')
);
const filePath = join(directory, 'show.ts');
const task: DownloadTask = {
id: 20,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
const failure = new Error('SQLite write failed');
jest.mocked(recordArchiveReservation)
.mockImplementationOnce(async () => {
if (replaced) {
await rename(
filePath + '.part',
join(directory, 'original')
);
await writeFile(filePath + '.part', 'foreign bytes');
}
throw failure;
})
.mockImplementationOnce(async (_db, id, path, identity) => {
jest.mocked(readArchiveFinalizations).mockResolvedValue(
new Map([
[
id,
{
version: 1,
phase: 'transfer',
filePath: path,
partialIdentity: identity,
},
],
])
);
});
try {
await expect(
reserveTarget({} as DownloadsDatabase, task)
).rejects.toBe(failure);
if (replaced) {
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'foreign bytes'
);
} else {
await expect(lstat(filePath + '.part')).rejects.toMatchObject({
code: 'ENOENT',
});
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
it.each([false, true])(
'never relocates an unjournaled reservation when SQLite remains unavailable (replaced=%s)',
async (replaced) => {
const directory = await mkdtemp(join(tmpdir(), 'archive-no-journal-'));
const filePath = join(directory, 'show.ts');
const task: DownloadTask = {
id: 21,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
const failure = new Error('SQLite unavailable');
jest.mocked(recordArchiveReservation)
.mockImplementationOnce(async () => {
if (replaced) {
await rename(
filePath + '.part',
join(directory, 'original')
);
await writeFile(filePath + '.part', 'foreign bytes');
}
throw failure;
})
.mockRejectedValueOnce(failure);
try {
await expect(
reserveTarget({} as DownloadsDatabase, task)
).rejects.toBe(failure);
expect(await readFile(filePath + '.part', 'utf8')).toBe(
replaced ? 'foreign bytes' : ''
);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
@@ -0,0 +1,97 @@
import { archiveFileStats, readArchiveStats } from './download-catchup-stats';
import { closeSync, fstatSync, openSync } from 'node:fs';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import {
clearArchiveFinalization,
recordArchiveReservation,
} from './download-catchup-journal';
import {
ArchivePartialReplacedError,
archiveFileIdentity,
sameArchiveFileIdentity,
} from './download-catchup-output';
import { reserveAvailablePartialDownloadFile } from './download-file-path';
import type { DownloadsDatabase, DownloadTask } from './download-task';
/** Archives restart at zero, so never relocate a retained entry on collision. */
export async function reserveFreshCatchupTarget(
db: DownloadsDatabase,
task: DownloadTask
) {
if (task.filePath) {
const partial = await readArchiveStats(`${task.filePath}.part`).catch(
(error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
}
);
const expected = task.catchupExpectedPartialIdentity;
if (
partial &&
(!expected ||
!partial.isFile() ||
!sameArchiveFileIdentity(partial, expected))
) {
throw new ArchivePartialReplacedError();
}
if (!(await cleanupStoredCatchupPartial(db, task.id, task.filePath))) {
throw new Error('Could not remove the owned archive partial');
}
}
await clearArchiveFinalization(db, task.id);
return reserveOwnedCatchupTarget(db, task);
}
/** Capture ownership from the exclusive creation descriptor, before any network wait. */
export async function reserveOwnedCatchupTarget(
db: DownloadsDatabase,
task: DownloadTask
) {
task.catchupExpectedPartialIdentity = undefined;
const reservation = reserveAvailablePartialDownloadFile(
task.directory,
task.fileName,
(partialPath) => {
const descriptor = openSync(partialPath, 'wx', 0o600);
try {
task.catchupExpectedPartialIdentity = archiveFileIdentity(
archiveFileStats(fstatSync(descriptor, { bigint: true }))
);
} finally {
closeSync(descriptor);
}
}
);
task.filePath = reservation.path;
task.fileName = reservation.filename;
const identity = task.catchupExpectedPartialIdentity;
if (!identity)
throw new Error('Archive reservation identity is unavailable');
try {
await recordArchiveReservation(
db,
task.id,
reservation.path,
identity,
reservation.filename
);
} catch (error) {
// Retry persistence once before cleanup. Never relocate an unjournaled
// entry: if SQLite remains unavailable, leave the empty placeholder in
// place rather than risk losing the recovery path of a replacement.
try {
await recordArchiveReservation(
db,
task.id,
reservation.path,
identity,
reservation.filename
);
await cleanupStoredCatchupPartial(db, task.id, reservation.path);
} catch {
/* no media bytes exist before the first ownership commit */
}
throw error;
}
return reservation;
}
@@ -0,0 +1,500 @@
import { unlinkSync } from 'node:fs';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import { ArchivePartialReplacedError } from './download-catchup-output';
import { handleDownloadFailure } from './download-finalize';
import {
lstat,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getDatabase } from '../../database/connection';
import { transferCatchupToPartialFile } from './download-catchup-transfer';
import {
enqueueDownload,
pauseDownload,
cancelDownload,
isDownloadCommitting,
removeDownloadFromRuntime,
prepareArchiveRemoval,
hasRuntimeDownload,
} from './download-runtime';
import {
readArchiveFinalizations,
recordArchiveCleanupPath,
type ArchiveDownloadProof,
} from './download-catchup-journal';
import type { DownloadTask } from './download-task';
jest.mock('node:fs', () => {
const actual = jest.requireActual('node:fs');
return { ...actual, unlinkSync: jest.fn(actual.unlinkSync) };
});
const mockProofs = new Map<number, ArchiveDownloadProof>();
function mockRememberCapture(
_db: unknown,
id: number,
proof: ArchiveDownloadProof,
path: string,
kind = 'partial'
) {
mockProofs.set(id, {
...proof,
[kind === 'partial' ? 'partialCleanupPath' : 'finalCleanupPath']: path,
});
}
jest.mock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
recordArchiveReservation: jest.fn(
async (_db, id, filePath, partialIdentity) => {
mockProofs.set(id, {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
});
}
),
recordArchiveFinalization: jest.fn(async (_db, id, proof) => {
mockProofs.set(id, proof);
}),
recordArchiveCleanupPath: jest.fn(mockRememberCapture),
clearArchiveFinalization: jest.fn(async (_db, id) => {
mockProofs.delete(id);
}),
readArchiveFinalizations: jest.fn(async () => new Map(mockProofs)),
}));
jest.mock('../../database/connection', () => ({ getDatabase: jest.fn() }));
jest.mock('./download-catchup-transfer', () => ({
transferCatchupToPartialFile: jest.fn(),
}));
jest.mock('./download-broadcast', () => ({
broadcastDownloadUpdate: jest.fn(),
}));
beforeEach(() => {
mockProofs.clear();
jest.mocked(unlinkSync)
.mockReset()
.mockImplementation(jest.requireActual('node:fs').unlinkSync);
jest.mocked(transferCatchupToPartialFile).mockReset();
jest.mocked(readArchiveFinalizations)
.mockReset()
.mockImplementation(async () => new Map(mockProofs));
jest.mocked(recordArchiveCleanupPath)
.mockReset()
.mockImplementation(mockRememberCapture);
});
it.each(['failed', 'canceled'])(
'preserves a replaced partial when the active archive becomes %s',
async (status) => {
const directory = await mkdtemp(join(tmpdir(), 'archive-runtime-'));
const task: DownloadTask = {
id: 991,
directory,
fileName: 'show.ts',
url: 'https://provider.test/show.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
let done!: () => void;
const completed = new Promise<void>((resolve) => {
done = resolve;
});
const updates: Record<string, unknown>[] = [];
const db = {
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => {
updates.push(value);
if (value.status === status) done();
},
}),
}),
};
jest.mocked(getDatabase).mockResolvedValue(db as never);
jest.mocked(transferCatchupToPartialFile).mockImplementationOnce(
async (_db, active, reservation) => {
active.catchupPartialIdentity = await lstat(
reservation.partialPath
);
expect(active.catchupExpectedPartialIdentity).toEqual(
expect.objectContaining({
dev: active.catchupPartialIdentity.dev,
ino: active.catchupPartialIdentity.ino,
birthtimeMs: active.catchupPartialIdentity.birthtimeMs,
})
);
await rename(
reservation.partialPath,
join(directory, 'original')
);
await writeFile(reservation.partialPath, 'keep replacement');
if (status === 'canceled') active.cancelRequested = true;
throw new Error('transfer interrupted');
}
);
const errorLog = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
task.filePath = join(directory, 'show.ts');
await writeFile(task.filePath + '.part', 'retained archive');
const partial = await lstat(task.filePath + '.part');
jest.mocked(readArchiveFinalizations).mockResolvedValueOnce(
new Map([
[
task.id,
{
version: 1,
filePath: task.filePath,
size: partial.size,
partialIdentity: partial,
finalIdentity: partial,
},
],
])
);
enqueueDownload(task);
await completed;
// Let the queue's finally block retire this task before the next case.
await new Promise((resolve) => setImmediate(resolve));
expect(
await readFile(join(directory, 'show.ts.part'), 'utf8')
).toBe('keep replacement');
expect(
updates.some((update) => update.status === 'completed')
).toBe(false);
expect(updates.at(-1)).toEqual(
expect.objectContaining({
status,
filePath: null,
})
);
} finally {
errorLog.mockRestore();
await rm(directory, { recursive: true, force: true });
}
}
);
it.each([1880, null])(
'recovers a verified archive after one completion-write failure (response length %s)',
async (totalBytes) => {
const directory = await mkdtemp(join(tmpdir(), 'archive-persistence-'));
const body = Buffer.alloc(1880, 0x47);
const task: DownloadTask = {
id: 992,
directory,
fileName: 'show.ts',
url: 'https://provider.test/show.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
let done!: () => void;
const settled = new Promise<void>((resolve) => {
done = resolve;
});
let completionAttempts = 0;
let terminalStatus: unknown;
const db = {
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => {
if (
value.status === 'completed' &&
++completionAttempts === 1
)
throw new Error('SQLITE_BUSY');
if (
value.status === 'completed' ||
value.status === 'failed'
) {
terminalStatus = value.status;
done();
}
},
}),
}),
};
jest.mocked(getDatabase).mockResolvedValue(db as never);
jest.mocked(transferCatchupToPartialFile).mockImplementationOnce(
async (_db, active, reservation) => {
await writeFile(reservation.partialPath, body);
active.catchupPartialIdentity = await lstat(
reservation.partialPath
);
active.totalBytes = totalBytes;
return {
bytesDownloaded: body.length,
totalBytes: body.length,
};
}
);
const lateCommands: Array<boolean | Promise<boolean>> = [];
jest.mocked(recordArchiveCleanupPath).mockImplementationOnce(
(database, id, proof, path, kind) => {
mockRememberCapture(database, id, proof, path, kind);
lateCommands.push(pauseDownload(task.id));
lateCommands.push(cancelDownload(task.id));
lateCommands.push(removeDownloadFromRuntime(task.id));
}
);
try {
if (totalBytes === null) {
task.filePath = join(directory, 'show.ts');
await writeFile(
task.filePath + '.part',
'unproven retained file'
);
}
enqueueDownload(task);
await settled;
await new Promise((resolve) => setImmediate(resolve));
expect(await Promise.all(lateCommands)).toEqual([
false,
false,
false,
]);
expect(terminalStatus).toBe('completed');
expect(completionAttempts).toBe(2);
expect(await readFile(task.filePath!)).toEqual(body);
if (totalBytes === null) {
expect(task.filePath).toBe(join(directory, 'show (1).ts'));
expect(
await readFile(join(directory, 'show.ts.part'), 'utf8')
).toBe('unproven retained file');
}
await expect(lstat(task.filePath + '.part')).rejects.toMatchObject({
code: 'ENOENT',
});
expect(transferCatchupToPartialFile).toHaveBeenCalledTimes(1);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
it('detaches a rejected replacement so Retry can reserve a fresh path', async () => {
const directory = await mkdtemp(join(tmpdir(), 'archive-detached-'));
const filePath = join(directory, 'show.ts');
const task: DownloadTask = {
id: 994,
directory,
filePath,
fileName: 'show.ts',
url: 'https://provider.test/show.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
const updates: Record<string, unknown>[] = [];
const db = {
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => {
updates.push(value);
},
}),
}),
};
try {
await writeFile(filePath + '.part', 'unrelated user file');
await handleDownloadFailure(
db as never,
task,
undefined,
new ArchivePartialReplacedError()
);
expect(updates.at(-1)).toEqual(
expect.objectContaining({ status: 'failed', filePath: null })
);
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'unrelated user file'
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it.each([false, true])(
'paused cancellation honors durable ownership (replaced=%s)',
async (replaced) => {
const directory = await mkdtemp(
join(tmpdir(), 'archive-cancel-owned-')
);
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath + '.part', 'owned archive');
const original = await lstat(filePath + '.part');
jest.mocked(readArchiveFinalizations).mockResolvedValueOnce(
new Map([
[
995,
{
version: 1,
phase: 'transfer',
filePath,
partialIdentity: original,
},
],
])
);
if (replaced) {
await rename(filePath + '.part', join(directory, 'original'));
await writeFile(filePath + '.part', 'unrelated file');
}
const updates: Record<string, unknown>[] = [];
const db = {
select: () => ({
from: () => ({
where: () => ({
limit: async () => [
{
filePath,
status: 'paused',
contentType: 'catchup',
},
],
}),
}),
}),
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => {
updates.push(value);
},
}),
}),
};
jest.mocked(getDatabase).mockResolvedValue(db as never);
await expect(cancelDownload(995)).resolves.toBe(true);
expect(updates).toContainEqual(
expect.objectContaining({ status: 'canceled', filePath: null })
);
if (replaced)
expect(await readFile(filePath + '.part', 'utf8')).toBe(
'unrelated file'
);
else
await expect(lstat(filePath + '.part')).rejects.toMatchObject({
code: 'ENOENT',
});
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
it.each(['failed', 'canceled'])(
'keeps active %s cleanup captures durable until retry',
async (status) => {
const directory = await mkdtemp(
join(tmpdir(), 'active-archive-cleanup-')
);
const task: DownloadTask = {
id: 996,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
let ready!: () => void, release!: () => void, finish!: () => void;
const started = new Promise<void>((resolve) => {
ready = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const settled = new Promise<void>((resolve) => {
finish = resolve;
});
const updates: Record<string, unknown>[] = [];
const db = {
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => {
updates.push(value);
if (value.status === status) finish();
},
}),
}),
};
jest.mocked(getDatabase).mockResolvedValue(db as never);
jest.mocked(transferCatchupToPartialFile).mockImplementationOnce(
async (_db, active, reservation) => {
await writeFile(
reservation.partialPath,
'verified archive bytes'
);
active.catchupPartialIdentity = await lstat(
reservation.partialPath
);
mockProofs.set(active.id, {
version: 1,
phase: 'transfer',
filePath: reservation.path,
partialIdentity: active.catchupPartialIdentity,
});
ready();
await gate;
throw new Error('interrupted transfer');
}
);
jest.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error('locked'), { code: 'EACCES' });
});
try {
enqueueDownload(task);
await started;
let removal: Promise<boolean> | undefined;
if (status === 'canceled') {
removal = prepareArchiveRemoval(task.id);
let returned = false;
void removal.then(() => {
returned = true;
});
await new Promise((resolve) => setImmediate(resolve));
expect(returned).toBe(false);
expect(hasRuntimeDownload(task.id)).toBe(true);
}
release();
await settled;
if (removal) await expect(removal).resolves.toBe(true);
await new Promise((resolve) => setImmediate(resolve));
const pointer = mockProofs.get(task.id)?.partialCleanupPath;
expect(pointer).toBeDefined();
expect(await readFile(pointer!, 'utf8')).toBe(
'verified archive bytes'
);
expect(updates.at(-1)).toEqual(
expect.objectContaining({ status, filePath: task.filePath })
);
await expect(
cleanupStoredCatchupPartial(db as never, task.id, task.filePath)
).resolves.toBe(true);
await expect(lstat(pointer!)).rejects.toMatchObject({
code: 'ENOENT',
});
} finally {
release();
await rm(directory, { recursive: true, force: true });
}
}
);
@@ -0,0 +1,52 @@
import { type BigIntStats } from 'node:fs';
import { archiveFileStats } from './download-catchup-stats';
import {
archiveFileIdentity,
sameArchiveFileIdentity,
} from './download-catchup-output';
import { parseArchiveFinalization } from './download-catchup-journal';
function stats(ino: string) {
return archiveFileStats({
dev: BigInt(1),
ino: BigInt(ino),
birthtimeMs: BigInt(1000),
birthtimeNs: BigInt('1000123456'),
size: BigInt(188),
nlink: BigInt(1),
isFile: () => true,
} as BigIntStats);
}
it('round-trips 64-bit Windows file references without losing ownership proof', () => {
const file = stats('18446744073709551000');
const identity = archiveFileIdentity(file);
const proof = parseArchiveFinalization(
JSON.stringify({
version: 1,
phase: 'transfer',
filePath: '/archive.ts',
partialIdentity: identity,
})
);
expect(proof?.partialIdentity.ino).toBe('18446744073709551000');
expect(sameArchiveFileIdentity(file, proof!.partialIdentity)).toBe(true);
expect(identity.birthtimeMs).toBe(1000.123456);
});
it('distinguishes adjacent file references that collide when converted to Number', () => {
const a = stats('18446744073709551000');
const b = stats('18446744073709551001');
expect(Number(a.ino)).toBe(Number(b.ino));
expect(sameArchiveFileIdentity(a, b)).toBe(false);
});
it('rejects already-rounded numeric ownership evidence', () => {
const rounded = {
dev: 1,
ino: Number('18446744073709551000'),
birthtimeMs: 1000,
};
expect(sameArchiveFileIdentity(rounded, rounded)).toBe(false);
expect(() => archiveFileIdentity(rounded)).toThrow(
'identity is unavailable'
);
});
@@ -0,0 +1,31 @@
import { lstatSync, type BigIntStats, type Stats } from 'node:fs';
import { lstat } from 'node:fs/promises';
/** Preserve 64-bit file IDs; Number stats can round Windows file references. */
export function archiveFileStats(file: BigIntStats | Stats) {
if (
(typeof file.dev === 'number' && !Number.isSafeInteger(file.dev)) ||
(typeof file.ino === 'number' && !Number.isSafeInteger(file.ino))
)
throw new Error('Archive filesystem identity was rounded');
const birthtimeMs =
'birthtimeNs' in file
? Number(file.birthtimeNs / BigInt(1000000000)) * 1000 +
Number(file.birthtimeNs % BigInt(1000000000)) / 1000000
: file.birthtimeMs;
return {
dev: String(file.dev),
ino: String(file.ino),
birthtimeMs,
size: Number(file.size),
nlink: Number(file.nlink),
isFile: () => file.isFile(),
};
}
export type ArchiveFileStats = ReturnType<typeof archiveFileStats>;
export async function readArchiveStats(path: string) {
return archiveFileStats(await lstat(path, { bigint: true }));
}
export function readArchiveStatsSync(path: string) {
return archiveFileStats(lstatSync(path, { bigint: true }));
}
@@ -0,0 +1,322 @@
import { recordArchivePartial } from './download-catchup-journal';
import {
mkdtemp,
readFile,
rm,
writeFile,
symlink,
link,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
createTsValidator,
transferCatchupToPartialFile,
} from './download-catchup-transfer';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import type { DownloadsDatabase, DownloadTask } from './download-task';
import {
getCompletedPartialProgress,
getExistingCompletedFileProgress,
} from './download-finalize';
import {
assertArchiveCopyHeadroom,
getArchiveByteLimit,
} from './download-catchup-limits';
import { stat } from 'node:fs/promises';
jest.mock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
recordArchivePartial: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./download-catchup-limits', () => {
const actual = jest.requireActual('./download-catchup-limits');
return {
...actual,
getArchiveByteLimit: jest.fn(actual.getArchiveByteLimit),
assertArchiveCopyHeadroom: jest.fn(actual.assertArchiveCopyHeadroom),
};
});
jest.mock('../../util/validated-axios', () => ({
requestWithValidatedRedirects: jest.fn(),
}));
jest.mock('./download-transfer-persistence', () => ({
persistProgress: jest.fn().mockResolvedValue(undefined),
persistTransferStart: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./download-broadcast', () => ({
broadcastDownloadUpdate: jest.fn(),
}));
const packets = Buffer.alloc(188 * 10, 0xff);
for (let i = 0; i < packets.length; i += 188) packets[i] = 0x47;
const metadata = {
channelName: 'News',
startTimestamp: Math.floor(Date.now() / 1000) - 7200,
stopTimestamp: Math.floor(Date.now() / 1000) - 3600,
};
const sink = () =>
new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
describe('TS archive transfer', () => {
it('accepts TS packet boundaries split across arbitrary chunks', async () => {
await pipeline(
Readable.from([packets.subarray(0, 199), packets.subarray(199)]),
createTsValidator(),
sink()
);
});
it.each([
Buffer.from('#EXTM3U\n#EXTINF:10,\na.ts'),
Buffer.from('<html>expired</html>'),
packets.subarray(0, packets.length - 1),
Buffer.alloc(0),
])(
'rejects playlists, error bodies and truncated packets',
async (body) => {
await expect(
pipeline(Readable.from([body]), createTsValidator(), sink())
).rejects.toThrow();
}
);
it('does not consider a byte-complete retained archive safe to finalize', async () => {
expect(
await getExistingCompletedFileProgress({
id: 1,
url: 'https://host/1.ts',
fileName: 'a.ts',
directory: '/tmp',
filePath: '/tmp/a.ts',
totalBytes: 1880,
catchup: metadata,
})
).toBeNull();
expect(
getCompletedPartialProgress({
id: 1,
url: 'https://host/1.ts',
fileName: 'a.ts',
directory: '/tmp',
filePath: '/tmp/a.ts',
totalBytes: 1880,
catchup: metadata,
})
).toBeNull();
});
it('overwrites retained bytes and requests a fresh response without Range', async () => {
const dir = await mkdtemp(join(tmpdir(), 'archive-transfer-'));
const path = join(dir, 'show.ts');
const task: DownloadTask = {
id: 1,
url: 'https://host/archive.ts',
fileName: 'show.ts',
directory: dir,
catchup: metadata,
totalBytes: 999,
resumeValidator: 'old',
headers: { 'User-Agent': 'custom' },
};
try {
await writeFile(path + '.part', 'old data');
task.catchupExpectedPartialIdentity = await stat(path + '.part');
jest.mocked(recordArchivePartial).mockImplementationOnce(
async (_db, id, filePath, identity) => {
expect(filePath).toBe(path);
expect(identity).toEqual(
expect.objectContaining({
ino: String((await stat(path + '.part')).ino),
})
);
expect(id).toBe(task.id);
expect(await readFile(path + '.part', 'utf8')).toBe(
'old data'
);
}
);
const actualLimit = jest.requireActual<
typeof import('./download-catchup-limits')
>('./download-catchup-limits').getArchiveByteLimit;
jest.mocked(getArchiveByteLimit).mockImplementationOnce(
async (...args) => {
expect((await stat(path + '.part')).size).toBe(0);
return actualLimit(...args);
}
);
jest.mocked(requestWithValidatedRedirects).mockResolvedValue({
status: 200,
headers: {
'content-length': String(packets.length),
'content-type': 'video/mp2t',
},
data: Readable.from([packets]),
} as never);
jest.mocked(assertArchiveCopyHeadroom).mockImplementationOnce(
async (directory, size) => {
expect(await readFile(path + '.part')).toEqual(packets);
expect(size).toBe(packets.length);
return jest
.requireActual<
typeof import('./download-catchup-limits')
>('./download-catchup-limits')
.assertArchiveCopyHeadroom(directory, size);
}
);
const progress = await transferCatchupToPartialFile(
{} as DownloadsDatabase,
task,
{ path, partialPath: path + '.part', filename: 'show.ts' }
);
expect(progress).toEqual({
bytesDownloaded: packets.length,
totalBytes: packets.length,
});
expect(await readFile(path + '.part')).toEqual(packets);
expect(requestWithValidatedRedirects).toHaveBeenLastCalledWith(
task.url,
expect.objectContaining({
headers: {
'User-Agent': 'custom',
'Accept-Encoding': 'identity',
},
}),
expect.anything()
);
expect(task.resumeValidator).toBeNull();
expect(recordArchivePartial).toHaveBeenCalledWith(
expect.anything(),
task.id,
path,
expect.objectContaining({
dev: expect.any(String),
ino: expect.any(String),
})
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('aborts a stalled response and never reports completion', async () => {
const dir = await mkdtemp(join(tmpdir(), 'archive-abort-'));
const task: DownloadTask = {
id: 2,
url: 'https://host/archive.ts',
fileName: 'show.ts',
directory: dir,
catchup: metadata,
};
const body = new PassThrough();
jest.mocked(requestWithValidatedRedirects).mockResolvedValue({
status: 200,
headers: {},
data: body,
} as never);
try {
const transfer = transferCatchupToPartialFile(
{} as DownloadsDatabase,
task,
{
path: join(dir, 'show.ts'),
partialPath: join(dir, 'show.ts.part'),
filename: 'show.ts',
}
);
const rejected = expect(transfer).rejects.toThrow('interrupted');
await new Promise((resolve) => setImmediate(resolve));
task.abortController?.abort();
await rejected;
expect(body.destroyed).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
it.each(['symlink', 'hardlink', 'regular file'])(
'refuses a retained archive partial replaced by a %s',
async (kind) => {
const directory = await mkdtemp(join(tmpdir(), 'archive-replaced-'));
const path = join(directory, 'archive.ts'),
target = join(directory, 'unrelated.txt');
try {
await writeFile(target, 'keep this file intact');
await (kind === 'symlink'
? symlink(target, path + '.part')
: kind === 'hardlink'
? link(target, path + '.part')
: writeFile(path + '.part', 'keep this file intact'));
jest.mocked(requestWithValidatedRedirects).mockResolvedValue({
status: 200,
headers: { 'content-type': 'video/mp2t' },
data: Readable.from([packets]),
} as never);
const task: DownloadTask = {
id: 3,
catchupExpectedPartialIdentity: await stat(target),
url: 'https://host/archive.ts',
fileName: 'archive.ts',
directory,
catchup: metadata,
};
const journalClears =
jest.mocked(recordArchivePartial).mock.calls.length;
await expect(
transferCatchupToPartialFile({} as DownloadsDatabase, task, {
path,
partialPath: path + '.part',
filename: 'archive.ts',
})
).rejects.toThrow();
expect(jest.mocked(recordArchivePartial).mock.calls.length).toBe(
journalClears
);
expect(await readFile(path + '.part', 'utf8')).toBe(
'keep this file intact'
);
expect(await readFile(target, 'utf8')).toBe(
'keep this file intact'
);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
it('requires both finalization proof and unchanged identity to recover completion', async () => {
const directory = await mkdtemp(join(tmpdir(), 'archive-proof-'));
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath, packets);
const task: DownloadTask = {
id: 9,
directory,
fileName: 'show.ts',
filePath,
url: 'https://host/show.ts',
catchup: metadata,
totalBytes: packets.length,
};
expect(await getExistingCompletedFileProgress(task)).toBeNull();
task.catchupFinalized = {
filePath,
identity: await stat(filePath),
size: packets.length,
};
expect(await getExistingCompletedFileProgress(task)).toEqual({
filePath,
bytesDownloaded: packets.length,
totalBytes: packets.length,
});
await (
await import('node:fs/promises')
).rename(filePath, join(directory, 'original'));
await writeFile(filePath, packets);
expect(await getExistingCompletedFileProgress(task)).toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,166 @@
import { recordArchivePartial } from './download-catchup-journal';
import {
assertArchiveCopyHeadroom,
createArchiveByteGuard,
getArchiveByteLimit,
} from './download-catchup-limits';
import { openCatchupOutput } from './download-catchup-output';
import { Readable, Transform } from 'node:stream';
import { finished, pipeline } from 'node:stream/promises';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import { validateCatchupDownload } from './download-catchup';
import type { ReservedPartialDownloadFile } from './download-file-path';
import type {
DownloadsDatabase,
DownloadTask,
TransferProgress,
} from './download-task';
import {
persistProgress,
persistTransferStart,
} from './download-transfer-persistence';
/** Check every 188-byte MPEG-TS packet without retaining the media in memory. */
export function createTsValidator(): Transform {
let bytes = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
for (
let offset = (188 - (bytes % 188)) % 188;
offset < chunk.length;
offset += 188
) {
if (chunk[offset] !== 0x47) {
callback(
new Error(
'The provider did not return a valid TS archive'
)
);
return;
}
}
bytes += chunk.length;
callback(null, chunk);
},
flush(callback) {
callback(
bytes < 188 * 3 || bytes % 188 !== 0
? new Error('The archive stream is empty or incomplete')
: undefined
);
},
});
}
export async function transferCatchupToPartialFile(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): Promise<TransferProgress> {
const metadata = validateCatchupDownload(task.catchup, task.url);
const controller = new AbortController();
task.abortController = controller;
if (task.cancelRequested || task.pauseRequested) controller.abort();
// An archive endpoint must terminate. Bound both stalled and endless streams.
const timeoutMs = Math.min(
24 * 3600_000,
(metadata.stopTimestamp - metadata.startTimestamp) * 2000 + 600_000
);
const timer = setTimeout(() => controller.abort(), timeoutMs);
let readable: Readable | undefined;
let output: Awaited<ReturnType<typeof openCatchupOutput>> | undefined;
let pendingProgress = Promise.resolve();
try {
const response = await requestWithValidatedRedirects<Readable>(
task.url,
{
headers: { ...task.headers, 'Accept-Encoding': 'identity' },
method: 'GET',
responseType: 'stream',
decompress: false,
signal: controller.signal,
timeout: 30_000,
validateStatus: (status) => status === 200,
},
{ allowPrivateNetworks: true }
);
readable = response.data;
readable.on('error', () => undefined);
const contentType = String(response.headers['content-type'] ?? '');
if (
/mpegurl|html|json|xml|text\/plain/i.test(contentType) ||
(response.headers['content-encoding'] &&
response.headers['content-encoding'] !== 'identity')
) {
throw new Error('The provider did not return a TS archive');
}
const length = Number(response.headers['content-length']);
const totalBytes =
Number.isSafeInteger(length) && length > 0 ? length : null;
// Restart safely before measuring space: the retained file is
// reclaimable only after its verified descriptor has been truncated.
output = await openCatchupOutput(
reservation.partialPath,
task.catchupExpectedPartialIdentity,
(identity) =>
recordArchivePartial(db, task.id, reservation.path, identity)
);
output.stream.on('error', () => undefined);
task.catchupPartialIdentity = output.identity;
const byteLimit = await getArchiveByteLimit(
task.directory,
metadata.stopTimestamp - metadata.startTimestamp,
totalBytes
);
task.totalBytes = totalBytes;
task.resumeValidator = null;
await persistTransferStart(db, task, 0, totalBytes);
let bytesDownloaded = 0;
let lastProgress = Date.now();
const progress = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytesDownloaded += chunk.length;
if (Date.now() - lastProgress >= 500) {
lastProgress = Date.now();
const snapshot = { bytesDownloaded, totalBytes };
pendingProgress = pendingProgress.then(() =>
persistProgress(db, task, snapshot)
);
// Apply backpressure while persisting, so no updates outlive finalization.
pendingProgress.then(
() => callback(null, chunk),
(error: Error) => callback(error)
);
} else callback(null, chunk);
},
});
await pipeline(
readable,
createArchiveByteGuard(task.directory, byteLimit),
createTsValidator(),
progress,
output.stream,
{ signal: controller.signal }
);
if (totalBytes !== null && bytesDownloaded !== totalBytes) {
throw new Error('The archive stream ended before it was complete');
}
await assertArchiveCopyHeadroom(task.directory, bytesDownloaded);
return { bytesDownloaded, totalBytes: totalBytes ?? bytesDownloaded };
} catch {
// Network errors can embed credential-bearing request URLs.
throw new Error(
'Archive download failed: the stream was interrupted, expired, exceeded the size or free-space limit, or is not a complete TS response. Retry starts from the beginning.'
);
} finally {
clearTimeout(timer);
readable?.on('error', () => undefined);
readable?.destroy();
if (output) {
const closed = finished(output.stream).catch(() => undefined);
output.stream.destroy();
await closed;
}
await pendingProgress.catch(() => undefined);
}
}
@@ -0,0 +1,37 @@
import { validateCatchupDownload } from './download-catchup';
describe('catch-up download validation', () => {
const now = 1788800000;
const metadata = {
channelName: 'News',
startTimestamp: now - 7200,
stopTimestamp: now - 3600,
expiresAt: now + 86400,
};
it('accepts a completed programme inside its archive window', () => {
expect(
validateCatchupDownload(
metadata,
'https://host/timeshift/u/p/60/date/1.ts',
now
)
).toEqual(metadata);
});
it.each([
{ ...metadata, stopTimestamp: now + 1 },
{ ...metadata, stopTimestamp: metadata.startTimestamp },
{ ...metadata, startTimestamp: NaN },
{ ...metadata, expiresAt: now },
null,
])('refuses incomplete, invalid or expired programmes', (data) => {
expect(() =>
validateCatchupDownload(data, 'https://host/1.ts', now)
).toThrow();
});
it.each([
'https://host/archive.m3u8',
'https://host/timeshift.php?extension=m3u8',
])('refuses HLS URLs', (url) => {
expect(() => validateCatchupDownload(metadata, url, now)).toThrow(/TS/);
});
});
@@ -0,0 +1,49 @@
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
export function validateCatchupDownload(
value: CatchupDownloadMetadata | null | undefined,
url: string,
now = Math.floor(Date.now() / 1000)
): CatchupDownloadMetadata {
if (
!value ||
typeof value.channelName !== 'string' ||
!Number.isSafeInteger(value.startTimestamp) ||
value.startTimestamp <= 0 ||
!Number.isSafeInteger(value.stopTimestamp) ||
value.stopTimestamp <= value.startTimestamp ||
value.stopTimestamp > now
) {
throw new Error('Only completed archive programmes can be downloaded');
}
if (
value.expiresAt !== undefined &&
(!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= now)
) {
throw new Error('This programme is no longer available in the archive');
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid archive URL');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
/\.m3u8$/i.test(parsed.pathname) ||
[...parsed.searchParams.values()].some((v) => /^m3u8$/i.test(v))
) {
throw new Error('Archive downloads currently require a TS stream');
}
return value;
}
export function catchupForDownload(item: {
contentType: string;
catchup?: CatchupDownloadMetadata | null;
url: string;
}): CatchupDownloadMetadata | undefined {
return item.contentType === 'catchup'
? validateCatchupDownload(item.catchup, item.url)
: undefined;
}
@@ -0,0 +1,90 @@
import type { DownloadTask } from './download-task';
import {
getPartialDownloadSize,
removePartialDownloadFile,
} from './download-file-path';
import { constants } from 'node:fs';
import { copyFile, link, stat, unlink } from 'node:fs/promises';
import type { ReservedPartialDownloadFile } from './download-file-path';
export async function finalizePartialDownload(
reservation: ReservedPartialDownloadFile,
expectedFileSize: number
): Promise<number> {
try {
await link(reservation.partialPath, reservation.path);
} catch (error) {
if (!canCopyCompletedPartialAfterLinkFailure(error)) {
throw error;
}
await copyFile(
reservation.partialPath,
reservation.path,
constants.COPYFILE_EXCL
);
}
try {
await unlink(reservation.partialPath);
} catch (error) {
const fileSize = await getExpectedFinalFileSize(
reservation.path,
expectedFileSize
);
if (fileSize !== null) {
console.error(
'[Downloads] Failed to delete completed partial file:',
error
);
return fileSize;
}
throw error;
}
const fileStats = await stat(reservation.path);
return fileStats.size;
}
async function getExpectedFinalFileSize(
filePath: string,
expectedFileSize: number
): Promise<number | null> {
try {
const fileStats = await stat(filePath);
return fileStats.size === expectedFileSize ? fileStats.size : null;
} catch {
return null;
}
}
function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
const errorCode = (error as NodeJS.ErrnoException).code;
return (
errorCode === 'EACCES' ||
errorCode === 'ENOSYS' ||
errorCode === 'ENOTSUP' ||
errorCode === 'EOPNOTSUPP' ||
errorCode === 'EPERM' ||
errorCode === 'EXDEV'
);
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(
filePath: string | null | undefined
): boolean {
try {
removePartialDownloadFile(filePath);
return true;
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
return false;
}
}
export function getPausedByteCount(task: DownloadTask): number {
try {
return getPartialDownloadSize(task.filePath);
} catch (error) {
console.error('[Downloads] Failed to inspect partial file:', error);
return 0;
}
}
@@ -1,11 +1,18 @@
import { ArchivePartialReplacedError } from './download-catchup-output';
import {
finalizePartialDownload,
removePartialFile,
} from './download-file-finalize';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import { promoteCatchupDownload } from './download-catchup-completion';
import { recoverCatchupCompletion } from './download-catchup-finalize';
import { eq, sql } from 'drizzle-orm';
import { constants, existsSync } from 'node:fs';
import { copyFile, link, stat, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import * as schema from '../../database/schema';
import {
getPartialDownloadPath,
getPartialDownloadSize,
removePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type {
@@ -55,7 +62,9 @@ export async function handleDownloadFailure(
const existingCompletedFileProgress =
await getExistingCompletedFileProgress(task);
if (existingCompletedFileProgress) {
removePartialFile(existingCompletedFileProgress.filePath);
if (task.catchup) {
await cleanupStoredCatchupPartial(db, task.id, task.filePath);
} else removePartialFile(existingCompletedFileProgress.filePath);
await persistCompletion(
db,
task,
@@ -82,12 +91,19 @@ export async function handleDownloadFailure(
`[Downloads] Error downloading ${task.fileName}:`,
describeError(error)
);
removePartialFile(task.filePath);
const removed = task.catchup
? await cleanupStoredCatchupPartial(db, task.id, task.filePath, true)
: removePartialFile(task.filePath);
await db
.update(schema.downloads)
.set({
errorMessage: describeError(error),
filePath: null,
filePath:
task.catchup &&
!removed &&
!(error instanceof ArchivePartialReplacedError)
? (task.filePath ?? null)
: null,
resumeValidator: null,
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
@@ -103,10 +119,19 @@ export async function completeDownloadFromPartial(
): Promise<void> {
let fileSize: number;
try {
fileSize = await finalizePartialDownload(
reservation,
progress.bytesDownloaded
);
if (task.catchup) {
fileSize = await promoteCatchupDownload(
db,
task,
reservation,
progress
);
} else {
fileSize = await finalizePartialDownload(
reservation,
progress.bytesDownloaded
);
}
} catch (error) {
if (task.cancelRequested || task.pauseRequested) {
throw error;
@@ -234,6 +259,7 @@ async function persistRetainedPartialFailure(
export async function getExistingCompletedFileProgress(
task: DownloadTask
): Promise<CompletedPartialProgress | null> {
if (task.catchup) return recoverCatchupCompletion(task);
if (
!task.filePath ||
task.totalBytes === null ||
@@ -263,6 +289,7 @@ export async function getExistingCompletedFileProgress(
export function getCompletedPartialProgress(
task: DownloadTask
): CompletedPartialProgress | null {
if (task.catchup) return null;
if (
!task.filePath ||
task.totalBytes === null ||
@@ -291,84 +318,7 @@ export function getCompletedPartialProgress(
}
}
export function getPausedByteCount(task: DownloadTask): number {
try {
return getPartialDownloadSize(task.filePath);
} catch (error) {
console.error('[Downloads] Failed to inspect partial file:', error);
return 0;
}
}
async function finalizePartialDownload(
reservation: ReservedPartialDownloadFile,
expectedFileSize: number
): Promise<number> {
try {
await link(reservation.partialPath, reservation.path);
} catch (error) {
if (!canCopyCompletedPartialAfterLinkFailure(error)) {
throw error;
}
await copyFile(
reservation.partialPath,
reservation.path,
constants.COPYFILE_EXCL
);
}
try {
await unlink(reservation.partialPath);
} catch (error) {
const fileSize = await getExpectedFinalFileSize(
reservation.path,
expectedFileSize
);
if (fileSize !== null) {
console.error(
'[Downloads] Failed to delete completed partial file:',
error
);
return fileSize;
}
throw error;
}
const fileStats = await stat(reservation.path);
return fileStats.size;
}
async function getExpectedFinalFileSize(
filePath: string,
expectedFileSize: number
): Promise<number | null> {
try {
const fileStats = await stat(filePath);
return fileStats.size === expectedFileSize ? fileStats.size : null;
} catch {
return null;
}
}
function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
const errorCode = (error as NodeJS.ErrnoException).code;
return (
errorCode === 'EACCES' ||
errorCode === 'ENOSYS' ||
errorCode === 'ENOTSUP' ||
errorCode === 'EOPNOTSUPP' ||
errorCode === 'EPERM' ||
errorCode === 'EXDEV'
);
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(
filePath: string | null | undefined
): boolean {
try {
removePartialDownloadFile(filePath);
return true;
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
return false;
}
}
export {
getPausedByteCount,
removePartialFile,
} from './download-file-finalize';
@@ -1,3 +1,15 @@
import { readArchiveStatsSync } from './download-catchup-stats';
import { sameArchiveFileIdentity } from './download-catchup-output';
import {
cleanupStoredCatchupPartial,
cleanupStoredCatchupFinal,
} from './download-catchup-removal';
import type { DownloadsDatabase } from './download-task';
import {
readArchiveFinalizations,
verifiedArchiveSize,
type ArchiveDownloadProof,
} from './download-catchup-journal';
import { inArray, sql } from 'drizzle-orm';
import { statSync } from 'node:fs';
import { getDatabase } from '../../database/connection';
@@ -8,12 +20,31 @@ import {
} from './download-file-path';
interface StaleDownload {
contentType?: string;
proof?: ArchiveDownloadProof;
filePath: string | null;
id: number;
status: string;
totalBytes: number | null;
}
/** A journaled source cannot be adopted again after an unrelated replacement. */
function hasReplacedArchivePartial(download: StaleDownload): boolean {
if (
download.contentType !== 'catchup' ||
!download.proof ||
!download.filePath
)
return false;
try {
const file = readArchiveStatsSync(`${download.filePath}.part`);
const expected = download.proof.partialIdentity;
return !file.isFile() || !sameArchiveFileIdentity(file, expected);
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ENOENT';
}
}
function getRecoverablePartialSize(download: StaleDownload): number {
if (
(download.status !== 'downloading' && download.status !== 'queued') ||
@@ -40,6 +71,10 @@ function getRecoverablePartialSize(download: StaleDownload): number {
* commits the completion instead of orphaning the file and re-downloading.
*/
function getFinalizedFileSize(download: StaleDownload): number | null {
if (download.contentType === 'catchup')
return download.status === 'downloading'
? verifiedArchiveSize(download.filePath, download.proof)
: null;
if (
download.status !== 'downloading' ||
!download.filePath ||
@@ -58,7 +93,17 @@ function getFinalizedFileSize(download: StaleDownload): number | null {
}
}
function removeFailedPartial(download: StaleDownload): boolean {
async function removeFailedPartial(
db: DownloadsDatabase,
download: StaleDownload
): Promise<boolean> {
if (download.contentType === 'catchup')
return cleanupStoredCatchupPartial(
db,
download.id,
download.filePath,
true
);
if (!download.filePath) {
return true;
}
@@ -76,7 +121,14 @@ function removeFailedPartial(download: StaleDownload): boolean {
}
}
function removeCompletedPartial(download: StaleDownload): void {
async function removeCompletedPartial(
db: DownloadsDatabase,
download: StaleDownload
): Promise<void> {
if (download.contentType === 'catchup') {
await cleanupStoredCatchupPartial(db, download.id, download.filePath);
return;
}
if (!download.filePath) {
return;
}
@@ -95,9 +147,10 @@ function removeCompletedPartial(download: StaleDownload): void {
export async function resetStaleDownloads(): Promise<void> {
try {
const db = await getDatabase();
const downloads = await db
const rows = await db
.select({
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
id: schema.downloads.id,
status: schema.downloads.status,
totalBytes: schema.downloads.totalBytes,
@@ -110,6 +163,19 @@ export async function resetStaleDownloads(): Promise<void> {
'completed',
])
);
const proofs = await readArchiveFinalizations(
db,
rows
.filter((row) => row.contentType === 'catchup')
.map((row) => row.id)
);
const downloads = rows.map((row) => {
const proof = proofs.get(row.id);
return {
...row,
proof: proof?.filePath === row.filePath ? proof : undefined,
};
});
const completedDownloads = downloads.filter(
(download) => download.status === 'completed'
);
@@ -125,6 +191,23 @@ export async function resetStaleDownloads(): Promise<void> {
const finalizedIds = new Set(
finalizedDownloads.map((download) => download.id)
);
// A killed copy may have left an incomplete owned destination. Remove
// only that journal-bound entry before resuming the retained source.
for (const download of staleDownloads) {
if (
download.contentType === 'catchup' &&
download.proof &&
download.proof.phase !== 'transfer' &&
!finalizedIds.has(download.id) &&
verifiedArchiveSize(download.filePath, download.proof) === null
) {
await cleanupStoredCatchupFinal(
db,
download.id,
download.proof.filePath
);
}
}
// Queued rows are recoverable even without partial bytes: a resumed
// download waiting behind an active one is persisted as 'queued' with
// its retained .part, and a never-started queued row loses nothing by
@@ -137,7 +220,9 @@ export async function resetStaleDownloads(): Promise<void> {
}))
.filter(
(download) =>
download.status === 'queued' || download.bytesDownloaded > 0
!hasReplacedArchivePartial(download) &&
(download.status === 'queued' ||
download.bytesDownloaded > 0)
);
const recoverableIds = new Set(
recoverableDownloads.map((download) => download.id)
@@ -147,10 +232,16 @@ export async function resetStaleDownloads(): Promise<void> {
!recoverableIds.has(download.id) &&
!finalizedIds.has(download.id)
);
const cleanupResult = failedDownloads.map((download) => ({
...download,
partialRemoved: removeFailedPartial(download),
}));
const cleanupResult = await Promise.all(
failedDownloads.map(async (download) => ({
...download,
// Detach a known replacement without touching it. A later retry
// reserves another path instead of truncating the unrelated file.
partialRemoved:
hasReplacedArchivePartial(download) ||
(await removeFailedPartial(db, download)),
}))
);
const failedIdsWithRemovedPartials = cleanupResult
.filter((download) => download.partialRemoved)
.map((download) => download.id);
@@ -158,15 +249,20 @@ export async function resetStaleDownloads(): Promise<void> {
.filter((download) => !download.partialRemoved)
.map((download) => download.id);
completedDownloads.forEach(removeCompletedPartial);
await Promise.all(
completedDownloads.map((download) =>
removeCompletedPartial(db, download)
)
);
for (const download of finalizedDownloads) {
// The interrupted commit may also have left the .part behind.
removeCompletedPartial(download);
await removeCompletedPartial(db, download);
await db
.update(schema.downloads)
.set({
bytesDownloaded: download.finalizedSize,
totalBytes: download.finalizedSize,
errorMessage: null,
status: 'completed',
updatedAt: sql`CURRENT_TIMESTAMP`,
@@ -196,7 +292,9 @@ export async function resetStaleDownloads(): Promise<void> {
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, failedIdsWithRemovedPartials));
.where(
inArray(schema.downloads.id, failedIdsWithRemovedPartials)
);
}
if (failedIdsWithRetainedPartials.length > 0) {
@@ -207,7 +305,9 @@ export async function resetStaleDownloads(): Promise<void> {
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, failedIdsWithRetainedPartials));
.where(
inArray(schema.downloads.id, failedIdsWithRetainedPartials)
);
}
console.log('[Downloads] Reset stale downloads');
@@ -67,6 +67,11 @@ async function setup(options: SetupOptions = {}) {
}
});
const enqueueDownload = jest.fn();
const proof = { version: 1, phase: 'transfer' };
const removeJournaledCatchupPartial = jest.fn((_path, _proof, record) =>
record('/downloads/.iptvnator-cleanup-test/entry')
);
const recordArchiveCleanupPath = jest.fn();
jest.doMock('node:fs', () => ({
...jest.requireActual<typeof import('node:fs')>('node:fs'),
@@ -78,6 +83,15 @@ async function setup(options: SetupOptions = {}) {
}));
jest.doMock('../url-safety', () => ({ assertRemoteUrlAllowed }));
jest.doMock('./download-file-path', () => ({ removePartialDownloadFile }));
jest.doMock('./download-catchup-journal', () => ({
recordArchiveCleanupPath,
readArchiveFinalizations: jest
.fn()
.mockResolvedValue(new Map([[42, proof]])),
}));
jest.doMock('./download-catchup-removal', () => ({
removeJournaledCatchupPartial,
}));
jest.doMock('./download-runtime', () => ({ enqueueDownload }));
const { redownloadMissingRequest } = await import('./download-redownload');
@@ -85,6 +99,8 @@ async function setup(options: SetupOptions = {}) {
accessSync,
assertRemoteUrlAllowed,
enqueueDownload,
removeJournaledCatchupPartial,
proof,
lstatSync,
redownloadMissingRequest,
removePartialDownloadFile,
@@ -124,6 +140,30 @@ describe('redownload missing completed file', () => {
});
});
it('reserves a fresh archive path and uses journal-backed previous-partial cleanup', async () => {
const catchup = {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
};
const h = await setup({ row: { contentType: 'catchup', catchup } });
await expect(h.redownloadMissingRequest(42)).resolves.toEqual({
success: true,
});
expect(h.removeJournaledCatchupPartial).toHaveBeenCalledWith(
'/downloads/movie.mp4',
h.proof,
expect.any(Function)
);
expect(h.removePartialDownloadFile).not.toHaveBeenCalled();
expect(h.set).toHaveBeenCalledWith(
expect.objectContaining({ status: 'queued', filePath: null })
);
expect(h.enqueueDownload).toHaveBeenCalledWith(
expect.objectContaining({ catchup, filePath: null })
);
});
it('adds the Xtream fallback User-Agent to a legacy row', async () => {
const harness = await setup({
playlistType: 'xtream',
@@ -1,3 +1,9 @@
import {
readArchiveFinalizations,
recordArchiveCleanupPath,
} from './download-catchup-journal';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
import { catchupForDownload } from './download-catchup';
import { and, eq, sql } from 'drizzle-orm';
import { accessSync, constants } from 'node:fs';
import { basename, dirname } from 'node:path';
@@ -60,11 +66,19 @@ export async function redownloadMissingRequest(
return { error: 'Download folder is unavailable', success: false };
}
const catchup = catchupForDownload(item);
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
const headers = await resolveStoredDownloadHeaders(db, item);
try {
removePartialDownloadFile(item.filePath);
if (catchup) {
const proof = (await readArchiveFinalizations(db, [item.id])).get(
item.id
);
removeJournaledCatchupPartial(item.filePath, proof, (path) => {
if (proof) recordArchiveCleanupPath(db, item.id, proof, path);
});
} else removePartialDownloadFile(item.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete partial before missing-file recovery:',
@@ -80,6 +94,7 @@ export async function redownloadMissingRequest(
.update(schema.downloads)
.set({
bytesDownloaded: 0,
...(catchup ? { filePath: null } : {}),
errorMessage: null,
resumeValidator: null,
status: 'queued',
@@ -100,9 +115,10 @@ export async function redownloadMissingRequest(
}
enqueueDownload({
catchup,
directory: dirname(item.filePath),
fileName: basename(item.filePath),
filePath: item.filePath,
filePath: catchup ? null : item.filePath,
headers,
id: item.id,
resumeValidator: null,
@@ -0,0 +1,206 @@
import { ArchiveRecoveryRequiredError } from './download-catchup-capture';
import { and, eq, inArray } from 'drizzle-orm';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import {
readArchiveFinalizations,
verifiedArchiveSize,
recordArchiveCleanupPath,
} from './download-catchup-journal';
import { removeJournaledCatchupPartial } from './download-catchup-removal';
import { removePartialDownloadFile } from './download-file-path';
import {
broadcastDownloadUpdate,
isDownloadCommitting,
hasRuntimeDownload,
prepareArchiveRemoval,
removeDownloadFromRuntime,
} from './download-runtime';
const removablePartialStatuses = new Set([
'queued',
'paused',
'completed',
'failed',
'canceled',
]);
export async function removeDownloadRequest(downloadId: number) {
try {
if (!(await prepareArchiveRemoval(downloadId)))
return {
success: false,
error: 'Download is completing; try again shortly',
};
console.log('[Downloads] Remove download:', downloadId);
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const row = rows[0];
const proof =
row?.contentType === 'catchup'
? (await readArchiveFinalizations(db, [downloadId])).get(
downloadId
)
: undefined;
if (
isDownloadCommitting(downloadId) ||
(row?.contentType === 'catchup' && hasRuntimeDownload(downloadId))
)
return {
success: false,
error: 'Download is completing; try again shortly',
};
// A settled archive can still say downloading when its cancellation
// status write failed. Its journal must be drained before row deletion.
if (
row?.filePath &&
(row.contentType === 'catchup' ||
removablePartialStatuses.has(row.status))
) {
try {
if (row.contentType === 'catchup')
removeJournaledCatchupPartial(
row.filePath,
proof,
(path, kind) => {
if (proof)
recordArchiveCleanupPath(
db,
downloadId,
proof,
path,
kind
);
},
row.status !== 'completed' &&
verifiedArchiveSize(row.filePath, proof) === null
);
else removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
// Keep the row (and its runtime entry) so the .part is never
// orphaned, but answer with a structured failure the UI can
// surface instead of an opaque IPC rejection. Retrying the
// remove re-attempts the deletion.
console.error(
'[Downloads] Failed to delete partial file on remove:',
row.filePath,
cleanupError
);
return {
error: 'Could not delete the partial file',
...(cleanupError instanceof ArchiveRecoveryRequiredError
? { recoveryPath: cleanupError.recoveryPath }
: {}),
success: false,
};
}
}
if (removeDownloadFromRuntime(downloadId) === false)
return {
success: false,
error: 'Download is completing; try again shortly',
};
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
throw error;
}
}
export async function clearCompletedDownloadsRequest(playlistId?: string) {
try {
const db = await getDatabase();
const terminalStatus = inArray(schema.downloads.status, [
'completed',
'failed',
'canceled',
]);
const terminalFilter = playlistId
? and(eq(schema.downloads.playlistId, playlistId), terminalStatus)
: terminalStatus;
const rows = await db
.select({
id: schema.downloads.id,
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(terminalFilter);
const proofs = await readArchiveFinalizations(
db,
rows
.filter((row) => row.contentType === 'catchup')
.map((row) => row.id)
);
const downloadIdsToDelete: number[] = [];
let recoveryPath: string | undefined;
for (const row of rows) {
if (hasRuntimeDownload(row.id)) continue;
if (row.filePath && removablePartialStatuses.has(row.status)) {
try {
if (row.contentType === 'catchup')
removeJournaledCatchupPartial(
row.filePath,
proofs.get(row.id),
(path, kind) => {
const proof = proofs.get(row.id);
if (proof)
recordArchiveCleanupPath(
db,
row.id,
proof,
path,
kind
);
},
row.status !== 'completed' &&
verifiedArchiveSize(
row.filePath,
proofs.get(row.id)
) === null
);
else removePartialDownloadFile(row.filePath);
} catch (error) {
if (error instanceof ArchiveRecoveryRequiredError)
recoveryPath ??= error.recoveryPath;
console.error(
'[Downloads] Retaining download after partial cleanup failed:',
error
);
continue;
}
}
downloadIdsToDelete.push(row.id);
}
if (downloadIdsToDelete.length > 0) {
await db
.delete(schema.downloads)
.where(
and(
terminalFilter,
inArray(schema.downloads.id, downloadIdsToDelete)
)
);
broadcastDownloadUpdate();
}
return recoveryPath
? { success: false, recoveryPath }
: { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
}
@@ -18,6 +18,8 @@ const episodeRequest = {
function createDownloadRow(overrides: Partial<DownloadRow> = {}): DownloadRow {
return {
catchup: null,
programmeStart: 0,
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
@@ -1,5 +1,8 @@
import { and, eq, isNull, or } from 'drizzle-orm';
import type { ElectronBridgeEpisodeIdentityScope } from '@iptvnator/shared/interfaces';
import type {
CatchupDownloadMetadata,
ElectronBridgeEpisodeIdentityScope,
} from '@iptvnator/shared/interfaces';
import * as schema from '../../database/schema';
import type { DownloadsDatabase } from './download-task';
@@ -29,6 +32,7 @@ export type ExistingDownloadIdentityResolution =
DownloadIdentityNone | DownloadIdentityConflict | DownloadIdentityMatch;
export interface DownloadIdentityRequest {
catchup?: CatchupDownloadMetadata | null;
contentType: DownloadRow['contentType'];
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
@@ -92,7 +96,13 @@ export async function resolveExistingDownloadIdentity(
and(
eq(schema.downloads.playlistId, request.playlistId),
eq(schema.downloads.contentType, request.contentType),
eq(schema.downloads.xtreamId, request.xtreamId)
eq(schema.downloads.xtreamId, request.xtreamId),
request.contentType === 'catchup'
? eq(
schema.downloads.programmeStart,
request.catchup?.startTimestamp ?? 0
)
: undefined
)
)
.limit(1);
@@ -0,0 +1,75 @@
import type {
CatchupDownloadMetadata,
DownloadContentType,
DownloadMetadataSnapshot,
ElectronBridgeEpisodeIdentityScope,
} from '@iptvnator/shared/interfaces';
import { extname } from 'node:path';
export interface StartDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: DownloadContentType;
catchup?: CatchupDownloadMetadata | null;
title: string;
url: string;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
export function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function getExtensionFromUrl(url: string): string {
try {
// Sanitize too: URL pathnames may legally contain characters like ':'
// that would create NTFS alternate data streams on Windows.
const extension = sanitizeFilename(extname(new URL(url).pathname));
return extension.startsWith('.') ? extension : '.mp4';
} catch {
return '.mp4';
}
}
export function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
export function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const result: Record<string, string> = {};
if (headers.userAgent) {
result['User-Agent'] = headers.userAgent;
}
if (headers.origin) {
result.Origin = headers.origin;
}
if (headers.referer) {
result.Referer = headers.referer;
}
return Object.keys(result).length > 0 ? result : undefined;
}
export function serializeHeaders(
headers: Record<string, string> | undefined
): string | null {
return headers ? JSON.stringify(headers) : null;
}
@@ -1,3 +1,13 @@
import {
mkdtemp,
writeFile,
readFile,
lstat,
rename,
rm,
} from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { Download } from '../../database/schema';
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
@@ -62,6 +72,7 @@ async function setupStartMetadataRequest(
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
hasRuntimeDownload: jest.fn().mockReturnValue(false),
}));
jest.doMock('./download-file-availability', () => ({
getDownloadFileAvailabilityAsync,
@@ -86,6 +97,8 @@ async function setupStartMetadataRequest(
function createStartDownloadRow(overrides: Partial<Download> = {}): Download {
return {
catchup: null,
programmeStart: 0,
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
@@ -1055,3 +1068,192 @@ describe('download requests resume', () => {
expect(enqueueDownload).not.toHaveBeenCalled();
});
});
describe('catch-up submissions restarting terminal rows', () => {
it.each([false, true])(
'preserves replaced partials and retains ownership on cleanup errors (locked=%s)',
async (locked) => {
const directory = await mkdtemp(
join(tmpdir(), 'archive-resubmit-')
);
const filePath = join(directory, 'show.ts');
try {
await writeFile(filePath + '.part', 'owned bytes');
const partialIdentity = await lstat(filePath + '.part');
await rename(filePath + '.part', join(directory, 'original'));
await writeFile(filePath + '.part', 'unrelated replacement');
const catchup = {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
};
const proof = {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
};
jest.doMock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
readArchiveFinalizations: jest.fn(async () => {
if (locked) throw new Error('SQLITE_BUSY');
return new Map([[42, proof]]);
}),
recordArchiveCleanupPath: jest.fn(),
}));
jest.dontMock('./download-catchup-removal');
const h = await setupStartMetadataRequest(
createStartDownloadRow({
id: 42,
contentType: 'catchup',
status: 'completed',
filePath,
catchup,
programmeStart: 100,
})
);
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'
);
if (locked) {
expect(h.set).not.toHaveBeenCalled();
expect(h.enqueueDownload).not.toHaveBeenCalled();
} else {
expect(h.set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: null,
status: 'queued',
})
);
expect(h.enqueueDownload).toHaveBeenCalledWith(
expect.not.objectContaining({ filePath })
);
}
} finally {
jest.dontMock('./download-catchup-journal');
await rm(directory, { recursive: true, force: true });
}
}
);
});
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,
expiresAt: 201,
};
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 { assertRemoteUrlAllowed } = await import('../url-safety');
jest.mocked(assertRemoteUrlAllowed).mockRejectedValue(
new Error('ENOTFOUND')
);
jest.mocked(h.authorizer.requireAuthorized).mockRejectedValue(
new Error('Folder unavailable')
);
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(assertRemoteUrlAllowed).not.toHaveBeenCalled();
expect(h.authorizer.requireAuthorized).not.toHaveBeenCalled();
expect(await readFile(filePath, 'utf8')).toBe('complete archive');
} finally {
jest.dontMock('./download-catchup-journal');
await rm(directory, { recursive: true, force: true });
}
}
);
@@ -1,11 +1,17 @@
import type {
DownloadMetadataSnapshot,
ElectronBridgeEpisodeIdentityScope,
ElectronBridgeDownloadStartResult,
} from '@iptvnator/shared/interfaces';
import {
sanitizeFilename,
createFileName,
createHeaders,
serializeHeaders,
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';
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
import { and, eq, sql } from 'drizzle-orm';
import { basename, dirname, extname } from 'node:path';
import { eq, sql } from 'drizzle-orm';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
@@ -13,81 +19,13 @@ import { DownloadDirectoryAuthorizer } from './download-directory-authorization'
import { getDownloadFileAvailabilityWithTimeoutAsync } from './download-file-availability';
import { removePartialDownloadFileAsync } from './download-partial-cleanup';
import { resolveExistingDownloadIdentity } from './download-request-identity';
import { resolveStoredDownloadHeaders } from './download-request-headers';
import {
assertDownloadMetadataArtworkDiffersFromStream,
assertDownloadMetadataMatchesContentType,
decodeDownloadMetadataSnapshot,
encodeDownloadMetadataSnapshot,
} from './download-metadata-snapshot';
import { enqueueDownload } from './download-runtime';
export interface StartDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
metadataSnapshot?: DownloadMetadataSnapshot;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function getExtensionFromUrl(url: string): string {
try {
// Sanitize too: URL pathnames may legally contain characters like ':'
// that would create NTFS alternate data streams on Windows.
const extension = sanitizeFilename(extname(new URL(url).pathname));
return extension.startsWith('.') ? extension : '.mp4';
} catch {
return '.mp4';
}
}
function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const result: Record<string, string> = {};
if (headers.userAgent) {
result['User-Agent'] = headers.userAgent;
}
if (headers.origin) {
result.Origin = headers.origin;
}
if (headers.referer) {
result.Referer = headers.referer;
}
return Object.keys(result).length > 0 ? result : undefined;
}
function serializeHeaders(
headers: Record<string, string> | undefined
): string | null {
return headers ? JSON.stringify(headers) : null;
}
import { enqueueDownload, hasRuntimeDownload } from './download-runtime';
export async function startDownloadRequest(
data: StartDownloadRequest,
@@ -108,8 +46,6 @@ export async function startDownloadRequest(
throw new Error('Invalid download metadata snapshot');
}
console.log('[Downloads] Enqueue download:', data.title);
const directory = await authorizer.requireAuthorized(data.downloadFolder);
await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true });
const db = await getDatabase();
if (!data.playlistId) {
@@ -128,8 +64,6 @@ export async function startDownloadRequest(
success: false,
};
}
const fileName = createFileName(data.title, data.url);
const headers = createHeaders(data.headers);
if (identity.kind === 'match') {
const item = identity.item;
@@ -147,7 +81,11 @@ export async function startDownloadRequest(
data.url
);
}
if (item.contentType === 'episode' && item.status === 'completed') {
if (
(item.contentType === 'episode' ||
item.contentType === 'catchup') &&
item.status === 'completed'
) {
const completedFileAvailability =
await getDownloadFileAvailabilityWithTimeoutAsync(item);
if (completedFileAvailability === 'unknown') {
@@ -175,6 +113,31 @@ 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,
};
}
}
// These checks authorize another remote transfer, not reuse of a proven file.
const catchup = catchupForDownload(data);
const directory = await authorizer.requireAuthorized(data.downloadFolder);
await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true });
const fileName = catchup
? sanitizeFilename(data.title) + '.ts'
: createFileName(data.title, data.url);
const headers = createHeaders(data.headers);
if (identity.kind === 'match') {
const item = identity.item;
if (
['completed', 'failed', 'canceled'].includes(item.status) &&
item.filePath
@@ -182,8 +145,17 @@ export async function startDownloadRequest(
// A terminal row can still reference a retained .part; delete it
// before the restart clears filePath, or the file is orphaned.
// An unavailable or slow .part must keep its database owner.
const cleanup = await removePartialDownloadFileAsync(item.filePath);
if (cleanup === 'unknown') {
const cleaned =
item.contentType === 'catchup'
? await cleanupStoredCatchupPartial(
db,
item.id,
item.filePath,
'incomplete'
)
: (await removePartialDownloadFileAsync(item.filePath)) !==
'unknown';
if (!cleaned) {
console.error(
'[Downloads] Could not verify retained partial cleanup'
);
@@ -198,6 +170,8 @@ export async function startDownloadRequest(
await db
.update(schema.downloads)
.set({
catchup,
programmeStart: catchup?.startTimestamp ?? 0,
bytesDownloaded: 0,
errorMessage: null,
fileName,
@@ -220,6 +194,7 @@ export async function startDownloadRequest(
})
.where(eq(schema.downloads.id, item.id));
enqueueDownload({
catchup,
directory,
fileName,
headers,
@@ -256,6 +231,8 @@ export async function startDownloadRequest(
const result = await db.insert(schema.downloads).values({
contentType: data.contentType,
catchup,
programmeStart: catchup?.startTimestamp ?? 0,
episodeNumber: data.episodeNumber,
episodeIdentityScope: data.episodeIdentityScope,
fileName,
@@ -272,6 +249,7 @@ export async function startDownloadRequest(
});
const insertedId = Number(result.lastInsertRowid);
enqueueDownload({
catchup,
directory,
fileName,
headers,
@@ -281,155 +259,7 @@ export async function startDownloadRequest(
return { id: insertedId, success: true };
}
export async function retryDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Retry download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (!['failed', 'canceled'].includes(item.status)) {
return {
error: 'Can only retry failed or canceled downloads',
success: false,
};
}
const retainedFilePath =
item.status === 'failed' && 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.
const directory = retainedFilePath
? dirname(retainedFilePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = retainedFilePath
? basename(retainedFilePath)
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
const queuedUpdate = retainedFilePath
? {
errorMessage: null,
fileName,
status: 'queued' as const,
updatedAt: sql`CURRENT_TIMESTAMP`,
}
: {
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
resumeValidator: null,
status: 'queued' as const,
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
};
await db
.update(schema.downloads)
.set(queuedUpdate)
.where(eq(schema.downloads.id, downloadId));
enqueueDownload({
directory,
fileName,
filePath: retainedFilePath,
headers,
id: item.id,
resumeValidator: retainedFilePath ? item.resumeValidator : null,
totalBytes: retainedFilePath ? item.totalBytes : null,
url: item.url,
});
return { success: true };
}
export async function resumeDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Resume download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (item.status !== 'paused') {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
// See retryDownloadRequest: DB-recorded retained paths stay usable after
// the user switches download folders.
const directory = item.filePath
? dirname(item.filePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = item.filePath
? basename(item.filePath)
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
// Claim the row atomically: a concurrent resume for the same id loses
// this conditional update and must not enqueue a second task.
const claim = await db
.update(schema.downloads)
.set({
errorMessage: null,
fileName,
status: 'queued',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(schema.downloads.id, downloadId),
eq(schema.downloads.status, 'paused')
)
);
if (hasNoChanges(claim)) {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
enqueueDownload({
directory,
fileName,
filePath: item.filePath,
headers,
id: item.id,
resumeValidator: item.resumeValidator,
totalBytes: item.totalBytes,
url: item.url,
});
return { success: true };
}
function hasNoChanges(result: unknown): boolean {
return (
typeof result === 'object' &&
result !== null &&
'changes' in result &&
(result as { changes: number }).changes === 0
);
}
export {
retryDownloadRequest,
resumeDownloadRequest,
} from './download-resume-requests';
@@ -0,0 +1,188 @@
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';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
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, hasRuntimeDownload } from './download-runtime';
export async function retryDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Retry download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
if (!['failed', 'canceled'].includes(item.status)) {
return {
error: 'Can only retry failed or canceled downloads',
success: false,
};
}
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' || 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.
const directory = retainedFilePath
? dirname(retainedFilePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = retainedFilePath
? basename(retainedFilePath)
: catchup
? sanitizeFilename(item.title) + '.ts'
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
const queuedUpdate = retainedFilePath
? {
errorMessage: null,
fileName,
status: 'queued' as const,
updatedAt: sql`CURRENT_TIMESTAMP`,
}
: {
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
resumeValidator: null,
status: 'queued' as const,
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
};
await db
.update(schema.downloads)
.set(queuedUpdate)
.where(eq(schema.downloads.id, downloadId));
enqueueDownload({
catchup,
directory,
fileName,
filePath: retainedFilePath,
headers,
id: item.id,
resumeValidator: retainedFilePath ? item.resumeValidator : null,
totalBytes: retainedFilePath ? item.totalBytes : null,
url: item.url,
});
return { success: true };
}
export async function resumeDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Resume download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
if (item.status !== 'paused') {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
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
? dirname(item.filePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = item.filePath
? basename(item.filePath)
: catchup
? sanitizeFilename(item.title) + '.ts'
: createFileName(item.title, item.url);
const headers = await resolveStoredDownloadHeaders(db, item);
// Claim the row atomically: a concurrent resume for the same id loses
// this conditional update and must not enqueue a second task.
const claim = await db
.update(schema.downloads)
.set({
errorMessage: null,
fileName,
status: 'queued',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(schema.downloads.id, downloadId),
eq(schema.downloads.status, 'paused')
)
);
if (hasNoChanges(claim)) {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
enqueueDownload({
catchup,
directory,
fileName,
filePath: item.filePath,
headers,
id: item.id,
resumeValidator: item.resumeValidator,
totalBytes: item.totalBytes,
url: item.url,
});
return { success: true };
}
function hasNoChanges(result: unknown): boolean {
return (
typeof result === 'object' &&
result !== null &&
'changes' in result &&
(result as { changes: number }).changes === 0
);
}
@@ -0,0 +1,78 @@
import { eq, sql } from 'drizzle-orm';
import * as schema from '../../database/schema';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import { getPausedByteCount, removePartialFile } from './download-finalize';
import type { DownloadsDatabase, DownloadTask } from './download-task';
export async function persistCancellation(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Canceled: ${task.fileName}`);
const removed = task.catchup
? await cleanupStoredCatchupPartial(db, task.id, task.filePath, true)
: removePartialFile(task.filePath);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: removed ? null : (task.filePath ?? null),
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to persist cancellation:', error);
}
}
export async function persistPause(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Paused: ${task.fileName}`);
const bytesDownloaded = getPausedByteCount(task);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded,
errorMessage: null,
fileName: task.fileName,
filePath: task.filePath ?? null,
// Keep a mid-attempt validator promotion (complete overlap
// match) across pause/resume.
resumeValidator: task.resumeValidator ?? null,
status: 'paused',
totalBytes: task.totalBytes ?? null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to persist pause:', error);
}
}
export async function persistQueuedCancellation(
db: DownloadsDatabase,
downloadId: number,
// Keep the path when the retained .part could not be deleted, so a later
// remove/clear can retry the cleanup instead of orphaning the file.
retainedFilePath: string | null = null
): Promise<void> {
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: retainedFilePath,
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
}
@@ -0,0 +1,71 @@
import { existsSync } from 'node:fs';
import { rename } from 'node:fs/promises';
import {
readArchiveFinalizations,
verifiedArchiveSize,
} from './download-catchup-journal';
import { cleanupStoredCatchupFinal } from './download-catchup-removal';
import {
reserveFreshCatchupTarget,
reserveOwnedCatchupTarget,
} from './download-catchup-reservation';
import {
findAvailableFinalPath,
getPartialDownloadPath,
reserveAvailablePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type { DownloadsDatabase, DownloadTask } from './download-task';
export async function reserveTarget(
db: DownloadsDatabase,
task: DownloadTask
): Promise<ReservedPartialDownloadFile> {
if (task.filePath) {
if (task.catchup) {
const proof = (await readArchiveFinalizations(db, [task.id])).get(
task.id
);
if (
proof &&
proof.phase !== 'transfer' &&
verifiedArchiveSize(task.filePath, proof) === null &&
!(await cleanupStoredCatchupFinal(db, task.id, task.filePath))
) {
throw new Error(
'Could not clean the interrupted archive promotion'
);
}
}
if (!existsSync(task.filePath)) {
return {
filename: task.fileName,
partialPath: getPartialDownloadPath(task.filePath),
path: task.filePath,
};
}
if (task.catchup) return reserveFreshCatchupTarget(db, task);
// Something now occupies the recorded destination — possibly a file
// the user created while this download was paused or failed. Never
// inspect or delete it: move the retained .part to the next free
// numbered destination and finalize there instead.
const redirected = findAvailableFinalPath(task.filePath);
const currentPartial = getPartialDownloadPath(task.filePath);
const redirectedPartial = getPartialDownloadPath(redirected.path);
if (existsSync(currentPartial)) {
await rename(currentPartial, redirectedPartial);
}
return {
filename: redirected.filename,
partialPath: redirectedPartial,
path: redirected.path,
};
}
return task.catchup
? reserveOwnedCatchupTarget(db, task)
: reserveAvailablePartialDownloadFile(task.directory, task.fileName);
}
@@ -1,19 +1,24 @@
import { cleanupArchiveCapture } from './download-catchup-capture';
import { reserveTarget } from './download-runtime-reservation';
import {
clearArchiveFinalization,
readArchiveFinalizations,
} from './download-catchup-journal';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import {
persistCancellation,
persistPause,
persistQueuedCancellation,
} from './download-runtime-persistence';
import { transferCatchupToPartialFile } from './download-catchup-transfer';
import { eq, sql } from 'drizzle-orm';
import { existsSync } from 'node:fs';
import { rename } from 'node:fs/promises';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { broadcastDownloadUpdate } from './download-broadcast';
import {
findAvailableFinalPath,
getPartialDownloadPath,
reserveAvailablePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type { ReservedPartialDownloadFile } from './download-file-path';
import {
completeDownloadFromPartial,
getCompletedPartialProgress,
getPausedByteCount,
handleDownloadFailure,
removePartialFile,
} from './download-finalize';
@@ -21,7 +26,6 @@ import { transferWithReconnects } from './download-reconnect';
import {
requestDownloadCancellation,
requestDownloadPause,
type DownloadsDatabase,
type DownloadTask,
} from './download-task';
import { describeError } from './download-transfer';
@@ -30,6 +34,7 @@ export { broadcastDownloadUpdate, setMainWindow } from './download-broadcast';
const downloadQueue: DownloadTask[] = [];
let activeDownload: DownloadTask | null = null;
const settlementWaiters = new Map<number, Array<() => void>>();
export function enqueueDownload(task: DownloadTask): void {
// A duplicate id (e.g. two rapid Resume clicks racing the status
@@ -48,8 +53,7 @@ export function enqueueDownload(task: DownloadTask): void {
export async function pauseDownload(downloadId: number): Promise<boolean> {
if (activeDownload?.id === downloadId) {
requestDownloadPause(activeDownload);
return true;
return requestDownloadPause(activeDownload);
}
const queueIndex = downloadQueue.findIndex(
@@ -75,8 +79,7 @@ export async function pauseDownload(downloadId: number): Promise<boolean> {
export async function cancelDownload(downloadId: number): Promise<boolean> {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
return true;
return requestDownloadCancellation(activeDownload);
}
const queueIndex = downloadQueue.findIndex(
@@ -84,8 +87,15 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
);
if (queueIndex !== -1) {
const [queuedTask] = downloadQueue.splice(queueIndex, 1);
const removed = removePartialFile(queuedTask?.filePath);
const db = await getDatabase();
const removed = queuedTask?.catchup
? await cleanupStoredCatchupPartial(
db,
downloadId,
queuedTask.filePath,
true
)
: removePartialFile(queuedTask?.filePath);
await persistQueuedCancellation(
db,
downloadId,
@@ -99,6 +109,7 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
const rows = await db
.select({
filePath: schema.downloads.filePath,
contentType: schema.downloads.contentType,
status: schema.downloads.status,
})
.from(schema.downloads)
@@ -109,7 +120,15 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
return false;
}
const removed = removePartialFile(item.filePath);
const removed =
item.contentType === 'catchup'
? await cleanupStoredCatchupPartial(
db,
downloadId,
item.filePath,
true
)
: removePartialFile(item.filePath);
await persistQueuedCancellation(
db,
downloadId,
@@ -119,9 +138,42 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
return true;
}
export function removeDownloadFromRuntime(downloadId: number): void {
/** Keep the journal row until an accepted active cancellation has settled. */
export async function prepareArchiveRemoval(
downloadId: number
): Promise<boolean> {
if (activeDownload?.id === downloadId && activeDownload.catchup) {
if (!requestDownloadCancellation(activeDownload)) return false;
await new Promise<void>((resolve) => {
const waiters = settlementWaiters.get(downloadId) ?? [];
waiters.push(resolve);
settlementWaiters.set(downloadId, waiters);
});
} else if (
downloadQueue.some((task) => task.id === downloadId && task.catchup)
) {
await cancelDownload(downloadId);
}
return true;
}
export function hasRuntimeDownload(downloadId: number): boolean {
return (
activeDownload?.id === downloadId ||
downloadQueue.some((task) => task.id === downloadId)
);
}
export function isDownloadCommitting(downloadId: number): boolean {
return (
activeDownload?.id === downloadId &&
!!activeDownload.catchupCommitStarted
);
}
export function removeDownloadFromRuntime(downloadId: number): boolean {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
if (!requestDownloadCancellation(activeDownload)) return false;
}
const queueIndex = downloadQueue.findIndex(
@@ -130,6 +182,7 @@ export function removeDownloadFromRuntime(downloadId: number): void {
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
}
return true;
}
async function processQueue(): Promise<void> {
@@ -158,31 +211,12 @@ function finishTask(task: DownloadTask): void {
if (activeDownload === task) {
activeDownload = null;
}
for (const resolve of settlementWaiters.get(task.id) ?? []) resolve();
settlementWaiters.delete(task.id);
broadcastDownloadUpdate();
void processQueue();
}
async function persistQueuedCancellation(
db: DownloadsDatabase,
downloadId: number,
// Keep the path when the retained .part could not be deleted, so a later
// remove/clear can retry the cleanup instead of orphaning the file.
retainedFilePath: string | null = null
): Promise<void> {
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: retainedFilePath,
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
}
async function startDownload(task: DownloadTask): Promise<void> {
const db = await getDatabase();
await db
@@ -206,7 +240,23 @@ async function startDownload(task: DownloadTask): Promise<void> {
return;
}
reservation = await reserveTarget(task);
if (task.catchup) {
if (task.filePath) {
const proof = (
await readArchiveFinalizations(db, [task.id])
).get(task.id);
cleanupArchiveCapture(proof);
task.catchupExpectedPartialIdentity = proof?.partialIdentity;
// No durable ownership evidence: preserve the old entry and
// reserve a fresh destination instead of adopting it.
if (!proof) task.filePath = null;
}
if (!task.filePath) {
// A fresh reservation must not inherit a previous attempt's proof.
await clearArchiveFinalization(db, task.id);
}
}
reservation = await reserveTarget(db, task);
task.fileName = reservation.filename;
task.filePath = reservation.path;
await db
@@ -239,7 +289,9 @@ async function startDownload(task: DownloadTask): Promise<void> {
return;
}
const progress = await transferWithReconnects(db, task, reservation);
const progress = await (task.catchup
? transferCatchupToPartialFile(db, task, reservation)
: transferWithReconnects(db, task, reservation));
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
@@ -266,87 +318,3 @@ async function startDownload(task: DownloadTask): Promise<void> {
finishTask(task);
}
}
async function reserveTarget(
task: DownloadTask
): Promise<ReservedPartialDownloadFile> {
if (task.filePath) {
if (!existsSync(task.filePath)) {
return {
filename: task.fileName,
partialPath: getPartialDownloadPath(task.filePath),
path: task.filePath,
};
}
// Something now occupies the recorded destination — possibly a file
// the user created while this download was paused or failed. Never
// inspect or delete it: move the retained .part to the next free
// numbered destination and finalize there instead.
const redirected = findAvailableFinalPath(task.filePath);
const currentPartial = getPartialDownloadPath(task.filePath);
const redirectedPartial = getPartialDownloadPath(redirected.path);
if (existsSync(currentPartial)) {
await rename(currentPartial, redirectedPartial);
}
return {
filename: redirected.filename,
partialPath: redirectedPartial,
path: redirected.path,
};
}
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
}
async function persistCancellation(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Canceled: ${task.fileName}`);
const removed = removePartialFile(task.filePath);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: removed ? null : (task.filePath ?? null),
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to persist cancellation:', error);
}
}
async function persistPause(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Paused: ${task.fileName}`);
const bytesDownloaded = getPausedByteCount(task);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded,
errorMessage: null,
fileName: task.fileName,
filePath: task.filePath ?? null,
// Keep a mid-attempt validator promotion (complete overlap
// match) across pause/resume.
resumeValidator: task.resumeValidator ?? null,
status: 'paused',
totalBytes: task.totalBytes ?? null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to persist pause:', error);
}
}
@@ -1,3 +1,5 @@
import type { ArchiveFileIdentity } from './download-catchup-output';
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
import type { getDatabase } from '../../database/connection';
export type DownloadsDatabase = Awaited<ReturnType<typeof getDatabase>>;
@@ -12,6 +14,15 @@ export interface CompletedPartialProgress extends TransferProgress {
}
export interface DownloadTask {
catchup?: CatchupDownloadMetadata;
catchupPartialIdentity?: ArchiveFileIdentity;
catchupExpectedPartialIdentity?: ArchiveFileIdentity;
catchupCommitStarted?: boolean;
catchupFinalized?: {
filePath: string;
identity: ArchiveFileIdentity;
size: number;
};
id: number;
url: string;
fileName: string;
@@ -42,12 +53,16 @@ export interface DownloadTask {
probeEof?: boolean;
}
export function requestDownloadCancellation(task: DownloadTask): void {
export function requestDownloadCancellation(task: DownloadTask): boolean {
if (task.catchupCommitStarted) return false;
task.cancelRequested = true;
task.abortController?.abort();
return true;
}
export function requestDownloadPause(task: DownloadTask): void {
export function requestDownloadPause(task: DownloadTask): boolean {
if (task.catchupCommitStarted) return false;
task.pauseRequested = true;
task.abortController?.abort();
return true;
}
@@ -4,6 +4,13 @@ import {
mockBroadcastDownloadUpdate,
mockDownloadRow,
mockRemoveDownloadFromRuntime,
mockIsDownloadCommitting,
mockHasRuntimeDownload,
mockPrepareArchiveRemoval,
mockRemoveJournaledPartial,
mockArchiveProofs,
mockVerifiedArchiveSize,
mockRecordArchiveCleanupPath,
mockRemovePartialDownloadFile,
mockTerminalRows,
setupDownloadsEventsHarness,
@@ -14,6 +21,248 @@ describe('downloads events: partial-file cleanup', () => {
await setupDownloadsEventsHarness();
});
it.each(['remove', 'clear'])(
'returns a recoverable path and retains the row on %s',
async (action) => {
const { ArchiveRecoveryRequiredError } =
await import('./download-catchup-capture');
const row = {
...createDownloadRow('failed'),
contentType: 'catchup',
};
const { deleteWhere } =
action === 'remove'
? mockDownloadRow(row)
: mockTerminalRows([row]);
const recoveryPath = '/downloads/.iptvnator-cleanup-test/entry';
mockRemoveJournaledPartial.mockImplementation(() => {
throw new ArchiveRecoveryRequiredError(recoveryPath);
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const result =
action === 'remove'
? await getHandler('DOWNLOADS_REMOVE')(null, 42)
: await getHandler('DOWNLOADS_CLEAR_COMPLETED')(null);
expect(result).toMatchObject({ success: false, recoveryPath });
expect(deleteWhere).not.toHaveBeenCalled();
expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
}
);
it('waits for active archive cancellation before reading or deleting the row', async () => {
const { db, deleteWhere } = mockDownloadRow(
createDownloadRow('canceled')
);
let release!: (ready: boolean) => void;
mockPrepareArchiveRemoval.mockReturnValue(
new Promise<boolean>((resolve) => {
release = resolve;
})
);
const remove = getHandler('DOWNLOADS_REMOVE')(null, 42);
expect(db.select).not.toHaveBeenCalled();
expect(deleteWhere).not.toHaveBeenCalled();
release(true);
await expect(remove).resolves.toEqual({ success: true });
});
it('rejects Remove before reading storage when completion already owns the task', async () => {
const { db, deleteWhere } = mockDownloadRow(
createDownloadRow('downloading')
);
mockPrepareArchiveRemoval.mockResolvedValue(false);
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toMatchObject({ success: false });
expect(db.select).not.toHaveBeenCalled();
expect(deleteWhere).not.toHaveBeenCalled();
});
it('keeps a new archive attempt that starts while Remove reads storage', async () => {
const { deleteWhere } = mockDownloadRow({
...createDownloadRow('failed'),
contentType: 'catchup',
});
mockHasRuntimeDownload.mockReturnValue(true);
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toMatchObject({ success: false });
expect(mockRemoveJournaledPartial).not.toHaveBeenCalled();
expect(deleteWhere).not.toHaveBeenCalled();
});
it('preserves completed archive media when removing its library row', async () => {
mockDownloadRow({
...createDownloadRow('completed'),
contentType: 'catchup',
});
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{ success: true }
);
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
undefined,
expect.any(Function),
false
);
});
it.each([false, true])(
'retries settled archive cleanup despite a failed cancellation status write (locked=%s)',
async (locked) => {
const { deleteWhere } = mockDownloadRow({
...createDownloadRow('downloading'),
contentType: 'catchup',
});
const proof = {
version: 1,
phase: 'transfer',
partialCleanupPath: '/downloads/.iptvnator-cleanup-test/entry',
};
mockArchiveProofs.mockResolvedValue(new Map([[42, proof]]));
if (locked)
mockRemoveJournaledPartial.mockImplementation(() => {
throw new Error('EACCES');
});
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toMatchObject({ success: !locked });
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
proof,
expect.any(Function),
true
);
if (locked) expect(deleteWhere).not.toHaveBeenCalled();
else expect(deleteWhere).toHaveBeenCalled();
}
);
it.each(['remove', 'clear'])(
'preserves a proven completed archive after failed status writes on %s',
async (action) => {
const proof = { version: 1, phase: 'finalization', size: 188 };
const id = action === 'remove' ? 42 : 1;
const row = {
...createDownloadRow(
action === 'remove' ? 'downloading' : 'failed'
),
contentType: 'catchup',
};
if (action === 'remove') mockDownloadRow(row);
else mockTerminalRows([row]);
mockArchiveProofs.mockResolvedValue(new Map([[id, proof]]));
mockVerifiedArchiveSize.mockReturnValue(188);
const result =
action === 'remove'
? getHandler('DOWNLOADS_REMOVE')(null, id)
: getHandler('DOWNLOADS_CLEAR_COMPLETED')(null);
await expect(result).resolves.toEqual({ success: true });
expect(mockVerifiedArchiveSize).toHaveBeenCalledWith(
row.filePath,
proof
);
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
row.filePath,
proof,
expect.any(Function),
false
);
}
);
it('keeps a committing archive row and journal intact on Remove', async () => {
const { deleteWhere } = mockDownloadRow(
createDownloadRow('downloading')
);
mockIsDownloadCommitting.mockReturnValue(true);
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{
success: false,
error: 'Download is completing; try again shortly',
}
);
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled();
expect(deleteWhere).not.toHaveBeenCalled();
});
it('skips completion commits when clearing terminal rows', async () => {
const { deleteWhere } = mockTerminalRows([
createDownloadRow('completed'),
]);
mockHasRuntimeDownload.mockReturnValue(true);
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
expect(deleteWhere).not.toHaveBeenCalled();
});
it('routes archive Remove through durable cleanup instead of pathname unlink', async () => {
const { deleteWhere } = mockDownloadRow({
...createDownloadRow('failed'),
contentType: 'catchup',
});
const proof = { version: 1, phase: 'transfer' };
mockRemoveJournaledPartial.mockImplementation((_path, _proof, record) =>
record('/downloads/.iptvnator-cleanup-test/entry', 'partial')
);
mockArchiveProofs.mockResolvedValue(new Map([[42, proof]]));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{ success: true }
);
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
proof,
expect.any(Function),
true
);
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
expect(mockRecordArchiveCleanupPath).toHaveBeenCalledWith(
expect.anything(),
42,
proof,
'/downloads/.iptvnator-cleanup-test/entry',
'partial'
);
expect(deleteWhere).toHaveBeenCalled();
});
it('routes archive Clear completed through durable cleanup', async () => {
mockTerminalRows([
{ ...createDownloadRow('canceled'), contentType: 'catchup' },
]);
const proof = { version: 1, phase: 'transfer' };
mockRemoveJournaledPartial.mockImplementation((_path, _proof, record) =>
record('/downloads/.iptvnator-cleanup-test/entry', 'partial')
);
mockArchiveProofs.mockResolvedValue(new Map([[1, proof]]));
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
expect(mockRemoveJournaledPartial).toHaveBeenCalledWith(
'/downloads/resume.mp4',
proof,
expect.any(Function),
true
);
expect(mockRecordArchiveCleanupPath).toHaveBeenCalledWith(
expect.anything(),
1,
proof,
'/downloads/.iptvnator-cleanup-test/entry',
'partial'
);
expect(mockRemovePartialDownloadFile).not.toHaveBeenCalled();
});
it('removes queued resumed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
@@ -1,5 +1,9 @@
import {
clearCompletedDownloadsRequest,
removeDownloadRequest,
} from './download-removal-requests';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import { and, eq, inArray } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { app, dialog, ipcMain, shell } from 'electron';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -10,7 +14,6 @@ import {
decorateDownloadItemAsync,
isAvailableDownloadFile,
} from './download-file-availability';
import { removePartialDownloadFile } from './download-file-path';
import { updateDownloadMetadataRequest } from './download-metadata-update';
import {
resumeDownloadRequest,
@@ -24,18 +27,9 @@ import {
broadcastDownloadUpdate,
cancelDownload,
pauseDownload,
removeDownloadFromRuntime,
setMainWindow,
} from './download-runtime';
const removablePartialStatuses = new Set([
'queued',
'paused',
'completed',
'failed',
'canceled',
]);
function getDownloadAuthorizationPath(): string {
return join(
app.getPath('userData'),
@@ -176,49 +170,9 @@ ipcMain.handle(
}
);
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Remove download:', downloadId);
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const row = rows[0];
if (row?.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
// Keep the row (and its runtime entry) so the .part is never
// orphaned, but answer with a structured failure the UI can
// surface instead of an opaque IPC rejection. Retrying the
// remove re-attempts the deletion.
console.error(
'[Downloads] Failed to delete partial file on remove:',
row.filePath,
cleanupError
);
return {
error: 'Could not delete the partial file',
success: false,
};
}
}
removeDownloadFromRuntime(downloadId);
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
throw error;
}
});
ipcMain.handle('DOWNLOADS_REMOVE', (_event, downloadId: number) =>
removeDownloadRequest(downloadId)
);
ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
try {
@@ -305,62 +259,8 @@ ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
: { error: 'File not found', success: false };
});
ipcMain.handle(
'DOWNLOADS_CLEAR_COMPLETED',
async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
const terminalStatus = inArray(schema.downloads.status, [
'completed',
'failed',
'canceled',
]);
const terminalFilter = playlistId
? and(
eq(schema.downloads.playlistId, playlistId),
terminalStatus
)
: terminalStatus;
const rows = await db
.select({
id: schema.downloads.id,
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(terminalFilter);
const downloadIdsToDelete: number[] = [];
for (const row of rows) {
if (row.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (error) {
console.error(
'[Downloads] Retaining download after partial cleanup failed:',
error
);
continue;
}
}
downloadIdsToDelete.push(row.id);
}
if (downloadIdsToDelete.length > 0) {
await db
.delete(schema.downloads)
.where(
and(
terminalFilter,
inArray(schema.downloads.id, downloadIdsToDelete)
)
);
broadcastDownloadUpdate();
}
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
}
ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', (_event, playlistId?: string) =>
clearCompletedDownloadsRequest(playlistId)
);
export { resetStaleDownloads, setMainWindow };
@@ -11,6 +11,13 @@ type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
export const mockRegisteredHandlers = new Map<string, IpcHandler>();
export const mockGetDatabase = jest.fn();
export const mockRemoveDownloadFromRuntime = jest.fn();
export const mockIsDownloadCommitting = jest.fn();
export const mockHasRuntimeDownload = jest.fn();
export const mockPrepareArchiveRemoval = jest.fn();
export const mockArchiveProofs = jest.fn();
export const mockVerifiedArchiveSize = jest.fn();
export const mockRecordArchiveCleanupPath = jest.fn();
export const mockRemoveJournaledPartial = jest.fn();
export const mockBroadcastDownloadUpdate = jest.fn();
export const mockRemovePartialDownloadFile = jest.fn();
export const mockPauseDownload = jest.fn();
@@ -53,6 +60,13 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
mockRegisteredHandlers.clear();
mockGetDatabase.mockReset();
mockRemoveDownloadFromRuntime.mockReset();
mockIsDownloadCommitting.mockReset().mockReturnValue(false);
mockHasRuntimeDownload.mockReset().mockReturnValue(false);
mockPrepareArchiveRemoval.mockReset().mockResolvedValue(true);
mockVerifiedArchiveSize.mockReset().mockReturnValue(null);
mockArchiveProofs.mockReset().mockResolvedValue(new Map());
mockRecordArchiveCleanupPath.mockReset();
mockRemoveJournaledPartial.mockReset();
mockBroadcastDownloadUpdate.mockReset();
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
@@ -111,9 +125,20 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
jest.doMock('./download-redownload', () => ({
redownloadMissingRequest: mockRedownloadMissingRequest,
}));
jest.doMock('./download-catchup-journal', () => ({
readArchiveFinalizations: mockArchiveProofs,
verifiedArchiveSize: mockVerifiedArchiveSize,
recordArchiveCleanupPath: mockRecordArchiveCleanupPath,
}));
jest.doMock('./download-catchup-removal', () => ({
removeJournaledCatchupPartial: mockRemoveJournaledPartial,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
isDownloadCommitting: mockIsDownloadCommitting,
hasRuntimeDownload: mockHasRuntimeDownload,
prepareArchiveRemoval: mockPrepareArchiveRemoval,
pauseDownload: mockPauseDownload,
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
setMainWindow: jest.fn(),
@@ -168,6 +193,7 @@ export function expectManagedPathLookup(
}
export function mockDownloadRow(row: {
contentType?: string;
filePath: string | null;
status: string;
}) {
@@ -187,7 +213,11 @@ export function mockDownloadRow(row: {
}
export function mockTerminalRows(
rows: Array<{ filePath: string | null; status: string }>
rows: Array<{
filePath: string | null;
status: string;
contentType?: string;
}>
) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
"ARCHIVE_URL_COPIED": "تم نسخ رابط الأرشيف",
"ARCHIVE_URL_FAILED": "تعذّر نسخ رابط الأرشيف",
"ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر."
"ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر.",
"DOWNLOAD_ARCHIVE": "تنزيل البرنامج (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "أُضيف البرنامج إلى التنزيلات",
"ARCHIVE_DOWNLOAD_FAILED": "تعذر تنزيل البرنامج. قد لا يكون متاحًا بعد الآن.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "تنزيلات الأرشيف تدعم حاليًا تدفقات TS فقط.",
"ARCHIVE_DOWNLOAD_EXISTS": "هذا البرنامج موجود بالفعل في التنزيلات."
},
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
"IMPORT_PROGRESS": "استيراد EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "يلزم استعادة الملف",
"MESSAGE": "تم الاحتفاظ بملف غير مرتبط بهذا التنزيل في: {{path}}. استعد المحتوى الذي تحتاجه، ثم احذف نسخة الاستعادة هذه وأعد المحاولة.",
"COPY_PATH": "نسخ مسار الاستعادة"
},
"TITLE": "التنزيلات",
"DOWNLOAD": "تنزيل",
"PLAY_LOCAL": "تشغيل محلي",
@@ -1395,9 +1405,12 @@
"RETRY": "إعادة محاولة {{title}}",
"MORE_ACTIONS": "إجراءات أخرى لـ {{title}}",
"COPY_URL": "نسخ رابط تنزيل {{title}}",
"DOWNLOAD_AGAIN": "تنزيل {{title}} مرة أخرى"
"DOWNLOAD_AGAIN": "تنزيل {{title}} مرة أخرى",
"RESTART_ARCHIVE": "تنزيل {{title}} من البداية"
},
"DOWNLOAD_AGAIN": "تنزيل مرة أخرى"
"DOWNLOAD_AGAIN": "تنزيل مرة أخرى",
"CATCHUP": "برنامج تلفزيوني",
"ARCHIVE_RESTART_NOTE": "بعد الانقطاع، يبدأ تنزيل الأرشيف من البداية."
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
"ARCHIVE_URL_COPIED": "تنسخ رابط الأرشيف",
"ARCHIVE_URL_FAILED": "ما قدرناش ننسخو رابط الأرشيف",
"ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر."
"ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر.",
"DOWNLOAD_ARCHIVE": "حمّل البرنامج (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "تزاد البرنامج للتنزيلات",
"ARCHIVE_DOWNLOAD_FAILED": "ما قدرناش نحمّلو البرنامج. يمكن ما بقاش متوفر.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "تنزيلات الأرشيف كتدعم دابا غير تدفقات TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "هاد البرنامج راه كاين فالتنزيلات."
},
"FETCH_SUCCESS": "EPG تجلب بنجاح",
"IMPORT_PROGRESS": "استيراد EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "خاص استرجاع الملف",
"MESSAGE": "تحفظ واحد الملف ما عندوش علاقة بهاد التحميل فـ: {{path}}. رجّع المحتوى اللي محتاج، مسح هاد النسخة الاحتياطية، وعاود جرّب.",
"COPY_PATH": "نسخ مسار الاسترجاع"
},
"TITLE": "التحميلات",
"DOWNLOAD": "تحميل",
"PLAY_LOCAL": "شغل محلياً",
@@ -1395,9 +1405,12 @@
"RETRY": "عاود جرّب {{title}}",
"MORE_ACTIONS": "إجراءات أخرى لـ {{title}}",
"COPY_URL": "نسخ رابط تحميل {{title}}",
"DOWNLOAD_AGAIN": "عاود حمّل {{title}}"
"DOWNLOAD_AGAIN": "عاود حمّل {{title}}",
"RESTART_ARCHIVE": "حمّل {{title}} من الأول"
},
"DOWNLOAD_AGAIN": "عاود حمّل"
"DOWNLOAD_AGAIN": "عاود حمّل",
"CATCHUP": "برنامج تلفزيوني",
"ARCHIVE_RESTART_NOTE": "من بعد الانقطاع، تنزيل الأرشيف كيبدا من الأول."
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Капіяваць спасылку на архіў",
"ARCHIVE_URL_COPIED": "Спасылка на архіў скапіявана",
"ARCHIVE_URL_FAILED": "Не ўдалося скапіяваць спасылку на архіў",
"ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы."
"ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы.",
"DOWNLOAD_ARCHIVE": "Спампаваць перадачу (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Перадача дададзена ў спампоўкі",
"ARCHIVE_DOWNLOAD_FAILED": "Не атрымалася спампаваць перадачу. Магчыма, яна ўжо недаступная.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Спампоўка архіва пакуль падтрымлівае толькі TS-патокі.",
"ARCHIVE_DOWNLOAD_EXISTS": "Гэтая перадача ўжо ёсць у спампоўках."
},
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
"IMPORT_PROGRESS": "Імпарт EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Інфармацыя",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Патрабуецца аднаўленне файла",
"MESSAGE": "Файл, не звязаны з гэтай загрузкай, захаваны па шляху: {{path}}. Захавайце патрэбнае змесціва, выдаліце гэтую рэзервовую копію і паўтарыце дзеянне.",
"COPY_PATH": "Капіяваць шлях аднаўлення"
},
"TITLE": "Спампаванні",
"DOWNLOAD": "Спампаваць",
"PLAY_LOCAL": "Прайграць лакальна",
@@ -1395,9 +1405,12 @@
"RETRY": "Паўтарыць {{title}}",
"MORE_ACTIONS": "Дадатковыя дзеянні для {{title}}",
"COPY_URL": "Скапіяваць URL спампоўкі для {{title}}",
"DOWNLOAD_AGAIN": "Спампаваць {{title}} яшчэ раз"
"DOWNLOAD_AGAIN": "Спампаваць {{title}} яшчэ раз",
"RESTART_ARCHIVE": "Спампаваць {{title}} нанова"
},
"DOWNLOAD_AGAIN": "Спампаваць яшчэ раз"
"DOWNLOAD_AGAIN": "Спампаваць яшчэ раз",
"CATCHUP": "Тэлеперадача",
"ARCHIVE_RESTART_NOTE": "Пасля перапынення спампоўка перадачы пачынаецца нанова."
},
"REMOTE_CONTROL": {
"TITLE": "Аддаленае кіраванне",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Archiv-URL kopieren",
"ARCHIVE_URL_COPIED": "Archiv-URL kopiert",
"ARCHIVE_URL_FAILED": "Archiv-URL konnte nicht kopiert werden",
"ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle."
"ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle.",
"DOWNLOAD_ARCHIVE": "Sendung herunterladen (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Sendung zu Downloads hinzugefügt",
"ARCHIVE_DOWNLOAD_FAILED": "Die Sendung konnte nicht heruntergeladen werden. Sie ist möglicherweise nicht mehr verfügbar.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archiv-Downloads unterstützen derzeit nur TS-Streams.",
"ARCHIVE_DOWNLOAD_EXISTS": "Diese Sendung ist bereits in Downloads."
},
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
"IMPORT_PROGRESS": "EPG-Import",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Information",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Dateiwiederherstellung erforderlich",
"MESSAGE": "Eine fremde Datei wurde hier gesichert: {{path}}. Sichern Sie benötigte Inhalte, löschen Sie diese Wiederherstellungskopie und versuchen Sie es erneut.",
"COPY_PATH": "Wiederherstellungspfad kopieren"
},
"TITLE": "Downloads",
"DOWNLOAD": "Herunterladen",
"PLAY_LOCAL": "Lokal abspielen",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} erneut versuchen",
"MORE_ACTIONS": "Weitere Aktionen für {{title}}",
"COPY_URL": "Download-URL für {{title}} kopieren",
"DOWNLOAD_AGAIN": "{{title}} erneut herunterladen"
"DOWNLOAD_AGAIN": "{{title}} erneut herunterladen",
"RESTART_ARCHIVE": "{{title}} von vorn herunterladen"
},
"DOWNLOAD_AGAIN": "Erneut herunterladen"
"DOWNLOAD_AGAIN": "Erneut herunterladen",
"CATCHUP": "TV-Sendung",
"ARCHIVE_RESTART_NOTE": "Nach einer Unterbrechung beginnt der Archiv-Download von vorn."
},
"REMOTE_CONTROL": {
"TITLE": "Fernbedienung",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Αντιγραφή URL αρχείου",
"ARCHIVE_URL_COPIED": "Το URL αρχείου αντιγράφηκε",
"ARCHIVE_URL_FAILED": "Αδυναμία αντιγραφής του URL αρχείου",
"ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής."
"ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής.",
"DOWNLOAD_ARCHIVE": "Λήψη εκπομπής (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Η εκπομπή προστέθηκε στις λήψεις",
"ARCHIVE_DOWNLOAD_FAILED": "Η λήψη απέτυχε. Η εκπομπή ίσως δεν είναι πλέον διαθέσιμη.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Οι λήψεις από το αρχείο υποστηρίζουν προς το παρόν μόνο ροές TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Αυτή η εκπομπή υπάρχει ήδη στις λήψεις."
},
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Απαιτείται ανάκτηση αρχείου",
"MESSAGE": "Ένα αρχείο άσχετο με αυτή τη λήψη διατηρήθηκε στη διαδρομή: {{path}}. Ανακτήστε ό,τι χρειάζεστε, διαγράψτε αυτό το αντίγραφο ανάκτησης και δοκιμάστε ξανά.",
"COPY_PATH": "Αντιγραφή διαδρομής ανάκτησης"
},
"TITLE": "Λήψεις",
"DOWNLOAD": "Λήψη",
"PLAY_LOCAL": "Τοπική αναπαραγωγή",
@@ -1395,9 +1405,12 @@
"RETRY": "Επανάληψη {{title}}",
"MORE_ACTIONS": "Περισσότερες ενέργειες για {{title}}",
"COPY_URL": "Αντιγραφή URL λήψης για {{title}}",
"DOWNLOAD_AGAIN": "Λήψη του {{title}} ξανά"
"DOWNLOAD_AGAIN": "Λήψη του {{title}} ξανά",
"RESTART_ARCHIVE": "Λήψη του {{title}} από την αρχή"
},
"DOWNLOAD_AGAIN": "Λήψη ξανά"
"DOWNLOAD_AGAIN": "Λήψη ξανά",
"CATCHUP": "Τηλεοπτική εκπομπή",
"ARCHIVE_RESTART_NOTE": "Μετά από διακοπή, η λήψη από το αρχείο ξεκινά από την αρχή."
},
"REMOTE_CONTROL": {
"TITLE": "Τηλεχειριστήριο",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Copy archive URL",
"ARCHIVE_URL_COPIED": "Archive URL copied",
"ARCHIVE_URL_FAILED": "Could not copy the archive URL",
"ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers."
"ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers.",
"DOWNLOAD_ARCHIVE": "Download programme (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programme added to Downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Could not download this programme. It may no longer be available.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archive downloads currently support TS streams only.",
"ARCHIVE_DOWNLOAD_EXISTS": "This programme is already in Downloads."
},
"FETCH_SUCCESS": "EPG was fetched successfully",
"IMPORT_PROGRESS": "EPG Import",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Information",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "File recovery needed",
"MESSAGE": "A file unrelated to this download was preserved at: {{path}}. Recover any content you need, delete this recovery copy, then try again.",
"COPY_PATH": "Copy recovery path"
},
"TITLE": "Downloads",
"DOWNLOAD": "Download",
"PLAY_LOCAL": "Play Local",
@@ -1395,9 +1405,12 @@
"RETRY": "Retry {{title}}",
"MORE_ACTIONS": "More actions for {{title}}",
"COPY_URL": "Copy download URL for {{title}}",
"DOWNLOAD_AGAIN": "Download {{title}} again"
"DOWNLOAD_AGAIN": "Download {{title}} again",
"RESTART_ARCHIVE": "Restart {{title}} from the beginning"
},
"DOWNLOAD_AGAIN": "Download again"
"DOWNLOAD_AGAIN": "Download again",
"CATCHUP": "TV programme",
"ARCHIVE_RESTART_NOTE": "Archive downloads restart from the beginning after an interruption."
},
"REMOTE_CONTROL": {
"TITLE": "Remote control",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Copiar URL del archivo",
"ARCHIVE_URL_COPIED": "URL del archivo copiada",
"ARCHIVE_URL_FAILED": "No se pudo copiar la URL del archivo",
"ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente."
"ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente.",
"DOWNLOAD_ARCHIVE": "Descargar programa (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programa añadido a Descargas",
"ARCHIVE_DOWNLOAD_FAILED": "No se pudo descargar el programa. Puede que ya no esté disponible.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Las descargas del archivo solo admiten flujos TS por ahora.",
"ARCHIVE_DOWNLOAD_EXISTS": "Este programa ya está en Descargas."
},
"FETCH_SUCCESS": "EPG obtenida correctamente",
"IMPORT_PROGRESS": "Importación de EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Información",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Se requiere recuperar un archivo",
"MESSAGE": "Se ha conservado un archivo ajeno a esta descarga en: {{path}}. Recupera el contenido que necesites, elimina esta copia de recuperación y vuelve a intentarlo.",
"COPY_PATH": "Copiar ruta de recuperación"
},
"TITLE": "Descargas",
"DOWNLOAD": "Descargar",
"PLAY_LOCAL": "Reproducir localmente",
@@ -1395,9 +1405,12 @@
"RETRY": "Reintentar {{title}}",
"MORE_ACTIONS": "Más acciones para {{title}}",
"COPY_URL": "Copiar la URL de descarga de {{title}}",
"DOWNLOAD_AGAIN": "Volver a descargar {{title}}"
"DOWNLOAD_AGAIN": "Volver a descargar {{title}}",
"RESTART_ARCHIVE": "Descargar {{title}} desde el principio"
},
"DOWNLOAD_AGAIN": "Volver a descargar"
"DOWNLOAD_AGAIN": "Volver a descargar",
"CATCHUP": "Programa de TV",
"ARCHIVE_RESTART_NOTE": "Tras una interrupción, la descarga del archivo empieza desde el principio."
},
"REMOTE_CONTROL": {
"TITLE": "Control remoto",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Copier le lien de rattrapage",
"ARCHIVE_URL_COPIED": "Lien de rattrapage copié",
"ARCHIVE_URL_FAILED": "Impossible de copier le lien de rattrapage",
"ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source."
"ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source.",
"DOWNLOAD_ARCHIVE": "Télécharger l’émission (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Émission ajoutée aux téléchargements",
"ARCHIVE_DOWNLOAD_FAILED": "Impossible de télécharger cette émission. Elle n’est peut-être plus disponible.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Seuls les flux TS sont pris en charge pour le téléchargement des archives.",
"ARCHIVE_DOWNLOAD_EXISTS": "Cette émission figure déjà dans les téléchargements."
},
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
"IMPORT_PROGRESS": "Importation EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Information",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Récupération du fichier nécessaire",
"MESSAGE": "Un fichier sans rapport avec ce téléchargement a été conservé ici : {{path}}. Récupérez le contenu utile, supprimez cette copie de récupération, puis réessayez.",
"COPY_PATH": "Copier le chemin de récupération"
},
"TITLE": "Téléchargements",
"DOWNLOAD": "Télécharger",
"PLAY_LOCAL": "Lire localement",
@@ -1395,9 +1405,12 @@
"RETRY": "Réessayer {{title}}",
"MORE_ACTIONS": "Plus d’actions pour {{title}}",
"COPY_URL": "Copier l’URL de téléchargement de {{title}}",
"DOWNLOAD_AGAIN": "Télécharger à nouveau {{title}}"
"DOWNLOAD_AGAIN": "Télécharger à nouveau {{title}}",
"RESTART_ARCHIVE": "Retélécharger {{title}} depuis le début"
},
"DOWNLOAD_AGAIN": "Télécharger à nouveau"
"DOWNLOAD_AGAIN": "Télécharger à nouveau",
"CATCHUP": "Émission TV",
"ARCHIVE_RESTART_NOTE": "Après une interruption, le téléchargement de l’archive reprend depuis le début."
},
"REMOTE_CONTROL": {
"TITLE": "Télécommande",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Archív URL másolása",
"ARCHIVE_URL_COPIED": "Archív URL másolva",
"ARCHIVE_URL_FAILED": "Az archív URL másolása sikertelen",
"ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet."
"ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet.",
"DOWNLOAD_ARCHIVE": "Műsor letöltése (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Műsor hozzáadva a letöltésekhez",
"ARCHIVE_DOWNLOAD_FAILED": "A műsor nem tölthető le. Lehet, hogy már nem érhető el.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Az archívum letöltése jelenleg csak TS adatfolyamokkal működik.",
"ARCHIVE_DOWNLOAD_EXISTS": "Ez a műsor már szerepel a letöltések között."
},
"FETCH_SUCCESS": "Az EPG lekérése sikerült",
"IMPORT_PROGRESS": "EPG importálása",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Információk",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Fájlhelyreállítás szükséges",
"MESSAGE": "A letöltéshez nem tartozó fájlt megőriztük itt: {{path}}. Mentse a szükséges tartalmat, törölje ezt a helyreállítási másolatot, majd próbálja újra.",
"COPY_PATH": "Helyreállítási útvonal másolása"
},
"TITLE": "Letöltések",
"DOWNLOAD": "Letöltés",
"PLAY_LOCAL": "Helyi fájl lejátszása",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} újrapróbálása",
"MORE_ACTIONS": "További műveletek: {{title}}",
"COPY_URL": "{{title}} letöltési URL-jének másolása",
"DOWNLOAD_AGAIN": "{{title}} letöltése újra"
"DOWNLOAD_AGAIN": "{{title}} letöltése újra",
"RESTART_ARCHIVE": "{{title}} letöltése elölről"
},
"DOWNLOAD_AGAIN": "Letöltés újra"
"DOWNLOAD_AGAIN": "Letöltés újra",
"CATCHUP": "TV-műsor",
"ARCHIVE_RESTART_NOTE": "Megszakítás után az archív műsor letöltése elölről indul."
},
"REMOTE_CONTROL": {
"TITLE": "Távvezérlés",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Copia URL archivio",
"ARCHIVE_URL_COPIED": "URL archivio copiato",
"ARCHIVE_URL_FAILED": "Impossibile copiare URL archivio",
"ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente."
"ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente.",
"DOWNLOAD_ARCHIVE": "Scarica programma (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programma aggiunto ai download",
"ARCHIVE_DOWNLOAD_FAILED": "Impossibile scaricare il programma. Potrebbe non essere più disponibile.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "I download dall’archivio supportano solo flussi TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Questo programma è già nei download."
},
"FETCH_SUCCESS": "EPG scaricato correttamente",
"IMPORT_PROGRESS": "Importazione EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Informazioni",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Recupero del file necessario",
"MESSAGE": "Un file estraneo a questo download è stato conservato in: {{path}}. Recupera i contenuti necessari, elimina questa copia di recupero e riprova.",
"COPY_PATH": "Copia percorso di recupero"
},
"TITLE": "Download",
"DOWNLOAD": "Scarica",
"PLAY_LOCAL": "Riproduci localmente",
@@ -1395,9 +1405,12 @@
"RETRY": "Riprova {{title}}",
"MORE_ACTIONS": "Altre azioni per {{title}}",
"COPY_URL": "Copia URL di download per {{title}}",
"DOWNLOAD_AGAIN": "Scarica di nuovo {{title}}"
"DOWNLOAD_AGAIN": "Scarica di nuovo {{title}}",
"RESTART_ARCHIVE": "Scarica {{title}} dall’inizio"
},
"DOWNLOAD_AGAIN": "Scarica di nuovo"
"DOWNLOAD_AGAIN": "Scarica di nuovo",
"CATCHUP": "Programma TV",
"ARCHIVE_RESTART_NOTE": "Dopo un’interruzione, il download dall’archivio ricomincia dall’inizio."
},
"REMOTE_CONTROL": {
"TITLE": "Controllo remoto",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "アーカイブURLをコピー",
"ARCHIVE_URL_COPIED": "アーカイブURLをコピーしました",
"ARCHIVE_URL_FAILED": "アーカイブURLをコピーできませんでした",
"ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。"
"ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。",
"DOWNLOAD_ARCHIVE": "番組をダウンロード (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "番組をダウンロードに追加しました",
"ARCHIVE_DOWNLOAD_FAILED": "番組をダウンロードできませんでした。配信が終了している可能性があります。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "アーカイブのダウンロードは現在TSストリームのみ対応しています。",
"ARCHIVE_DOWNLOAD_EXISTS": "この番組はすでにダウンロードにあります。"
},
"FETCH_SUCCESS": "EPGの取得に成功しました",
"IMPORT_PROGRESS": "EPGインポート",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "情報",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "ファイルの復元が必要です",
"MESSAGE": "このダウンロードとは無関係なファイルが {{path}} に保存されています。必要な内容を取り出し、この復元用コピーを削除してから、もう一度お試しください。",
"COPY_PATH": "復元パスをコピー"
},
"TITLE": "ダウンロード",
"DOWNLOAD": "ダウンロード",
"PLAY_LOCAL": "ローカル再生",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} を再試行",
"MORE_ACTIONS": "{{title}} のその他の操作",
"COPY_URL": "{{title}} のダウンロード URL をコピー",
"DOWNLOAD_AGAIN": "{{title}} を再ダウンロード"
"DOWNLOAD_AGAIN": "{{title}} を再ダウンロード",
"RESTART_ARCHIVE": "{{title}}を最初からダウンロード"
},
"DOWNLOAD_AGAIN": "再ダウンロード"
"DOWNLOAD_AGAIN": "再ダウンロード",
"CATCHUP": "テレビ番組",
"ARCHIVE_RESTART_NOTE": "中断後はアーカイブのダウンロードを最初からやり直します。"
},
"REMOTE_CONTROL": {
"TITLE": "リモートコントロール",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "다시보기 URL 복사",
"ARCHIVE_URL_COPIED": "다시보기 URL이 복사되었습니다",
"ARCHIVE_URL_FAILED": "다시보기 URL을 복사하지 못했습니다",
"ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다."
"ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다.",
"DOWNLOAD_ARCHIVE": "프로그램 다운로드 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "프로그램을 다운로드에 추가했습니다",
"ARCHIVE_DOWNLOAD_FAILED": "프로그램을 다운로드할 수 없습니다. 더 이상 제공되지 않을 수 있습니다.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "보관 프로그램 다운로드는 현재 TS 스트림만 지원합니다.",
"ARCHIVE_DOWNLOAD_EXISTS": "이 프로그램은 이미 다운로드 목록에 있습니다."
},
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
"IMPORT_PROGRESS": "EPG 가져오기",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "정보",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "파일 복구 필요",
"MESSAGE": "이 다운로드와 관련 없는 파일이 {{path}}에 보관되었습니다. 필요한 내용을 복구하고 이 복구용 사본을 삭제한 후 다시 시도하세요.",
"COPY_PATH": "복구 경로 복사"
},
"TITLE": "다운로드",
"DOWNLOAD": "다운로드",
"PLAY_LOCAL": "로컬 재생",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} 다시 시도",
"MORE_ACTIONS": "{{title}} 추가 작업",
"COPY_URL": "{{title}} 다운로드 URL 복사",
"DOWNLOAD_AGAIN": "{{title}} 다시 다운로드"
"DOWNLOAD_AGAIN": "{{title}} 다시 다운로드",
"RESTART_ARCHIVE": "{{title}} 처음부터 다운로드"
},
"DOWNLOAD_AGAIN": "다시 다운로드"
"DOWNLOAD_AGAIN": "다시 다운로드",
"CATCHUP": "TV 프로그램",
"ARCHIVE_RESTART_NOTE": "중단 후에는 보관 프로그램 다운로드를 처음부터 다시 시작합니다."
},
"REMOTE_CONTROL": {
"TITLE": "원격 제어",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Archief-URL kopiëren",
"ARCHIVE_URL_COPIED": "Archief-URL gekopieerd",
"ARCHIVE_URL_FAILED": "Kan archief-URL niet kopiëren",
"ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig."
"ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig.",
"DOWNLOAD_ARCHIVE": "Programma downloaden (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programma toegevoegd aan Downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Het programma kon niet worden gedownload. Het is mogelijk niet meer beschikbaar.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Archiefdownloads ondersteunen momenteel alleen TS-streams.",
"ARCHIVE_DOWNLOAD_EXISTS": "Dit programma staat al in Downloads."
},
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
"IMPORT_PROGRESS": "EPG-import",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Informatie",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Bestandsherstel nodig",
"MESSAGE": "Een bestand dat niet bij deze download hoort, is bewaard op: {{path}}. Bewaar de benodigde inhoud, verwijder deze herstelkopie en probeer het opnieuw.",
"COPY_PATH": "Herstelpad kopiëren"
},
"TITLE": "Downloads",
"DOWNLOAD": "Downloaden",
"PLAY_LOCAL": "Lokaal bestand afspelen",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} opnieuw proberen",
"MORE_ACTIONS": "Meer acties voor {{title}}",
"COPY_URL": "Download-URL voor {{title}} kopiëren",
"DOWNLOAD_AGAIN": "{{title}} opnieuw downloaden"
"DOWNLOAD_AGAIN": "{{title}} opnieuw downloaden",
"RESTART_ARCHIVE": "{{title}} vanaf het begin downloaden"
},
"DOWNLOAD_AGAIN": "Opnieuw downloaden"
"DOWNLOAD_AGAIN": "Opnieuw downloaden",
"CATCHUP": "TV-programma",
"ARCHIVE_RESTART_NOTE": "Na een onderbreking begint de archiefdownload opnieuw."
},
"REMOTE_CONTROL": {
"TITLE": "Afstandsbediening",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Kopiuj adres archiwum",
"ARCHIVE_URL_COPIED": "Skopiowano adres archiwum",
"ARCHIVE_URL_FAILED": "Nie udało się skopiować adresu archiwum",
"ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła."
"ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła.",
"DOWNLOAD_ARCHIVE": "Pobierz program (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Program dodany do pobierania",
"ARCHIVE_DOWNLOAD_FAILED": "Nie udało się pobrać programu. Może już być niedostępny.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Pobieranie z archiwum obsługuje obecnie tylko strumienie TS.",
"ARCHIVE_DOWNLOAD_EXISTS": "Ten program jest już na liście pobierania."
},
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
"IMPORT_PROGRESS": "Import EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Informacje",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Wymagane odzyskanie pliku",
"MESSAGE": "Plik niezwiązany z tym pobieraniem zachowano pod ścieżką: {{path}}. Odzyskaj potrzebną zawartość, usuń tę kopię odzyskiwania i spróbuj ponownie.",
"COPY_PATH": "Kopiuj ścieżkę odzyskiwania"
},
"TITLE": "Pobrane",
"DOWNLOAD": "Pobierz",
"PLAY_LOCAL": "Odtwórz plik lokalny",
@@ -1395,9 +1405,12 @@
"RETRY": "Ponów {{title}}",
"MORE_ACTIONS": "Więcej akcji dla {{title}}",
"COPY_URL": "Kopiuj URL pobierania dla {{title}}",
"DOWNLOAD_AGAIN": "Pobierz {{title}} ponownie"
"DOWNLOAD_AGAIN": "Pobierz {{title}} ponownie",
"RESTART_ARCHIVE": "Pobierz {{title}} od początku"
},
"DOWNLOAD_AGAIN": "Pobierz ponownie"
"DOWNLOAD_AGAIN": "Pobierz ponownie",
"CATCHUP": "Program TV",
"ARCHIVE_RESTART_NOTE": "Po przerwaniu pobieranie z archiwum zaczyna się od początku."
},
"REMOTE_CONTROL": {
"TITLE": "Zdalne sterowanie",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Copiar URL do arquivo",
"ARCHIVE_URL_COPIED": "URL do arquivo copiada",
"ARCHIVE_URL_FAILED": "Não foi possível copiar a URL do arquivo",
"ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte."
"ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte.",
"DOWNLOAD_ARCHIVE": "Baixar programa (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Programa adicionado aos downloads",
"ARCHIVE_DOWNLOAD_FAILED": "Não foi possível baixar o programa. Ele pode não estar mais disponível.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Os downloads do arquivo aceitam apenas fluxos TS por enquanto.",
"ARCHIVE_DOWNLOAD_EXISTS": "Este programa já está nos downloads."
},
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
"IMPORT_PROGRESS": "Importação de EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Informação",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "É necessário recuperar um ficheiro",
"MESSAGE": "Um ficheiro alheio a esta transferência foi preservado em: {{path}}. Recupere o conteúdo necessário, elimine esta cópia de recuperação e tente novamente.",
"COPY_PATH": "Copiar caminho de recuperação"
},
"TITLE": "Downloads",
"DOWNLOAD": "Baixar",
"PLAY_LOCAL": "Reproduzir local",
@@ -1395,9 +1405,12 @@
"RETRY": "Tentar {{title}} novamente",
"MORE_ACTIONS": "Mais ações para {{title}}",
"COPY_URL": "Copiar URL de download de {{title}}",
"DOWNLOAD_AGAIN": "Baixar {{title}} novamente"
"DOWNLOAD_AGAIN": "Baixar {{title}} novamente",
"RESTART_ARCHIVE": "Baixar {{title}} do início"
},
"DOWNLOAD_AGAIN": "Baixar novamente"
"DOWNLOAD_AGAIN": "Baixar novamente",
"CATCHUP": "Programa de TV",
"ARCHIVE_RESTART_NOTE": "Após uma interrupção, o download do arquivo começa do início."
},
"REMOTE_CONTROL": {
"TITLE": "Controle remoto",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Копировать ссылку на архив",
"ARCHIVE_URL_COPIED": "Ссылка на архив скопирована",
"ARCHIVE_URL_FAILED": "Не удалось скопировать ссылку на архив",
"ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника."
"ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника.",
"DOWNLOAD_ARCHIVE": "Скачать передачу (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Передача добавлена в загрузки",
"ARCHIVE_DOWNLOAD_FAILED": "Не удалось скачать передачу. Возможно, она уже недоступна в архиве.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Скачивание архива пока поддерживает только TS-потоки.",
"ARCHIVE_DOWNLOAD_EXISTS": "Эта передача уже есть в загрузках."
},
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
"IMPORT_PROGRESS": "Импорт EPG",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Инфо",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Требуется восстановление файла",
"MESSAGE": "Файл, не относящийся к этой загрузке, сохранён по пути: {{path}}. Сохраните нужное содержимое, удалите эту резервную копию и повторите действие.",
"COPY_PATH": "Копировать путь восстановления"
},
"TITLE": "Загрузки",
"DOWNLOAD": "Скачать",
"PLAY_LOCAL": "Воспроизвести локально",
@@ -1395,9 +1405,12 @@
"RETRY": "Повторить {{title}}",
"MORE_ACTIONS": "Дополнительные действия для {{title}}",
"COPY_URL": "Скопировать URL загрузки для {{title}}",
"DOWNLOAD_AGAIN": "Скачать {{title}} снова"
"DOWNLOAD_AGAIN": "Скачать {{title}} снова",
"RESTART_ARCHIVE": "Скачать {{title}} заново"
},
"DOWNLOAD_AGAIN": "Скачать снова"
"DOWNLOAD_AGAIN": "Скачать снова",
"CATCHUP": "Передача",
"ARCHIVE_RESTART_NOTE": "После прерывания скачивание передачи начинается заново."
},
"REMOTE_CONTROL": {
"TITLE": "Дистанционное управление",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "Arşiv URL’sini kopyala",
"ARCHIVE_URL_COPIED": "Arşiv URL’si kopyalandı",
"ARCHIVE_URL_FAILED": "Arşiv URL’si kopyalanamadı",
"ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir."
"ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir.",
"DOWNLOAD_ARCHIVE": "Programı indir (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "Program indirmelere eklendi",
"ARCHIVE_DOWNLOAD_FAILED": "Program indirilemedi. Artık mevcut olmayabilir.",
"ARCHIVE_DOWNLOAD_TS_ONLY": "Arşiv indirmeleri şu anda yalnızca TS akışlarını destekler.",
"ARCHIVE_DOWNLOAD_EXISTS": "Bu program zaten indirmelerde."
},
"FETCH_SUCCESS": "EPG verileri başarıyla alındı",
"IMPORT_PROGRESS": "EPG İçe Aktarımı",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "Bilgi",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Dosya kurtarma gerekiyor",
"MESSAGE": "Bu indirmeyle ilgisi olmayan bir dosya şurada korundu: {{path}}. Gerekli içeriği kurtarın, bu kurtarma kopyasını silin ve yeniden deneyin.",
"COPY_PATH": "Kurtarma yolunu kopyala"
},
"TITLE": "İndirilenler",
"DOWNLOAD": "İndir",
"PLAY_LOCAL": "Yerel olarak oynat",
@@ -1395,9 +1405,12 @@
"RETRY": "{{title}} için yeniden dene",
"MORE_ACTIONS": "{{title}} için daha fazla eylem",
"COPY_URL": "{{title}} için indirme URL’sini kopyala",
"DOWNLOAD_AGAIN": "{{title}} öğesini yeniden indir"
"DOWNLOAD_AGAIN": "{{title}} öğesini yeniden indir",
"RESTART_ARCHIVE": "{{title}} programını baştan indir"
},
"DOWNLOAD_AGAIN": "Yeniden indir"
"DOWNLOAD_AGAIN": "Yeniden indir",
"CATCHUP": "TV programı",
"ARCHIVE_RESTART_NOTE": "Kesintiden sonra arşiv indirmesi baştan başlar."
},
"REMOTE_CONTROL": {
"TITLE": "Uzaktan kontrol",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "复制回看链接",
"ARCHIVE_URL_COPIED": "已复制回看链接",
"ARCHIVE_URL_FAILED": "无法复制回看链接",
"ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。"
"ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。",
"DOWNLOAD_ARCHIVE": "下载节目 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "节目已加入下载列表",
"ARCHIVE_DOWNLOAD_FAILED": "无法下载此节目,它可能已不可用。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "回看下载目前仅支持 TS 流。",
"ARCHIVE_DOWNLOAD_EXISTS": "此节目已在下载列表中。"
},
"FETCH_SUCCESS": "EPG 获取成功",
"IMPORT_PROGRESS": "EPG 导入",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "信息",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "需要恢复文件",
"MESSAGE": "与此下载无关的文件已保存在:{{path}}。请保存所需内容,删除此恢复副本,然后重试。",
"COPY_PATH": "复制恢复路径"
},
"TITLE": "下载",
"DOWNLOAD": "下载",
"PLAY_LOCAL": "播放本地文件",
@@ -1395,9 +1405,12 @@
"RETRY": "重试 {{title}}",
"MORE_ACTIONS": "{{title}} 的更多操作",
"COPY_URL": "复制 {{title}} 的下载 URL",
"DOWNLOAD_AGAIN": "再次下载 {{title}}"
"DOWNLOAD_AGAIN": "再次下载 {{title}}",
"RESTART_ARCHIVE": "从头下载 {{title}}"
},
"DOWNLOAD_AGAIN": "再次下载"
"DOWNLOAD_AGAIN": "再次下载",
"CATCHUP": "电视节目",
"ARCHIVE_RESTART_NOTE": "中断后,回看节目的下载将从头开始。"
},
"REMOTE_CONTROL": {
"TITLE": "远程控制",
+16 -3
View File
@@ -816,7 +816,12 @@
"COPY_ARCHIVE_URL": "複製回看連結",
"ARCHIVE_URL_COPIED": "已複製回看連結",
"ARCHIVE_URL_FAILED": "無法複製回看連結",
"ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。"
"ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。",
"DOWNLOAD_ARCHIVE": "下載節目 (TS)",
"ARCHIVE_DOWNLOAD_QUEUED": "節目已加入下載清單",
"ARCHIVE_DOWNLOAD_FAILED": "無法下載此節目,它可能已無法使用。",
"ARCHIVE_DOWNLOAD_TS_ONLY": "回看下載目前僅支援 TS 串流。",
"ARCHIVE_DOWNLOAD_EXISTS": "此節目已在下載清單中。"
},
"FETCH_SUCCESS": "EPG 已成功擷取",
"IMPORT_PROGRESS": "EPG 匯入",
@@ -1261,6 +1266,11 @@
},
"INFORMATION": "資訊",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "需要復原檔案",
"MESSAGE": "與此下載無關的檔案已保存在:{{path}}。請儲存所需內容,刪除此復原副本,然後重試。",
"COPY_PATH": "複製復原路徑"
},
"TITLE": "下載",
"DOWNLOAD": "下載",
"PLAY_LOCAL": "播放本機檔案",
@@ -1395,9 +1405,12 @@
"RETRY": "重試 {{title}}",
"MORE_ACTIONS": "{{title}} 的更多操作",
"COPY_URL": "複製 {{title}} 的下載 URL",
"DOWNLOAD_AGAIN": "再次下載 {{title}}"
"DOWNLOAD_AGAIN": "再次下載 {{title}}",
"RESTART_ARCHIVE": "從頭下載 {{title}}"
},
"DOWNLOAD_AGAIN": "再次下載"
"DOWNLOAD_AGAIN": "再次下載",
"CATCHUP": "電視節目",
"ARCHIVE_RESTART_NOTE": "中斷後,回看節目的下載將從頭開始。"
},
"REMOTE_CONTROL": {
"TITLE": "遠端控制",
+16 -1
View File
@@ -322,7 +322,22 @@ function installStreamRoutes(
app.get('/series/:username/:password/:streamId.:ext', seriesResponse);
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
streamResponse
(request, response) => {
if (request.params['username'] === 'epg') {
response
.type('video/mp2t')
.send(
readFileSync(
join(
process.cwd(),
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
return;
}
streamResponse(request, response);
}
);
app.all('/streaming/timeshift.php', streamResponse);
}