feat: 增加下载任务中心与断点续传

This commit is contained in:
lofyer
2026-08-05 15:25:26 +08:00
parent f72c26642f
commit cb7b020dc8
20 changed files with 1094 additions and 70 deletions
+216 -45
View File
@@ -150,6 +150,8 @@ const legacyImportPending = !settings.get('legacyImported', false);
let mainWindow;
let activeDownloads = 0;
const downloadSessions = new Map();
const downloadSessionSenders = new Set();
let readerPurgeSeq = 0;
let startupMaintenanceStarted = false;
const readerPurgeWaiters = new Map();
@@ -177,6 +179,40 @@ function ensureReaderWritable(entryId) {
if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
}
function downloadSessionKey(senderId, requestId) {
return `${senderId}:${requestId}`;
}
function removeDownloadPartial(session) {
if (!session || !session.partial) return;
try {
fs.unlinkSync(session.partial);
session.partial = '';
} catch (e) {
if (e.code === 'ENOENT') session.partial = '';
}
}
function discardDownloadSession(session) {
if (!session) return;
session.control = 'delete';
if (session.controller) session.controller.abort();
removeDownloadPartial(session);
if (session.state !== 'running') downloadSessions.delete(session.key);
}
function trackDownloadSender(webContents) {
const senderId = webContents.id;
if (downloadSessionSenders.has(senderId)) return;
downloadSessionSenders.add(senderId);
webContents.once('destroyed', () => {
downloadSessionSenders.delete(senderId);
for (const session of downloadSessions.values()) {
if (session.senderId === senderId) discardDownloadSession(session);
}
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1240,
@@ -276,6 +312,7 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
for (const download of downloadSessions.values()) discardDownloadSession(download);
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
});
@@ -439,7 +476,7 @@ ipcMain.handle('library:pickDir', async () => {
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
const dest = String(dir || '').trim();
if (!dest) throw new Error('目录不能为空');
if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录');
if (activeDownloads || downloadSessions.size) throw new Error('请等待当前下载完成或删除未完成任务后再切换书库目录');
const previousSetting = settings.get('libraryDir', '');
const previousRoot = library.getRoot();
try {
@@ -624,11 +661,17 @@ ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
if (!/^[A-Za-z0-9_-]{1,100}$/.test(progressId)) return { ok: false, error: '下载任务 ID 无效' };
const key = downloadSessionKey(event.sender.id, progressId);
let download = downloadSessions.get(key);
if (download && download.state === 'running') return { ok: false, error: '该下载任务正在运行' };
if (download && download.control === 'delete') return { ok: false, error: '该下载任务已删除' };
trackDownloadSender(event.sender);
activeDownloads++;
let partial = '';
let res = null;
let bodyHandled = false;
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const sendProgress = (data) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('download:progress', { requestId: progressId, ...data });
@@ -638,22 +681,65 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
const askSavePath = settings.get('askSavePath', false);
const suggested = library.sanitize(suggestName || 'download.bin');
let target;
if (askSavePath) {
if (!download) {
download = {
key,
senderId: event.sender.id,
requestId: progressId,
state: 'preparing',
control: '',
controller: null,
partial: '',
target: '',
defaultName: '',
askSavePath,
receivedBytes: 0,
totalBytes: null,
validator: '',
url: parsedUrl.toString(),
suggestName: String(suggestName || ''),
entryId,
extraHeaders: { ...(extraHeaders || {}) },
meta
};
downloadSessions.set(key, download);
}
if (download.url !== parsedUrl.toString()) throw new Error('续传链接与原任务不一致');
if (download.askSavePath && !download.target) {
const save = await dialog.showSaveDialog(liveWindow(), {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
target = save.filePath;
if (save.canceled || !save.filePath) {
downloadSessions.delete(key);
return { ok: true, data: { canceled: true } };
}
download.target = save.filePath;
}
if (download.control === 'delete') {
downloadSessions.delete(key);
return { ok: true, data: { deleted: true } };
}
const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) };
const headers = { 'User-Agent': DL_UA, ...download.extraHeaders };
headers['Referer'] = parsedUrl.origin + '/';
const resumeBytes = download.partial && fs.existsSync(download.partial)
? fs.statSync(download.partial).size
: 0;
download.receivedBytes = resumeBytes;
if (resumeBytes > 0) {
headers['Range'] = `bytes=${resumeBytes}-`;
if (download.validator) headers['If-Range'] = download.validator;
}
const ac = new AbortController();
download.controller = ac;
download.control = '';
download.state = 'running';
const timer = setTimeout(() => ac.abort(), 30000);
try {
res = await fetchWithProxy(parsedUrl.toString(), {
res = await fetchWithProxy(download.url, {
redirect: 'follow',
headers,
signal: ac.signal
@@ -661,30 +747,58 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
} finally {
clearTimeout(timer);
}
if (download.control) throw new Error('下载已中止');
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
const declaredSize = Number(res.headers.get('content-length'));
const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null;
let receivedBytes = 0;
let lastProgressAt = 0;
sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null });
const respName = filenameFromResponse(res, suggestName);
const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
const defaultName = library.sanitize(hasExt ? suggestName : respName);
const resumed = resumeBytes > 0 && res.status === 206;
if (resumeBytes > 0 && !resumed) {
removeDownloadPartial(download);
download.receivedBytes = 0;
}
const declaredSize = Number(res.headers.get('content-length'));
let totalBytes = Number.isFinite(declaredSize) && declaredSize > 0
? declaredSize + (resumed ? resumeBytes : 0)
: null;
const contentRange = res.headers.get('content-range') || '';
const rangeMatch = contentRange.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/i);
if (resumed && (!rangeMatch || Number(rangeMatch[1]) !== resumeBytes)) {
throw new Error('远端服务器返回了错误的断点位置,请删除任务后重新下载');
}
if (rangeMatch && rangeMatch[2] !== '*') totalBytes = Number(rangeMatch[2]);
if (resumed && download.totalBytes && totalBytes && download.totalBytes !== totalBytes) {
throw new Error('远端文件在暂停期间发生变化,请删除任务后重新下载');
}
if (!resumed) download.validator = res.headers.get('etag') || res.headers.get('last-modified') || '';
download.totalBytes = totalBytes;
let receivedBytes = resumed ? resumeBytes : 0;
let lastProgressAt = 0;
sendProgress({
receivedBytes,
totalBytes,
percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
});
const respName = filenameFromResponse(res, download.suggestName);
const hasExt = download.suggestName && /\.[a-z0-9]{2,5}$/i.test(download.suggestName);
if (!download.defaultName) {
download.defaultName = library.sanitize(hasExt ? download.suggestName : respName);
}
const contentType = res.headers.get('content-type') || '';
const expectedExt = path.extname(target || defaultName).toLowerCase();
const expectedExt = path.extname(download.target || download.defaultName).toLowerCase();
if (/text\/html|application\/json/i.test(contentType)
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
throw new Error('下载地址返回了网页而不是文献文件');
}
if (!target) target = library.allocFilePath(defaultName);
if (!download.target) download.target = library.allocFilePath(download.defaultName);
fs.mkdirSync(path.dirname(target), { recursive: true });
partial = path.join(
path.dirname(target),
`.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
);
fs.mkdirSync(path.dirname(download.target), { recursive: true });
if (!download.partial) {
download.partial = path.join(
path.dirname(download.target),
`.${path.basename(download.target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
);
}
if (!res.body) throw new Error('下载响应没有文件内容');
let transferTimer;
const refreshTransferTimer = () => {
@@ -695,6 +809,7 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
transform(chunk, encoding, callback) {
refreshTransferTimer();
receivedBytes += chunk.length;
download.receivedBytes = receivedBytes;
const now = Date.now();
if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
lastProgressAt = now;
@@ -710,71 +825,127 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
refreshTransferTimer();
bodyHandled = true;
try {
await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' }));
await pipeline(
Readable.fromWeb(res.body),
activity,
fs.createWriteStream(download.partial, { flags: resumed ? 'a' : 'wx' })
);
} finally {
clearTimeout(transferTimer);
}
if (askSavePath) {
const backup = `${target}.${process.pid}-${Date.now()}.bak`;
if (download.control) throw new Error('下载已中止');
if (download.askSavePath) {
const backup = `${download.target}.${process.pid}-${Date.now()}.bak`;
let backedUp = false;
try {
if (fs.existsSync(target)) {
fs.renameSync(target, backup);
if (fs.existsSync(download.target)) {
fs.renameSync(download.target, backup);
backedUp = true;
}
fs.renameSync(partial, target);
partial = '';
fs.renameSync(download.partial, download.target);
download.partial = '';
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
}
} catch (e) {
try {
if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
if (backedUp && !fs.existsSync(download.target) && fs.existsSync(backup)) {
fs.renameSync(backup, download.target);
}
} catch (rollbackError) { /* ignore */ }
throw e;
}
} else {
for (;;) {
try {
fs.linkSync(partial, target);
fs.linkSync(download.partial, download.target);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
target = library.allocFilePath(defaultName);
download.target = library.allocFilePath(download.defaultName);
}
}
try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
partial = '';
try { fs.unlinkSync(download.partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
download.partial = '';
}
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
// 落库:优先挂到已有条目,否则用 meta 新建
let id = entryId;
if (!id && meta) {
const existing = meta.sourceId && meta.sourcePostId
? library.findBySource(meta.sourceId, meta.sourcePostId) : null;
id = existing ? existing.id : library.add(meta).id;
let id = download.entryId;
if (!id && download.meta) {
const existing = download.meta.sourceId && download.meta.sourcePostId
? library.findBySource(download.meta.sourceId, download.meta.sourcePostId) : null;
id = existing ? existing.id : library.add(download.meta).id;
}
const entry = id ? library.attachFile(id, target) : null;
const entry = id ? library.attachFile(id, download.target) : null;
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
downloadSessions.delete(key);
return {
ok: true,
data: {
path: download.target,
name: path.basename(download.target),
entryId: id || null,
entry,
receivedBytes,
totalBytes
}
};
} catch (e) {
if (res && res.body && !bodyHandled) {
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
}
if (partial) {
try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ }
if (download && download.control === 'pause') {
download.state = 'paused';
download.controller = null;
if (download.partial && fs.existsSync(download.partial)) {
download.receivedBytes = fs.statSync(download.partial).size;
}
return {
ok: true,
data: {
paused: true,
receivedBytes: download.receivedBytes,
totalBytes: download.totalBytes
}
};
}
if (download && download.control === 'delete') {
removeDownloadPartial(download);
downloadSessions.delete(key);
return { ok: true, data: { deleted: true } };
}
removeDownloadPartial(download);
downloadSessions.delete(key);
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
return { ok: false, error: '下载超时,请检查网络或代理设置' };
}
return { ok: false, error: e.message || String(e) };
} finally {
if (download) download.controller = null;
activeDownloads--;
}
});
ipcMain.handle('download:pause', (event, requestId) => wrap(() => {
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
if (!download || download.state !== 'running' || !download.controller) return false;
download.control = 'pause';
download.controller.abort();
return true;
}));
ipcMain.handle('download:delete', (event, requestId) => wrap(() => {
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
if (!download) return false;
discardDownloadSession(download);
return true;
}));
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
// 在资源管理器里定位该文件,而不是静默失败。
ipcMain.handle('shell:openPath', async (_e, p) => {