修复批量下载不添加注册表的问题,增加下载系统鲁棒性
This commit is contained in:
@@ -63,7 +63,9 @@ function loggerMiddleware(req, res, next) {
|
|||||||
req.path === '/api/download/tasks/active' ||
|
req.path === '/api/download/tasks/active' ||
|
||||||
req.path === '/api/download/tasks/summary' ||
|
req.path === '/api/download/tasks/summary' ||
|
||||||
req.path === '/api/download/tasks/changes' ||
|
req.path === '/api/download/tasks/changes' ||
|
||||||
req.path === '/api/download/tasks/completed';
|
req.path === '/api/download/tasks/completed' ||
|
||||||
|
req.path === '/api/download/registry/stats' ||
|
||||||
|
/^\/api\/download\/check\/\d+/.test(req.path);
|
||||||
|
|
||||||
// 过滤掉仓库预览请求(图片预览)
|
// 过滤掉仓库预览请求(图片预览)
|
||||||
const isRepositoryPreview = req.path === '/api/repository/preview';
|
const isRepositoryPreview = req.path === '/api/repository/preview';
|
||||||
|
|||||||
@@ -439,6 +439,81 @@ router.post('/resume/:taskId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停批量下载任务
|
||||||
|
* POST /api/download/batch/pause/:taskId
|
||||||
|
*/
|
||||||
|
router.post('/batch/pause/:taskId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task ID is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.pauseBatchTask(taskId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '批量下载任务已暂停'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复批量下载任务
|
||||||
|
* POST /api/download/batch/resume/:taskId
|
||||||
|
*/
|
||||||
|
router.post('/batch/resume/:taskId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task ID is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadService = req.backend.getDownloadService();
|
||||||
|
const result = await downloadService.resumeBatchTask(taskId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
message: '批量下载任务已恢复'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消任务
|
* 取消任务
|
||||||
* DELETE /api/download/cancel/:taskId
|
* DELETE /api/download/cancel/:taskId
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ class DownloadExecutor {
|
|||||||
|
|
||||||
// 检查文件是否已存在且完整
|
// 检查文件是否已存在且完整
|
||||||
if (await this.fileManager.fileExists(filePath)) {
|
if (await this.fileManager.fileExists(filePath)) {
|
||||||
// 验证文件完整性
|
// 验证文件完整性,传入期望的MIME类型
|
||||||
const integrity = await this.fileManager.checkFileIntegrity(filePath);
|
const expectedMimeType = this.getMimeTypeFromUrl(imageUrl);
|
||||||
|
const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType);
|
||||||
if (integrity.valid) {
|
if (integrity.valid) {
|
||||||
// 只有在非恢复模式下才增加计数,避免重复计算
|
// 只有在非恢复模式下才增加计数,避免重复计算
|
||||||
if (!task.isResuming) {
|
if (!task.isResuming) {
|
||||||
@@ -90,7 +91,7 @@ class DownloadExecutor {
|
|||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// 文件不完整,删除重新下载
|
// 文件不完整,删除重新下载
|
||||||
logger.info(`文件不完整,重新下载: ${filePath}`);
|
logger.info(`文件不完整,重新下载: ${filePath}, 原因: ${integrity.reason}`);
|
||||||
await this.fileManager.safeDeleteFile(filePath);
|
await this.fileManager.safeDeleteFile(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,9 +103,12 @@ class DownloadExecutor {
|
|||||||
// 下载文件并等待完成
|
// 下载文件并等待完成
|
||||||
await this.fileManager.downloadFile(imageUrl, filePath);
|
await this.fileManager.downloadFile(imageUrl, filePath);
|
||||||
|
|
||||||
// 验证下载的文件完整性
|
// 验证下载的文件完整性,传入期望的MIME类型
|
||||||
const integrity = await this.fileManager.checkFileIntegrity(filePath);
|
const expectedMimeType = this.getMimeTypeFromUrl(imageUrl);
|
||||||
|
const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType);
|
||||||
if (!integrity.valid) {
|
if (!integrity.valid) {
|
||||||
|
// 删除损坏的文件
|
||||||
|
await this.fileManager.safeDeleteFile(filePath);
|
||||||
throw new Error(`文件下载不完整: ${integrity.reason}`);
|
throw new Error(`文件下载不完整: ${integrity.reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +120,28 @@ class DownloadExecutor {
|
|||||||
results.push({ success: true, file: fileName });
|
results.push({ success: true, file: fileName });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
task.failed_files++;
|
task.failed_files++;
|
||||||
logger.error(`下载图片失败 ${index + 1}: ${error.message}`);
|
logger.error(`下载图片失败 ${index + 1}: ${error.message}`, {
|
||||||
|
taskId: task.id,
|
||||||
|
imageUrl,
|
||||||
|
filePath,
|
||||||
|
error: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
// 尝试清理可能存在的损坏文件
|
||||||
|
try {
|
||||||
|
if (await this.fileManager.fileExists(filePath)) {
|
||||||
|
await this.fileManager.safeDeleteFile(filePath);
|
||||||
|
logger.debug('已清理损坏的文件', { filePath });
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn('清理损坏文件失败', {
|
||||||
|
filePath,
|
||||||
|
error: cleanupError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.progressManager.notifyProgressUpdate(task.id, task);
|
this.progressManager.notifyProgressUpdate(task.id, task);
|
||||||
results.push({ success: false, error: error.message });
|
results.push({ success: false, error: error.message, file: fileName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +162,50 @@ class DownloadExecutor {
|
|||||||
await this.taskManager.saveTasks();
|
await this.taskManager.saveTasks();
|
||||||
this.progressManager.notifyProgressUpdate(task.id, task);
|
this.progressManager.notifyProgressUpdate(task.id, task);
|
||||||
|
|
||||||
// 如果下载成功,更新下载注册表
|
// 只有在所有文件都成功下载且完整性检查通过时,才更新下载注册表
|
||||||
if (task.status === 'completed') {
|
if (task.status === 'completed' && task.failed_files === 0 && task.completed_files === task.total_files) {
|
||||||
|
// 再次验证所有文件的完整性
|
||||||
|
let allFilesValid = true;
|
||||||
|
const artworkFiles = await fs.readdir(artworkDir);
|
||||||
|
const imageFiles = artworkFiles.filter(file =>
|
||||||
|
file.startsWith('image_') &&
|
||||||
|
!file.endsWith('.json') &&
|
||||||
|
!file.endsWith('.txt')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const imageFile of imageFiles) {
|
||||||
|
const filePath = path.join(artworkDir, imageFile);
|
||||||
|
const expectedMimeType = this.getMimeTypeFromUrl(imageFile);
|
||||||
|
const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType);
|
||||||
|
if (!integrity.valid) {
|
||||||
|
allFilesValid = false;
|
||||||
|
logger.warn('发现不完整文件,不添加到下载注册表', {
|
||||||
|
file: imageFile,
|
||||||
|
reason: integrity.reason
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allFilesValid && imageFiles.length === task.total_files) {
|
||||||
try {
|
try {
|
||||||
await this.downloadService.downloadRegistry.addArtwork(task.artist_name, task.artwork_id);
|
await this.downloadService.downloadRegistry.addArtwork(task.artist_name, task.artwork_id);
|
||||||
logger.debug('已更新下载注册表', {
|
logger.debug('已更新下载注册表', {
|
||||||
artistName: task.artist_name,
|
artistName: task.artist_name,
|
||||||
artworkId: task.artwork_id
|
artworkId: task.artwork_id,
|
||||||
|
totalFiles: task.total_files,
|
||||||
|
completedFiles: task.completed_files
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('更新下载注册表失败:', error.message);
|
logger.warn('更新下载注册表失败:', error.message);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('文件完整性验证失败或文件数量不匹配,不添加到下载注册表', {
|
||||||
|
expectedFiles: task.total_files,
|
||||||
|
actualFiles: imageFiles.length,
|
||||||
|
allFilesValid
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加到历史记录
|
// 添加到历史记录
|
||||||
@@ -218,6 +274,16 @@ class DownloadExecutor {
|
|||||||
const batch = items.slice(i, i + batchSize);
|
const batch = items.slice(i, i + batchSize);
|
||||||
const batchPromises = batch.map(async item => {
|
const batchPromises = batch.map(async item => {
|
||||||
try {
|
try {
|
||||||
|
// 检查是否应该暂停(在每个作品下载前检查)
|
||||||
|
if (this.shouldPause(task.id)) {
|
||||||
|
logger.info('批量下载任务已暂停,停止当前作品下载:', task.id);
|
||||||
|
// 设置任务状态为暂停
|
||||||
|
task.status = 'paused';
|
||||||
|
await this.taskManager.saveTasks();
|
||||||
|
this.progressManager.notifyProgressUpdate(task.id, task);
|
||||||
|
return { artwork_id: typeof item === 'object' ? item.id : item, success: false, paused: true };
|
||||||
|
}
|
||||||
|
|
||||||
// 获取作品ID - 支持直接传入ID或作品对象
|
// 获取作品ID - 支持直接传入ID或作品对象
|
||||||
const artworkId = typeof item === 'object' ? item.id : item;
|
const artworkId = typeof item === 'object' ? item.id : item;
|
||||||
|
|
||||||
@@ -237,9 +303,30 @@ class DownloadExecutor {
|
|||||||
results.push(result);
|
results.push(result);
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
// 真正下载成功
|
// 真正下载成功,立即添加到注册表
|
||||||
task.completed_files++;
|
task.completed_files++;
|
||||||
|
|
||||||
|
// 立即添加到下载注册表
|
||||||
|
try {
|
||||||
|
await this.downloadService.downloadRegistry.addArtwork(
|
||||||
|
downloadResult.artist_name,
|
||||||
|
artworkId
|
||||||
|
);
|
||||||
|
logger.debug(`批量下载中的作品 ${artworkId} 已添加到下载注册表`, {
|
||||||
|
artworkId,
|
||||||
|
artistName: downloadResult.artist_name,
|
||||||
|
taskId: task.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`批量下载中添加作品到注册表失败: ${artworkId}`, {
|
||||||
|
artworkId,
|
||||||
|
artistName: downloadResult.artist_name,
|
||||||
|
taskId: task.id,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 添加到最近完成列表
|
// 添加到最近完成列表
|
||||||
const completedItem = {
|
const completedItem = {
|
||||||
artwork_id: artworkId,
|
artwork_id: artworkId,
|
||||||
@@ -294,6 +381,12 @@ class DownloadExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查任务是否被暂停,如果是则不要更新最终状态
|
||||||
|
if (task.status === 'paused') {
|
||||||
|
logger.info('批量下载任务已暂停,跳过最终状态更新:', task.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 更新任务状态
|
// 更新任务状态
|
||||||
task.status = task.failed_files === 0 ? 'completed' : 'partial';
|
task.status = task.failed_files === 0 ? 'completed' : 'partial';
|
||||||
task.end_time = new Date();
|
task.end_time = new Date();
|
||||||
@@ -328,6 +421,30 @@ class DownloadExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据URL获取MIME类型
|
||||||
|
* @param {string} url - 图片URL
|
||||||
|
* @returns {string} MIME类型
|
||||||
|
*/
|
||||||
|
getMimeTypeFromUrl(url) {
|
||||||
|
const ext = this.getFileExtension(url).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case 'png':
|
||||||
|
return 'image/png';
|
||||||
|
case 'gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case 'webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case 'bmp':
|
||||||
|
return 'image/bmp';
|
||||||
|
default:
|
||||||
|
return 'image/jpeg'; // 默认为JPEG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件扩展名
|
* 获取文件扩展名
|
||||||
*/
|
*/
|
||||||
@@ -423,14 +540,15 @@ class DownloadExecutor {
|
|||||||
|
|
||||||
// 检查文件是否存在且完整
|
// 检查文件是否存在且完整
|
||||||
if (await this.fileManager.fileExists(filePath)) {
|
if (await this.fileManager.fileExists(filePath)) {
|
||||||
const integrity = await this.fileManager.checkFileIntegrity(filePath);
|
const expectedMimeType = this.getMimeTypeFromUrl(imageUrl);
|
||||||
|
const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType);
|
||||||
if (integrity.valid) {
|
if (integrity.valid) {
|
||||||
completedFiles.push({ index, fileName, filePath });
|
completedFiles.push({ index, fileName, filePath });
|
||||||
} else {
|
} else {
|
||||||
incompleteFiles.push({ index, fileName, filePath });
|
incompleteFiles.push({ index, fileName, filePath, reason: integrity.reason });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
incompleteFiles.push({ index, fileName, filePath });
|
incompleteFiles.push({ index, fileName, filePath, reason: '文件不存在' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +584,28 @@ class DownloadExecutor {
|
|||||||
await this.executeArtworkDownload(task, images, 'original', artworkDir, artwork);
|
await this.executeArtworkDownload(task, images, 'original', artworkDir, artwork);
|
||||||
} else if (task.type === 'batch' || task.type === 'artist') {
|
} else if (task.type === 'batch' || task.type === 'artist') {
|
||||||
// 批量下载和作者下载的恢复逻辑
|
// 批量下载和作者下载的恢复逻辑
|
||||||
// 这里需要根据具体实现来恢复
|
|
||||||
logger.info('恢复批量下载任务:', taskId);
|
logger.info('恢复批量下载任务:', taskId);
|
||||||
// TODO: 实现批量下载的恢复逻辑
|
|
||||||
|
// 重置任务状态为下载中
|
||||||
|
task.status = 'downloading';
|
||||||
|
task.isResuming = true;
|
||||||
|
await this.taskManager.saveTasks();
|
||||||
|
this.progressManager.notifyProgressUpdate(task.id, task);
|
||||||
|
|
||||||
|
// 获取原始的作品列表
|
||||||
|
const items = task.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
logger.error('批量下载任务没有作品列表,无法恢复', { taskId });
|
||||||
|
throw new Error('批量下载任务没有作品列表,无法恢复');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新开始批量下载
|
||||||
|
await this.executeBatchDownload(task, items, {
|
||||||
|
size: task.size || 'original',
|
||||||
|
quality: task.quality || 'high',
|
||||||
|
format: task.format || 'auto',
|
||||||
|
concurrent: task.concurrent || 3
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('任务恢复执行完成', { taskId });
|
logger.info('任务恢复执行完成', { taskId });
|
||||||
|
|||||||
@@ -233,13 +233,42 @@ class DownloadService {
|
|||||||
return { success: false, error: '任务不存在' };
|
return { success: false, error: '任务不存在' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('开始取消任务', { taskId, status: task.status, type: task.type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新任务状态为取消中,防止并发操作
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'cancelling' });
|
||||||
|
|
||||||
|
// 清理未完成的文件
|
||||||
|
await this.cleanupIncompleteFiles(task);
|
||||||
|
|
||||||
|
// 最终更新任务状态
|
||||||
await this.taskManager.updateTask(taskId, {
|
await this.taskManager.updateTask(taskId, {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
end_time: new Date(),
|
end_time: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.progressManager.notifyProgressUpdate(taskId, task);
|
// 获取更新后的任务并通知
|
||||||
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
logger.info('任务取消完成', { taskId });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('取消任务失败', { taskId, error: error.message });
|
||||||
|
|
||||||
|
// 如果清理失败,仍然标记为取消,但记录错误
|
||||||
|
await this.taskManager.updateTask(taskId, {
|
||||||
|
status: 'cancelled',
|
||||||
|
end_time: new Date(),
|
||||||
|
error: `取消时清理失败: ${error.message}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
return { success: true, warning: `任务已取消,但清理时出现问题: ${error.message}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pauseTask(taskId) {
|
async pauseTask(taskId) {
|
||||||
@@ -253,13 +282,38 @@ class DownloadService {
|
|||||||
return { success: false, error: '只能暂停正在下载的任务' };
|
return { success: false, error: '只能暂停正在下载的任务' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('开始暂停任务', { taskId, status: task.status, type: task.type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新任务状态为暂停中,防止并发操作
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'pausing' });
|
||||||
|
|
||||||
|
// 清理未完成的文件
|
||||||
|
await this.cleanupIncompleteFiles(task);
|
||||||
|
|
||||||
|
// 最终更新任务状态为暂停
|
||||||
await this.taskManager.updateTask(taskId, { status: 'paused' });
|
await this.taskManager.updateTask(taskId, { status: 'paused' });
|
||||||
|
|
||||||
// 获取更新后的任务
|
// 获取更新后的任务
|
||||||
const updatedTask = this.taskManager.getTask(taskId);
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
logger.info('任务暂停完成', { taskId });
|
||||||
return { success: true, data: updatedTask };
|
return { success: true, data: updatedTask };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('暂停任务失败', { taskId, error: error.message });
|
||||||
|
|
||||||
|
// 如果清理失败,仍然标记为暂停,但记录错误
|
||||||
|
await this.taskManager.updateTask(taskId, {
|
||||||
|
status: 'paused',
|
||||||
|
error: `暂停时清理失败: ${error.message}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
return { success: true, data: updatedTask, warning: `任务已暂停,但清理时出现问题: ${error.message}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeTask(taskId) {
|
async resumeTask(taskId) {
|
||||||
@@ -311,6 +365,288 @@ class DownloadService {
|
|||||||
return { success: true, data: this.taskManager.getTask(taskId) };
|
return { success: true, data: this.taskManager.getTask(taskId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停批量下载任务
|
||||||
|
*/
|
||||||
|
async pauseBatchTask(taskId) {
|
||||||
|
const task = this.taskManager.getTask(taskId);
|
||||||
|
if (!task) {
|
||||||
|
return { success: false, error: '任务不存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为批量下载任务
|
||||||
|
if (!['batch', 'artist', 'art'].includes(task.type)) {
|
||||||
|
return { success: false, error: '此方法仅适用于批量下载任务' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许暂停正在下载的任务
|
||||||
|
if (task.status !== 'downloading') {
|
||||||
|
return { success: false, error: '只能暂停正在下载的任务' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('开始暂停批量下载任务', { taskId, status: task.status, type: task.type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 直接设置任务状态为暂停,不进行文件清理
|
||||||
|
// 批量下载中的每个文件都是独立完成的,不需要清理
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'paused' });
|
||||||
|
|
||||||
|
// 获取更新后的任务
|
||||||
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
logger.info('批量下载任务暂停完成', { taskId });
|
||||||
|
return { success: true, data: updatedTask };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('暂停批量下载任务失败', { taskId, error: error.message });
|
||||||
|
return { success: false, error: `暂停任务失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复批量下载任务
|
||||||
|
*/
|
||||||
|
async resumeBatchTask(taskId) {
|
||||||
|
const task = this.taskManager.getTask(taskId);
|
||||||
|
if (!task) {
|
||||||
|
logger.error('恢复批量下载任务失败:任务不存在', { taskId });
|
||||||
|
return { success: false, error: '任务不存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为批量下载任务
|
||||||
|
if (!['batch', 'artist', 'art'].includes(task.type)) {
|
||||||
|
return { success: false, error: '此方法仅适用于批量下载任务' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许恢复暂停的任务
|
||||||
|
if (task.status !== 'paused') {
|
||||||
|
logger.warn('恢复批量下载任务失败:任务状态不是暂停状态', {
|
||||||
|
taskId,
|
||||||
|
currentStatus: task.status
|
||||||
|
});
|
||||||
|
return { success: false, error: '只能恢复暂停的任务' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新开始批量下载执行
|
||||||
|
try {
|
||||||
|
logger.info('开始恢复批量下载任务执行', { taskId });
|
||||||
|
|
||||||
|
// 直接设置任务状态为下载中
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'downloading' });
|
||||||
|
|
||||||
|
// 获取原始的作品列表
|
||||||
|
const items = task.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
logger.error('批量下载任务没有作品列表,无法恢复', { taskId });
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'paused' });
|
||||||
|
return { success: false, error: '批量下载任务没有作品列表,无法恢复' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知状态更新
|
||||||
|
const updatedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, updatedTask);
|
||||||
|
|
||||||
|
// 异步重新开始批量下载,不等待完成
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
await this.downloadExecutor.executeBatchDownload(task, items, {
|
||||||
|
size: task.size || 'original',
|
||||||
|
quality: task.quality || 'high',
|
||||||
|
format: task.format || 'auto',
|
||||||
|
concurrent: task.concurrent || 3
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('批量下载任务恢复执行失败', {
|
||||||
|
taskId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
// 如果执行失败,设置任务状态为失败
|
||||||
|
await this.taskManager.updateTask(taskId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
const failedTask = this.taskManager.getTask(taskId);
|
||||||
|
this.progressManager.notifyProgressUpdate(taskId, failedTask);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('批量下载任务恢复成功', {
|
||||||
|
taskId,
|
||||||
|
newStatus: updatedTask.status
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updatedTask };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('恢复批量下载任务失败', {
|
||||||
|
taskId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
// 如果恢复失败,保持暂停状态
|
||||||
|
await this.taskManager.updateTask(taskId, { status: 'paused' });
|
||||||
|
return { success: false, error: `恢复任务失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理未完成的文件
|
||||||
|
* @param {Object} task - 任务对象
|
||||||
|
*/
|
||||||
|
async cleanupIncompleteFiles(task) {
|
||||||
|
if (!task) {
|
||||||
|
logger.warn('清理未完成文件:任务对象为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('开始清理未完成文件', { taskId: task.id, type: task.type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (task.type === 'artwork') {
|
||||||
|
await this.cleanupArtworkIncompleteFiles(task);
|
||||||
|
} else if (task.type === 'batch' || task.type === 'artist') {
|
||||||
|
// 批量下载任务通常不需要清理单个文件,因为每个作品都是独立处理的
|
||||||
|
logger.info('批量任务无需清理单个文件', { taskId: task.id, type: task.type });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清理未完成文件失败', { taskId: task.id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理单个作品任务的未完成文件
|
||||||
|
* @param {Object} task - 作品下载任务
|
||||||
|
*/
|
||||||
|
async cleanupArtworkIncompleteFiles(task) {
|
||||||
|
if (!task.artwork_id || !task.artist_name || !task.artwork_title) {
|
||||||
|
logger.warn('作品任务信息不完整,跳过文件清理', {
|
||||||
|
taskId: task.id,
|
||||||
|
artwork_id: task.artwork_id,
|
||||||
|
artist_name: task.artist_name,
|
||||||
|
artwork_title: task.artwork_title
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建作品目录路径
|
||||||
|
const downloadPath = await this.fileManager.getDownloadPath();
|
||||||
|
const artistName = this.fileManager.createSafeDirectoryName(task.artist_name);
|
||||||
|
const artworkTitle = this.fileManager.createSafeDirectoryName(task.artwork_title);
|
||||||
|
const artistDir = path.join(downloadPath, artistName);
|
||||||
|
const artworkDirName = `${task.artwork_id}_${artworkTitle}`;
|
||||||
|
const artworkDir = path.join(artistDir, artworkDirName);
|
||||||
|
|
||||||
|
// 检查作品目录是否存在
|
||||||
|
if (!(await this.fileManager.directoryExists(artworkDir))) {
|
||||||
|
logger.debug('作品目录不存在,无需清理', { taskId: task.id, artworkDir });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取目录中的所有文件
|
||||||
|
const files = await this.fileManager.listDirectory(artworkDir);
|
||||||
|
const imageFiles = files.filter(file =>
|
||||||
|
/\.(jpg|jpeg|png|gif|webp)$/i.test(file) && file !== 'artwork_info.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
let cleanedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// 检查并删除未完成的图片文件
|
||||||
|
for (const fileName of imageFiles) {
|
||||||
|
const filePath = path.join(artworkDir, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查文件完整性,根据文件扩展名推断MIME类型
|
||||||
|
const expectedMimeType = this.getMimeTypeFromExtension(fileName);
|
||||||
|
const integrity = await this.fileManager.checkFileIntegrity(filePath, null, expectedMimeType);
|
||||||
|
|
||||||
|
if (!integrity.valid) {
|
||||||
|
// 文件不完整,删除它
|
||||||
|
const deleted = await this.fileManager.safeDeleteFile(filePath);
|
||||||
|
if (deleted) {
|
||||||
|
cleanedCount++;
|
||||||
|
logger.debug('删除未完成文件', {
|
||||||
|
taskId: task.id,
|
||||||
|
fileName,
|
||||||
|
reason: integrity.reason
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
logger.warn('删除未完成文件失败', {
|
||||||
|
taskId: task.id,
|
||||||
|
fileName,
|
||||||
|
reason: '文件可能被占用'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
logger.warn('检查文件完整性失败', {
|
||||||
|
taskId: task.id,
|
||||||
|
fileName,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果目录中只剩下artwork_info.json或为空,删除整个目录
|
||||||
|
const remainingFiles = await this.fileManager.listDirectory(artworkDir);
|
||||||
|
const remainingImageFiles = remainingFiles.filter(file =>
|
||||||
|
/\.(jpg|jpeg|png|gif|webp)$/i.test(file)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingImageFiles.length === 0) {
|
||||||
|
try {
|
||||||
|
await this.fileManager.removeDirectory(artworkDir);
|
||||||
|
logger.info('删除空的作品目录', { taskId: task.id, artworkDir });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('删除空目录失败', {
|
||||||
|
taskId: task.id,
|
||||||
|
artworkDir,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('文件清理完成', {
|
||||||
|
taskId: task.id,
|
||||||
|
cleanedCount,
|
||||||
|
errorCount,
|
||||||
|
totalImageFiles: imageFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清理作品文件失败', { taskId: task.id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件扩展名获取MIME类型
|
||||||
|
* @param {string} fileName - 文件名
|
||||||
|
* @returns {string} MIME类型
|
||||||
|
*/
|
||||||
|
getMimeTypeFromExtension(fileName) {
|
||||||
|
const ext = path.extname(fileName).toLowerCase().replace('.', '');
|
||||||
|
switch (ext) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case 'png':
|
||||||
|
return 'image/png';
|
||||||
|
case 'gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case 'webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case 'bmp':
|
||||||
|
return 'image/bmp';
|
||||||
|
default:
|
||||||
|
return 'image/jpeg'; // 默认为JPEG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 代理方法 - 历史记录管理
|
// 代理方法 - 历史记录管理
|
||||||
async getDownloadHistory(offset = 0, limit = 50) {
|
async getDownloadHistory(offset = 0, limit = 50) {
|
||||||
const result = this.historyManager.getDownloadHistory(offset, limit);
|
const result = this.historyManager.getDownloadHistory(offset, limit);
|
||||||
@@ -651,7 +987,12 @@ class DownloadService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('下载作品失败:', error);
|
logger.error('下载作品失败:', {
|
||||||
|
artworkId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
options
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -687,9 +1028,27 @@ class DownloadService {
|
|||||||
try {
|
try {
|
||||||
// 检查是否已下载
|
// 检查是否已下载
|
||||||
if (skipExisting && (await this.isArtworkDownloaded(artworkId))) {
|
if (skipExisting && (await this.isArtworkDownloaded(artworkId))) {
|
||||||
|
// 获取作品信息用于返回
|
||||||
|
let artistName = 'Unknown Artist';
|
||||||
|
let artworkTitle = 'Untitled';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artworkResult = await this.artworkService.getArtworkDetail(artworkId);
|
||||||
|
if (artworkResult.success && artworkResult.data) {
|
||||||
|
const artwork = artworkResult.data;
|
||||||
|
artistName = this.fileManager.createSafeDirectoryName(artwork.user?.name || 'Unknown Artist');
|
||||||
|
artworkTitle = this.fileManager.createSafeDirectoryName(artwork.title || 'Untitled');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`获取已下载作品信息失败: ${artworkId}`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
|
artwork_id: artworkId,
|
||||||
|
artist_name: artistName,
|
||||||
|
artwork_title: artworkTitle,
|
||||||
message: '作品已存在且完整,跳过下载',
|
message: '作品已存在且完整,跳过下载',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -779,7 +1138,14 @@ class DownloadService {
|
|||||||
await this.fileManager.downloadFile(imageUrl, filePath);
|
await this.fileManager.downloadFile(imageUrl, filePath);
|
||||||
results.push({ success: true, file: fileName });
|
results.push({ success: true, file: fileName });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`下载图片失败 ${index + 1}: ${error.message}`);
|
logger.error(`下载图片失败 ${index + 1}:`, {
|
||||||
|
artworkId,
|
||||||
|
imageIndex: index + 1,
|
||||||
|
imageUrl,
|
||||||
|
filePath,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
results.push({ success: false, error: error.message });
|
results.push({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,9 +1157,73 @@ class DownloadService {
|
|||||||
// 检查下载结果
|
// 检查下载结果
|
||||||
const failedCount = results.filter(r => !r.success).length;
|
const failedCount = results.filter(r => !r.success).length;
|
||||||
const successCount = results.filter(r => r.success && !r.skipped).length;
|
const successCount = results.filter(r => r.success && !r.skipped).length;
|
||||||
|
const skippedCount = results.filter(r => r.success && r.skipped).length;
|
||||||
|
|
||||||
|
// 只有在所有文件都成功下载(包括跳过的文件)时才添加到注册表
|
||||||
|
const allFilesSuccessful = failedCount === 0;
|
||||||
|
|
||||||
|
if (allFilesSuccessful) {
|
||||||
|
try {
|
||||||
|
// 执行文件完整性检查
|
||||||
|
let integrityCheckPassed = true;
|
||||||
|
for (let index = 0; index < images.length; index++) {
|
||||||
|
const fileName = `image_${index + 1}.${this.getFileExtension(images[index].original || images[index].large || images[index].medium)}`;
|
||||||
|
const filePath = path.join(artworkDir, fileName);
|
||||||
|
|
||||||
|
if (await this.fileManager.fileExists(filePath)) {
|
||||||
|
// 检查文件大小
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
logger.warn(`文件大小为0,完整性检查失败: ${filePath}`);
|
||||||
|
integrityCheckPassed = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查MIME类型 - 使用checkFileHeader方法来检测文件类型
|
||||||
|
const headerCheck = await this.fileManager.checkFileHeader(filePath);
|
||||||
|
if (!headerCheck.valid || !headerCheck.detectedType || !headerCheck.detectedType.startsWith('image/')) {
|
||||||
|
logger.warn(`文件MIME类型检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`);
|
||||||
|
integrityCheckPassed = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (integrityCheckPassed) {
|
||||||
|
// 添加到下载注册表(仅用于单个作品下载,批量下载在executeBatchDownload中处理)
|
||||||
|
await this.downloadRegistry.addArtwork(artistName, artworkId);
|
||||||
|
logger.debug(`作品 ${artworkId} 已添加到下载注册表`, {
|
||||||
|
artworkId,
|
||||||
|
artistName,
|
||||||
|
totalFiles: images.length,
|
||||||
|
completedFiles: successCount,
|
||||||
|
skippedFiles: skippedCount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`作品 ${artworkId} 文件完整性检查失败,未添加到下载注册表`, {
|
||||||
|
artworkId,
|
||||||
|
artistName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`添加作品到下载注册表失败: ${artworkId}`, {
|
||||||
|
artworkId,
|
||||||
|
artistName,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(`作品 ${artworkId} 下载不完整,未添加到下载注册表`, {
|
||||||
|
artworkId,
|
||||||
|
artistName,
|
||||||
|
failedCount,
|
||||||
|
totalFiles: images.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: failedCount === 0,
|
success: allFilesSuccessful,
|
||||||
artwork_id: artworkId,
|
artwork_id: artworkId,
|
||||||
artist_name: artistName,
|
artist_name: artistName,
|
||||||
artwork_title: artworkTitle,
|
artwork_title: artworkTitle,
|
||||||
@@ -803,7 +1233,12 @@ class DownloadService {
|
|||||||
results: results,
|
results: results,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`下载作品 ${artworkId} 失败:`, error);
|
logger.error(`下载作品 ${artworkId} 失败:`, {
|
||||||
|
artworkId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
options
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -872,6 +1307,7 @@ class DownloadService {
|
|||||||
results: [],
|
results: [],
|
||||||
task_description: taskDescription,
|
task_description: taskDescription,
|
||||||
task_title: taskTitle,
|
task_title: taskTitle,
|
||||||
|
items: items, // 保存原始的作品列表,用于恢复任务
|
||||||
// 保留原有的任务特定字段
|
// 保留原有的任务特定字段
|
||||||
...(options.artist_id && { artist_id: options.artist_id }),
|
...(options.artist_id && { artist_id: options.artist_id }),
|
||||||
...(options.artist_name && { artist_name: options.artist_name }),
|
...(options.artist_name && { artist_name: options.artist_name }),
|
||||||
|
|||||||
@@ -111,7 +111,14 @@ class FileManager {
|
|||||||
/**
|
/**
|
||||||
* 检查文件完整性
|
* 检查文件完整性
|
||||||
*/
|
*/
|
||||||
async checkFileIntegrity(filePath, expectedSize = null) {
|
/**
|
||||||
|
* 检查文件完整性
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @param {number} expectedSize - 期望的文件大小
|
||||||
|
* @param {string} expectedMimeType - 期望的MIME类型
|
||||||
|
* @returns {Object} 检查结果
|
||||||
|
*/
|
||||||
|
async checkFileIntegrity(filePath, expectedSize = null, expectedMimeType = null) {
|
||||||
try {
|
try {
|
||||||
if (!await fs.pathExists(filePath)) {
|
if (!await fs.pathExists(filePath)) {
|
||||||
return { valid: false, reason: '文件不存在' };
|
return { valid: false, reason: '文件不存在' };
|
||||||
@@ -130,8 +137,15 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件是否过小(可能下载不完整)
|
// 检查文件是否过小(可能下载不完整)
|
||||||
if (stats.size < 512) { // 小于512字节的文件可能是损坏的
|
const minSize = this.getMinimumFileSize(filePath, expectedMimeType);
|
||||||
return { valid: false, reason: '文件过小,可能下载不完整', size: stats.size };
|
if (stats.size < minSize) {
|
||||||
|
return { valid: false, reason: '文件过小,可能下载不完整', size: stats.size, minSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件头部是否符合预期格式
|
||||||
|
const headerCheck = await this.checkFileHeader(filePath, expectedMimeType);
|
||||||
|
if (!headerCheck.valid) {
|
||||||
|
return headerCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件存在且大小正常,认为有效
|
// 文件存在且大小正常,认为有效
|
||||||
@@ -141,6 +155,110 @@ class FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的最小合理大小
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @param {string} expectedMimeType - 期望的MIME类型
|
||||||
|
* @returns {number} 最小文件大小
|
||||||
|
*/
|
||||||
|
getMinimumFileSize(filePath, expectedMimeType) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
// 根据文件类型设置最小大小
|
||||||
|
if (expectedMimeType) {
|
||||||
|
if (expectedMimeType.startsWith('image/')) {
|
||||||
|
return 1024; // 图片文件至少1KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据扩展名判断
|
||||||
|
switch (ext) {
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 1024; // JPEG文件至少1KB
|
||||||
|
case '.png':
|
||||||
|
return 512; // PNG文件至少512字节
|
||||||
|
case '.gif':
|
||||||
|
return 256; // GIF文件至少256字节
|
||||||
|
case '.webp':
|
||||||
|
return 512; // WebP文件至少512字节
|
||||||
|
case '.bmp':
|
||||||
|
return 1024; // BMP文件至少1KB
|
||||||
|
default:
|
||||||
|
return 256; // 其他文件至少256字节
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件头部格式
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @param {string} expectedMimeType - 期望的MIME类型
|
||||||
|
* @returns {Object} 检查结果
|
||||||
|
*/
|
||||||
|
async checkFileHeader(filePath, expectedMimeType) {
|
||||||
|
try {
|
||||||
|
// 读取文件前几个字节来检查文件头
|
||||||
|
const buffer = Buffer.alloc(16);
|
||||||
|
const fd = await fs.open(filePath, 'r');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { bytesRead } = await fs.read(fd, buffer, 0, 16, 0);
|
||||||
|
|
||||||
|
if (bytesRead < 4) {
|
||||||
|
return { valid: false, reason: '文件头部数据不足' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查常见图片格式的文件头
|
||||||
|
const header = buffer.toString('hex', 0, Math.min(bytesRead, 8));
|
||||||
|
|
||||||
|
// JPEG文件头: FFD8FF
|
||||||
|
if (header.startsWith('ffd8ff')) {
|
||||||
|
if (expectedMimeType && !expectedMimeType.includes('jpeg') && !expectedMimeType.includes('jpg')) {
|
||||||
|
return { valid: false, reason: '文件格式不匹配:检测到JPEG但期望其他格式' };
|
||||||
|
}
|
||||||
|
return { valid: true, detectedType: 'image/jpeg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG文件头: 89504E47
|
||||||
|
if (header.startsWith('89504e47')) {
|
||||||
|
if (expectedMimeType && !expectedMimeType.includes('png')) {
|
||||||
|
return { valid: false, reason: '文件格式不匹配:检测到PNG但期望其他格式' };
|
||||||
|
}
|
||||||
|
return { valid: true, detectedType: 'image/png' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF文件头: 474946
|
||||||
|
if (header.startsWith('474946')) {
|
||||||
|
if (expectedMimeType && !expectedMimeType.includes('gif')) {
|
||||||
|
return { valid: false, reason: '文件格式不匹配:检测到GIF但期望其他格式' };
|
||||||
|
}
|
||||||
|
return { valid: true, detectedType: 'image/gif' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP文件头: 52494646...57454250
|
||||||
|
if (header.startsWith('52494646') && buffer.toString('hex', 8, 12) === '57454250') {
|
||||||
|
if (expectedMimeType && !expectedMimeType.includes('webp')) {
|
||||||
|
return { valid: false, reason: '文件格式不匹配:检测到WebP但期望其他格式' };
|
||||||
|
}
|
||||||
|
return { valid: true, detectedType: 'image/webp' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有明确的期望类型,且检测到了有效的图片头部,则认为有效
|
||||||
|
if (!expectedMimeType) {
|
||||||
|
return { valid: true, detectedType: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有期望类型但未匹配到已知格式,可能是损坏的文件
|
||||||
|
return { valid: false, reason: '无法识别的文件格式或文件头部损坏' };
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await fs.close(fd);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, reason: '检查文件头部失败', error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简单的文件下载方法
|
* 简单的文件下载方法
|
||||||
*/
|
*/
|
||||||
@@ -217,11 +335,24 @@ class FileManager {
|
|||||||
if (isResolved) return;
|
if (isResolved) return;
|
||||||
isResolved = true;
|
isResolved = true;
|
||||||
|
|
||||||
|
logger.error(`文件写入流错误: ${filePath}`, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
attempt,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
|
||||||
// 下载失败时删除文件
|
// 下载失败时删除文件
|
||||||
try {
|
try {
|
||||||
|
if (await fs.pathExists(filePath)) {
|
||||||
await this.safeDeleteFile(filePath);
|
await this.safeDeleteFile(filePath);
|
||||||
|
logger.debug('已清理失败的下载文件', { filePath });
|
||||||
|
}
|
||||||
} catch (removeError) {
|
} catch (removeError) {
|
||||||
logger.warn('清理失败文件时出错:', removeError.message);
|
logger.warn('清理失败文件时出错:', {
|
||||||
|
filePath,
|
||||||
|
error: removeError.message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -233,6 +364,12 @@ class FileManager {
|
|||||||
if (isResolved) return;
|
if (isResolved) return;
|
||||||
isResolved = true;
|
isResolved = true;
|
||||||
|
|
||||||
|
logger.error(`下载超时: ${filePath}`, {
|
||||||
|
url,
|
||||||
|
timeout: downloadConfig.timeout + 60000,
|
||||||
|
attempt
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutError = new Error('下载超时');
|
const timeoutError = new Error('下载超时');
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(timeoutError);
|
reject(timeoutError);
|
||||||
@@ -257,7 +394,13 @@ class FileManager {
|
|||||||
// 处理文件系统错误
|
// 处理文件系统错误
|
||||||
const errorResult = ErrorHandler.handleFileSystemError(error, filePath, 'download');
|
const errorResult = ErrorHandler.handleFileSystemError(error, filePath, 'download');
|
||||||
|
|
||||||
logger.error(`下载文件失败 (尝试 ${attempt}/${maxRetries}): ${filePath}`, error.message);
|
logger.error(`下载文件失败 (尝试 ${attempt}/${maxRetries}): ${filePath}`, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
url,
|
||||||
|
retryable: errorResult.retryable,
|
||||||
|
attempt
|
||||||
|
});
|
||||||
|
|
||||||
// 如果不是可重试的错误,直接抛出
|
// 如果不是可重试的错误,直接抛出
|
||||||
if (!errorResult.retryable) {
|
if (!errorResult.retryable) {
|
||||||
@@ -266,7 +409,12 @@ class FileManager {
|
|||||||
|
|
||||||
// 如果是最后一次尝试,抛出错误
|
// 如果是最后一次尝试,抛出错误
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
logger.error(`下载文件最终失败: ${filePath}`, error.message);
|
logger.error(`下载文件最终失败: ${filePath}`, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
url,
|
||||||
|
totalAttempts: maxRetries
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,9 +565,14 @@ class FileManager {
|
|||||||
try {
|
try {
|
||||||
if (await fs.pathExists(filePath)) {
|
if (await fs.pathExists(filePath)) {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
|
logger.debug('文件删除成功', { filePath });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`文件删除失败: ${filePath}`, error.message);
|
logger.error(`文件删除失败: ${filePath}`, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code
|
||||||
|
});
|
||||||
// 不抛出错误,避免影响其他操作
|
// 不抛出错误,避免影响其他操作
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,19 @@ class ErrorHandler {
|
|||||||
isPkg: process.pkg !== undefined,
|
isPkg: process.pkg !== undefined,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
errorCode: error.code,
|
errorCode: error.code,
|
||||||
errorMessage: error.message
|
errorMessage: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 记录错误信息
|
// 记录详细错误信息
|
||||||
logger.error(`文件系统错误 [${operation}]:`, {
|
logger.error(`文件系统错误 [${operation}]:`, {
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
errorCode: error.code,
|
errorCode: error.code,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
|
stack: error.stack,
|
||||||
isPkg: errorInfo.isPkg,
|
isPkg: errorInfo.isPkg,
|
||||||
platform: errorInfo.platform
|
platform: errorInfo.platform,
|
||||||
|
timestamp: errorInfo.timestamp
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据错误类型提供解决方案
|
// 根据错误类型提供解决方案
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ REM INFO: 显示一般信息及以上级别信息 (默认)
|
|||||||
REM DEBUG: 显示调试信息及以上级别信息
|
REM DEBUG: 显示调试信息及以上级别信息
|
||||||
REM TRACE: 显示所有级别信息 (最详细)
|
REM TRACE: 显示所有级别信息 (最详细)
|
||||||
REM ========================================
|
REM ========================================
|
||||||
set LOG_LEVEL=
|
set LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Pixiv Manager 启动中...
|
echo Pixiv Manager 启动中...
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ class DownloadService {
|
|||||||
return apiService.post(`/api/download/resume/${taskId}`);
|
return apiService.post(`/api/download/resume/${taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停批量下载任务
|
||||||
|
*/
|
||||||
|
async pauseBatchTask(taskId: string) {
|
||||||
|
return apiService.post(`/api/download/batch/pause/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复批量下载任务
|
||||||
|
*/
|
||||||
|
async resumeBatchTask(taskId: string) {
|
||||||
|
return apiService.post(`/api/download/batch/resume/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消任务
|
* 取消任务
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -303,7 +303,19 @@ export const useDownloadStore = defineStore('download', () => {
|
|||||||
// 恢复任务
|
// 恢复任务
|
||||||
const resumeTask = async (taskId: string) => {
|
const resumeTask = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await downloadService.resumeTask(taskId);
|
// 获取任务信息以确定类型
|
||||||
|
const task = getTask(taskId);
|
||||||
|
let response;
|
||||||
|
|
||||||
|
// 判断是否为批量下载任务(batch、artist、art类型都是批量下载)
|
||||||
|
if (task && ['batch', 'artist', 'art'].includes(task.type)) {
|
||||||
|
// 使用批量下载专用API
|
||||||
|
response = await downloadService.resumeBatchTask(taskId);
|
||||||
|
} else {
|
||||||
|
// 使用单个下载API
|
||||||
|
response = await downloadService.resumeTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
// 重新管理SSE连接
|
// 重新管理SSE连接
|
||||||
@@ -321,7 +333,19 @@ export const useDownloadStore = defineStore('download', () => {
|
|||||||
// 暂停任务
|
// 暂停任务
|
||||||
const pauseTask = async (taskId: string) => {
|
const pauseTask = async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await downloadService.pauseTask(taskId);
|
// 获取任务信息以确定类型
|
||||||
|
const task = getTask(taskId);
|
||||||
|
let response;
|
||||||
|
|
||||||
|
// 判断是否为批量下载任务(batch、artist、art类型都是批量下载)
|
||||||
|
if (task && ['batch', 'artist', 'art'].includes(task.type)) {
|
||||||
|
// 使用批量下载专用API
|
||||||
|
response = await downloadService.pauseBatchTask(taskId);
|
||||||
|
} else {
|
||||||
|
// 使用单个下载API
|
||||||
|
response = await downloadService.pauseTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
|
<button v-if="task.status === 'downloading'" @click="pauseTask(task.id)" class="btn btn-warning btn-sm">
|
||||||
|
暂停
|
||||||
|
</button>
|
||||||
<button v-if="task.status === 'downloading'" @click="cancelTask(task.id)" class="btn btn-danger btn-sm">
|
<button v-if="task.status === 'downloading'" @click="cancelTask(task.id)" class="btn btn-danger btn-sm">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -370,6 +373,16 @@ const resumeTask = async (taskId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 暂停任务
|
||||||
|
const pauseTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
await downloadStore.pauseTask(taskId);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : '暂停任务失败';
|
||||||
|
console.error('暂停任务失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 清理历史记录
|
// 清理历史记录
|
||||||
const cleanupHistory = async () => {
|
const cleanupHistory = async () => {
|
||||||
if (confirm('确定要清理下载历史吗?这将保留最新的500条记录。')) {
|
if (confirm('确定要清理下载历史吗?这将保留最新的500条记录。')) {
|
||||||
@@ -783,7 +796,7 @@ onUnmounted(() => {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.success + .stat-label {
|
.stat-value.success+.stat-label {
|
||||||
color: #047857;
|
color: #047857;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +812,7 @@ onUnmounted(() => {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.error + .stat-label {
|
.stat-value.error+.stat-label {
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,6 +918,19 @@ onUnmounted(() => {
|
|||||||
box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.3);
|
box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover:not(:disabled) {
|
||||||
|
background: #d97706;
|
||||||
|
border-color: #d97706;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user