mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "Аддаленае кіраванне",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Τηλεχειριστήριο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "リモートコントロール",
|
||||
|
||||
@@ -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": "원격 제어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дистанционное управление",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "远程控制",
|
||||
|
||||
@@ -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": "遠端控制",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user