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

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

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

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

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

* fix(downloads): verify archive identity through finalization

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

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

* fix(downloads): recover explicitly verified archive completions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(downloads): expose retained archive recovery files

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

No files matched your search

@@ -1,3 +1,17 @@
import {
getDownloadPlayPaths,
installDownloadPlayCapture,
} from './downloads.e2e-support';
import {
mkdirSync,
linkSync,
readFileSync,
readdirSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
@@ -15,6 +29,7 @@ import {
switchUnifiedCollectionScope,
test,
waitForXtreamWorkspaceReady,
workspaceRoot,
} from './electron-test-fixtures';
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
@@ -308,3 +323,462 @@ test('@epg @xtream @electron copies the archive URL without starting archive pla
await closeElectronApp(app);
}
});
test('@downloads @epg @xtream @electron downloads a completed archive into the local library', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
const credentials = { username: 'epg', password: 'epg' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
let app = await launchElectronApp(dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
try {
const folder = join(dataDir, 'archive-downloads');
mkdirSync(folder, { recursive: true });
await app.electronApp.evaluate(({ dialog }, target) => {
dialog.showOpenDialog = async () =>
({ canceled: false, filePaths: [target] }) as Awaited<
ReturnType<typeof dialog.showOpenDialog>
>;
}, folder);
await app.mainWindow.evaluate(async () => {
await window.electron.downloadsSelectFolder();
});
await addXtreamPortal(app.mainWindow, {
name: 'Archive downloads',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
const captured = captureTimeshiftRequests(app.mainWindow);
const block = app.mainWindow
.locator('app-epg-timeline .epg-timeline__block')
.filter({ hasText: PAST_PROGRAM })
.first();
await expect(block).toBeVisible({ timeout: 20000 });
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText('Programme added to Downloads', {
exact: true,
})
).toBeVisible();
await expect
.poll(
async () => {
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
return rows.find((row) => row.contentType === 'catchup')
?.status;
},
{ timeout: 30000 }
)
.toBe('completed');
const rows = await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
);
const row = rows.find((entry) => entry.contentType === 'catchup');
expect(row?.catchup?.channelName).toBe(CHANNEL);
expect(row?.title).toBe(PAST_PROGRAM);
expect(row?.filePath).toMatch(/archive-downloads.*\.ts$/);
if (!row?.filePath) throw new Error('No archive file');
expect(readFileSync(row.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
expect(captured).toEqual([]);
const ownership = await app.electronApp.evaluate(
(_electron, { dependency, file, id, mediaPath }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const proof = JSON.parse(
db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id).proof
);
const stats = process
.getBuiltinModule('fs')
.lstatSync(mediaPath, { bigint: true });
return {
recorded: proof.finalIdentity,
actual: {
dev: String(stats.dev),
ino: String(stats.ino),
},
};
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
mediaPath: row.filePath,
}
);
expect(ownership.recorded).toMatchObject(ownership.actual);
// A failed completion status write must recover the proven file in place
// on a repeated EPG submission, without a duplicate transfer.
await app.electronApp.evaluate(
(_electron, { dependency, file, id }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
db.prepare(
"UPDATE downloads SET status='failed' WHERE id=?"
).run(id);
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
}
);
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText(
'This programme is already in Downloads.',
{ exact: true }
)
).toBeVisible();
expect(
await app.mainWindow.evaluate(
async () => (await window.electron.downloadsGetList()).length
)
).toBe(1);
// Submitting the programme again after its file disappears must use
// journal ownership too, not delete a replacement at the old .part path.
const firstPath = row.filePath;
unlinkSync(firstPath);
writeFileSync(firstPath + '.part', 'unrelated submission partial');
await block.locator('.epg-timeline__info').click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', {
name: 'Download programme (TS)',
exact: true,
})
.click();
await expect
.poll(async () => {
const current = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
return (
current?.status === 'completed' &&
!!current.filePath &&
current.filePath !== firstPath
);
})
.toBe(true);
const resubmitted = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!resubmitted?.filePath)
throw new Error('Missing resubmitted archive');
expect(resubmitted.filePath).not.toBe(firstPath);
expect(readFileSync(firstPath + '.part', 'utf8')).toBe(
'unrelated submission partial'
);
row.filePath = resubmitted.filePath;
expect(readFileSync(row.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
const card = app.mainWindow.getByTestId(
`download-library-catchup-${row.id}`
);
await expect(card).toBeVisible();
await expect(card).toContainText('TV programme');
await expect(card).toContainText(CHANNEL);
await app.mainWindow.screenshot({
path: test.info().outputPath('archive-download-library.png'),
});
await installDownloadPlayCapture(app);
await card
.getByRole('button', { name: `Play: ${PAST_PROGRAM}`, exact: true })
.click();
await expect
.poll(() => getDownloadPlayPaths(app))
.toEqual([row.filePath]);
await expect(app.mainWindow).toHaveURL(
/\/workspace\/downloads(?:\?.*)?$/
);
// Model termination after verified promotion but before the completion
// DB write, including a response without Content-Length. Keep only the
// real SQLite journal written by the transfer; the next process has no task.
const durableProof = await app.electronApp.evaluate(
(_electron, { dependency, file, id }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const journal = db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id) as { proof: string } | undefined;
if (!journal)
throw new Error(
'Archive promotion journal was not persisted'
);
db.prepare(
"UPDATE downloads SET status='downloading', total_bytes=NULL WHERE id=?"
).run(id);
return JSON.parse(journal.proof) as {
version: number;
filePath: string;
size: number;
};
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
}
);
expect(durableProof).toEqual(
expect.objectContaining({
version: 1,
filePath: row.filePath,
size: readFileSync(row.filePath).length,
})
);
// Library remains usable after recovery, including archive metadata.
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
await expect(
app.mainWindow.getByTestId(`download-library-catchup-${row.id}`)
).toBeVisible();
const recovered = await app.mainWindow.evaluate(async () =>
(await window.electron.downloadsGetList()).find(
(entry) => entry.contentType === 'catchup'
)
);
expect(recovered).toEqual(
expect.objectContaining({
id: row.id,
status: 'completed',
filePath: row.filePath,
totalBytes: durableProof.size,
})
);
expect(
readdirSync(folder).filter((name) => name.endsWith('.ts'))
).toHaveLength(1);
// Missing-file recovery must reserve fresh output and preserve a foreign
// entry at the old partial name, even with a completed journal present.
unlinkSync(row.filePath);
writeFileSync(row.filePath + '.part', 'unrelated retained file');
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRedownloadMissing(id),
row.id
)
).toEqual({ success: true });
await expect
.poll(
async () =>
(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id)?.status
)
.toBe('completed');
const redownloaded = (
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).find((entry) => entry.id === row.id);
if (!redownloaded?.filePath)
throw new Error('Missing re-downloaded archive');
expect(redownloaded.filePath).not.toBe(row.filePath);
expect(readFileSync(row.filePath + '.part', 'utf8')).toBe(
'unrelated retained file'
);
expect(readFileSync(redownloaded.filePath)).toEqual(
readFileSync(
join(
workspaceRoot,
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
// Fail owned cleanup after private capture. Its recovery pointer must
// survive a process restart without relying on hardlink restoration.
linkSync(redownloaded.filePath, redownloaded.filePath + '.part');
await app.electronApp.evaluate(() => {
const fs = process.getBuiltinModule('fs');
const unlink = fs.unlinkSync;
fs.unlinkSync = (path) => {
if (String(path).includes('.iptvnator-cleanup-'))
throw Object.assign(new Error('simulated locked capture'), {
code: 'EACCES',
});
return unlink(path);
};
});
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRemove(id),
row.id
)
).toEqual({
success: false,
error: 'Could not delete the partial file',
});
const capturedPath = await app.electronApp.evaluate(
(_electron, { dependency, file, id }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(file);
try {
const record = db
.prepare(
'SELECT proof FROM download_archive_finalizations WHERE download_id=?'
)
.get(id) as { proof: string };
return (
JSON.parse(record.proof) as {
partialCleanupPath: string;
}
).partialCleanupPath;
} finally {
db.close();
}
},
{
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
file: join(dataDir, 'databases/iptvnator.db'),
id: row.id,
}
);
expect(capturedPath).toContain('.iptvnator-cleanup-');
expect(readFileSync(capturedPath)).toEqual(
readFileSync(redownloaded.filePath)
);
// A foreign replacement in the retained capture must survive restart
// and be discoverable without backend logs. Retain the original too.
renameSync(capturedPath, capturedPath + '.owned');
writeFileSync(capturedPath, 'foreign recovery content');
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
await app.mainWindow
.getByRole('button', { name: 'Open downloads', exact: true })
.click();
await app.mainWindow
.getByRole('button', { name: 'Clear finished', exact: true })
.click();
await app.mainWindow
.getByRole('dialog')
.getByRole('button', { name: 'Clear finished', exact: true })
.click();
const recoveryDialog = app.mainWindow.getByRole('dialog');
await expect(recoveryDialog).toContainText('File recovery needed');
await expect(recoveryDialog).toContainText(capturedPath);
await expect(
recoveryDialog.getByRole('button', {
name: 'Copy recovery path',
exact: true,
})
).toBeVisible();
await app.mainWindow.evaluate(() => {
Object.defineProperty(navigator.clipboard, 'writeText', {
configurable: true,
value: () =>
Promise.reject(new Error('simulated clipboard failure')),
});
});
await recoveryDialog
.getByRole('button', { name: 'Copy recovery path', exact: true })
.click();
await expect(recoveryDialog).toBeVisible();
await expect(recoveryDialog).toContainText(capturedPath);
expect(readFileSync(capturedPath, 'utf8')).toBe(
'foreign recovery content'
);
await recoveryDialog
.getByRole('button', { name: 'Close', exact: true })
.click();
// Simulate explicit user recovery; the app never deletes this copy.
unlinkSync(capturedPath);
renameSync(capturedPath + '.owned', capturedPath);
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
writeFileSync(
redownloaded.filePath + '.part',
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(
(id) => window.electron.downloadsRemove(id),
row.id
)
).toEqual({ success: true });
expect(readFileSync(redownloaded.filePath + '.part', 'utf8')).toBe(
'unrelated terminal file'
);
expect(
await app.mainWindow.evaluate(() =>
window.electron.downloadsGetList()
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});