feat: 增加下载任务中心与断点续传
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user