mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user