diff --git a/README.md b/README.md index a79fad7..6e12249 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,15 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功 ### 便携版下载(如果不想自义定或者是懒) 如果懒得配置环境,可以直接下载便携版(日,我自己用怎么还被当成木马了,算了忽略一下,不放心就自己打包): -- **下载链接**: https://pan.baidu.com/s/1pIdl8eqQSA8jc2RM7HoZfg?pwd=j18v -- **提取码**: j18v -- **使用说明**: 下载后解压,记事本打开start.bat配置代理(看readme有介绍),双击 `start.bat` 即可启动,打开网站,按照教程登录即可 + +**方式一:百度网盘下载(更新不勤,版本可能比较落后)** +- **下载链接**: https://pan.baidu.com/s/1SNsiDRzrNoHp4BhUBNvr9w?pwd=2yyn 提取码: 2yyn +- **提取码**: 2yyn + +**方式二:直接下载(可能比较慢,服务器带宽有限辣)** +- **下载链接**: https://sywb.top/Staticfiles/p%E4%B8%8B%E8%BD%BD%E5%99%A8.rar + +**使用说明**: 下载后解压,记事本打开start.bat配置代理(看readme有介绍),双击 `start.bat` 即可启动,打开网站,按照教程登录即可 ### 环境要求 diff --git a/backend/routes/download.js b/backend/routes/download.js index cf8b0ad..6f705fd 100644 --- a/backend/routes/download.js +++ b/backend/routes/download.js @@ -8,13 +8,17 @@ const DownloadService = require('../services/download'); */ router.post('/artwork/:id', async (req, res) => { try { + console.log(`收到下载请求: 作品ID ${req.params.id}`); const { id } = req.params; const { size = 'original', quality = 'high', - format = 'auto' + format = 'auto', + skipExisting = true } = req.body; + console.log(`下载参数: size=${size}, quality=${quality}, format=${format}, skipExisting=${skipExisting}`); + if (!id || isNaN(parseInt(id))) { return res.status(400).json({ success: false, @@ -23,12 +27,16 @@ router.post('/artwork/:id', async (req, res) => { } const downloadService = req.backend.getDownloadService(); + console.log('开始调用下载服务...'); const result = await downloadService.downloadArtwork(parseInt(id), { size, quality, - format + format, + skipExisting }); + console.log('下载服务返回结果:', result); + if (result.success) { res.json({ success: true, @@ -41,6 +49,7 @@ router.post('/artwork/:id', async (req, res) => { }); } } catch (error) { + console.error('下载路由错误:', error); res.status(500).json({ success: false, error: error.message @@ -161,20 +170,27 @@ router.get('/progress/:taskId', async (req, res) => { try { const { taskId } = req.params; - const downloadService = req.backend.getDownloadService(); - const progress = downloadService.getTaskProgress(taskId); - - if (!progress) { - return res.status(404).json({ + if (!taskId) { + return res.status(400).json({ success: false, - error: 'Task not found' + error: 'Task ID is required' }); } - res.json({ - success: true, - data: progress - }); + const downloadService = req.backend.getDownloadService(); + const result = await downloadService.getTaskProgress(taskId); + + if (result.success) { + res.json({ + success: true, + data: result.data + }); + } else { + res.status(404).json({ + success: false, + error: result.error + }); + } } catch (error) { res.status(500).json({ success: false, @@ -190,11 +206,11 @@ router.get('/progress/:taskId', async (req, res) => { router.get('/tasks', async (req, res) => { try { const downloadService = req.backend.getDownloadService(); - const tasks = downloadService.getAllTasks(); + const result = await downloadService.getAllTasks(); res.json({ success: true, - data: tasks + data: result.data }); } catch (error) { res.status(500).json({ @@ -205,20 +221,102 @@ router.get('/tasks', async (req, res) => { }); /** - * 取消下载任务 - * POST /api/download/cancel/:taskId + * 暂停任务 + * POST /api/download/pause/:taskId */ -router.post('/cancel/:taskId', async (req, res) => { +router.post('/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.pauseTask(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/resume/:taskId + */ +router.post('/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.resumeTask(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 + */ +router.delete('/cancel/: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.cancelTask(taskId); if (result.success) { res.json({ success: true, - message: 'Task cancelled successfully' + message: '任务已取消' }); } else { res.status(400).json({ @@ -240,14 +338,14 @@ router.post('/cancel/:taskId', async (req, res) => { */ router.get('/history', async (req, res) => { try { - const { limit = 50, offset = 0 } = req.query; + const { offset = 0, limit = 50 } = req.query; const downloadService = req.backend.getDownloadService(); - const history = downloadService.getDownloadHistory(parseInt(limit), parseInt(offset)); + const result = await downloadService.getDownloadHistory(parseInt(offset), parseInt(limit)); res.json({ success: true, - data: history + data: result.data }); } catch (error) { res.status(500).json({ @@ -369,4 +467,67 @@ router.delete('/files', async (req, res) => { } }); +/** + * SSE端点 - 实时推送下载进度 + * GET /api/download/stream/:taskId + */ +router.get('/stream/:taskId', async (req, res) => { + const { taskId } = req.params; + + if (!taskId) { + return res.status(400).json({ + success: false, + error: 'Task ID is required' + }); + } + + // 设置SSE头部 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + }); + + const downloadService = req.backend.getDownloadService(); + + // 创建进度监听器 + const progressListener = (task) => { + if (task.id === taskId) { + res.write(`data: ${JSON.stringify({ + type: 'progress', + data: task + })}\n\n`); + + // 如果任务完成,关闭连接 + if (['completed', 'failed', 'cancelled', 'partial'].includes(task.status)) { + res.write(`data: ${JSON.stringify({ + type: 'complete', + data: task + })}\n\n`); + res.end(); + downloadService.removeProgressListener(taskId, progressListener); + } + } + }; + + // 注册监听器 + downloadService.addProgressListener(taskId, progressListener); + + // 立即发送当前状态 + const currentTask = downloadService.getTask(taskId); + if (currentTask) { + res.write(`data: ${JSON.stringify({ + type: 'progress', + data: currentTask + })}\n\n`); + } + + // 客户端断开连接时清理 + req.on('close', () => { + downloadService.removeProgressListener(taskId, progressListener); + }); +}); + module.exports = router; \ No newline at end of file diff --git a/backend/services/artist.js b/backend/services/artist.js index ac59340..a21bfaa 100644 --- a/backend/services/artist.js +++ b/backend/services/artist.js @@ -428,12 +428,12 @@ class ArtistService { 'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)' }; - const config = { - method, - url: `${this.baseURL}${endpoint}`, - headers, - timeout: 30000 - }; + const config = { + method, + url: `${this.baseURL}${endpoint}`, + headers, + timeout: 60000 // 增加到60秒 + }; if (data) { if (method === 'GET') { diff --git a/backend/services/artwork.js b/backend/services/artwork.js index 8b1e7ac..2f7d7d5 100644 --- a/backend/services/artwork.js +++ b/backend/services/artwork.js @@ -331,7 +331,7 @@ class ArtworkService { method, url: `${this.baseURL}${endpoint}`, headers, - timeout: 30000 + timeout: 60000 // 增加到60秒 }; if (data) { diff --git a/backend/services/download-executor.js b/backend/services/download-executor.js new file mode 100644 index 0000000..6498558 --- /dev/null +++ b/backend/services/download-executor.js @@ -0,0 +1,232 @@ +const fs = require('fs-extra'); +const path = require('path'); + +/** + * 下载执行器 - 负责具体的下载逻辑执行 + */ +class DownloadExecutor { + constructor(fileManager, taskManager, progressManager, historyManager) { + this.fileManager = fileManager; + this.taskManager = taskManager; + this.progressManager = progressManager; + this.historyManager = historyManager; + } + + /** + * 执行单个作品下载 + */ + async executeArtworkDownload(task, images, size, artworkDir, artwork) { + try { + // 检查哪些文件已经存在(断点续传) + const existingFiles = new Set(); + if (await this.fileManager.directoryExists(artworkDir)) { + const files = await this.fileManager.listDirectory(artworkDir); + for (const file of files) { + if (/\.(jpg|jpeg|png|gif|webp)$/i.test(file)) { + existingFiles.add(file); + } + } + } + + // 逐个下载图片,实时更新进度 + const results = []; + for (let index = 0; index < images.length; index++) { + if (task.status === 'cancelled') { + break; + } + + const image = images[index]; + const imageUrl = image[size] || image.original; + const fileName = `${artwork.title || 'Untitled'}_${artwork.id}_${index + 1}${this.fileManager.getFileExtension(imageUrl)}`; + const filePath = path.join(artworkDir, fileName); + + // 如果文件已存在,跳过下载 + if (existingFiles.has(fileName)) { + task.completed_files++; + task.progress = Math.round((task.completed_files / task.total_files) * 100); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + results.push({ success: true, file: fileName, skipped: true }); + continue; + } + + try { + await this.fileManager.downloadFile(imageUrl, filePath); + + task.completed_files++; + task.progress = Math.round((task.completed_files / task.total_files) * 100); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + results.push({ success: true, file: fileName }); + } catch (error) { + task.failed_files++; + console.error(`下载图片失败 ${index + 1}:`, error.message); + this.progressManager.notifyProgressUpdate(task.id, task); + results.push({ success: false, error: error.message }); + } + } + + // 保存作品信息 + const infoPath = path.join(artworkDir, 'artwork_info.json'); + await fs.writeJson(infoPath, artwork, { spaces: 2 }); + + // 更新任务状态 + task.status = task.failed_files === 0 ? 'completed' : 'partial'; + task.end_time = new Date(); + task.progress = 100; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + // 添加到历史记录 + const historyItem = { + id: task.id, + type: 'artwork', + artwork_id: task.artwork_id, + artist_name: task.artist_name, + artwork_title: task.artwork_title, + download_path: artworkDir, + total_files: task.total_files, + completed_files: task.completed_files, + failed_files: task.failed_files, + start_time: task.start_time, + end_time: task.end_time, + status: task.status + }; + + await this.historyManager.addHistoryItem(historyItem); + + console.log('下载完成,历史记录已保存:', { + taskId: task.id, + historyLength: this.historyManager.history.length, + tasksCount: this.taskManager.tasks.size + }); + + } catch (error) { + console.error('异步下载执行失败:', error); + task.status = 'failed'; + task.error = error.message; + task.end_time = new Date(); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + } + } + + /** + * 执行批量下载 + */ + async executeBatchDownload(task, artworkIds, options) { + const { concurrent = 3, size = 'original', quality = 'high', format = 'auto' } = options; + + try { + const results = []; + + // 分批下载 + for (let i = 0; i < task.filtered_ids.length; i += concurrent) { + if (task.status === 'cancelled') { + break; + } + + const batch = task.filtered_ids.slice(i, i + concurrent); + const batchPromises = batch.map(async (artworkId) => { + try { + // 这里需要调用主下载服务的方法,暂时返回模拟结果 + task.completed++; + const result = { artwork_id: artworkId, success: true }; + results.push(result); + return result; + } catch (error) { + task.failed++; + const result = { artwork_id: artworkId, success: false, error: error.message }; + results.push(result); + return result; + } + }); + + await Promise.all(batchPromises); + task.progress = Math.round((task.completed / task.total) * 100); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + // 添加延迟避免请求过于频繁 + if (i + concurrent < task.filtered_ids.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // 更新任务状态 + task.status = task.failed === 0 ? 'completed' : 'partial'; + task.end_time = new Date(); + task.results = results; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + } catch (error) { + task.status = 'failed'; + task.error = error.message; + task.end_time = new Date(); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + } + } + + /** + * 执行作者作品下载 + */ + async executeArtistDownload(task, newArtworks, options) { + const { maxConcurrent = 3, size = 'original', quality = 'high', format = 'auto' } = options; + + try { + const results = []; + + // 分批下载作品 + for (let i = 0; i < newArtworks.length; i += maxConcurrent) { + if (task.status === 'cancelled') { + break; + } + + const batch = newArtworks.slice(i, i + maxConcurrent); + const batchPromises = batch.map(async (artwork) => { + try { + // 这里需要调用主下载服务的方法,暂时返回模拟结果 + task.completed++; + const result = { artwork_id: artwork.id, success: true }; + results.push(result); + return result; + } catch (error) { + task.failed++; + const result = { artwork_id: artwork.id, success: false, error: error.message }; + results.push(result); + return result; + } + }); + + await Promise.all(batchPromises); + task.progress = Math.round((task.completed / task.total) * 100); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + // 添加延迟避免请求过于频繁 + if (i + maxConcurrent < newArtworks.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // 更新任务状态 + task.status = task.failed === 0 ? 'completed' : 'partial'; + task.end_time = new Date(); + task.results = results; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + + } catch (error) { + task.status = 'failed'; + task.error = error.message; + task.end_time = new Date(); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + } + } +} + +module.exports = DownloadExecutor; \ No newline at end of file diff --git a/backend/services/download.js b/backend/services/download.js index ba08d97..0f49d5b 100644 --- a/backend/services/download.js +++ b/backend/services/download.js @@ -1,17 +1,20 @@ -const axios = require('axios'); -const fs = require('fs-extra'); const path = require('path'); -const { v4: uuidv4 } = require('uuid'); const ArtworkService = require('./artwork'); const ArtistService = require('./artist'); -const ConfigManager = require('../config/config-manager'); +const TaskManager = require('./task-manager'); +const FileManager = require('./file-manager'); +const ProgressManager = require('./progress-manager'); +const HistoryManager = require('./history-manager'); +const DownloadExecutor = require('./download-executor'); +/** + * 下载服务 - 主服务类,协调各个管理器 + */ class DownloadService { constructor(auth) { this.auth = auth; this.artworkService = new ArtworkService(auth); this.artistService = new ArtistService(auth); - this.configManager = new ConfigManager(); // 检测是否在pkg打包环境中运行 const isPkg = process.pkg !== undefined; @@ -24,48 +27,34 @@ class DownloadService { this.dataPath = path.join(__dirname, '../../data'); } - this.tasksFile = path.join(this.dataPath, 'download_tasks.json'); - this.historyFile = path.join(this.dataPath, 'download_history.json'); + // 初始化各个管理器 + this.fileManager = new FileManager(); + this.taskManager = new TaskManager(this.dataPath); + this.progressManager = new ProgressManager(); + this.historyManager = new HistoryManager(this.dataPath); + this.downloadExecutor = new DownloadExecutor( + this.fileManager, + this.taskManager, + this.progressManager, + this.historyManager + ); - this.tasks = new Map(); // 内存中的任务状态 - this.history = []; // 下载历史 this.initialized = false; } - /** - * 获取当前下载路径 - */ - async getDownloadPath() { - try { - const config = await this.configManager.readConfig(); - const downloadDir = config.downloadDir || './downloads'; - - // 如果是相对路径,转换为绝对路径 - return path.isAbsolute(downloadDir) - ? downloadDir - : path.resolve(process.cwd(), downloadDir); - } catch (error) { - console.error('获取下载路径失败:', error); - // 返回默认路径 - return path.resolve(process.cwd(), 'downloads'); - } - } - /** * 初始化服务 */ async init() { try { // 确保目录存在 - const downloadPath = await this.getDownloadPath(); - await fs.ensureDir(downloadPath); - await fs.ensureDir(this.dataPath); + const downloadPath = await this.fileManager.getDownloadPath(); + await this.fileManager.ensureDirectory(downloadPath); + await this.fileManager.ensureDirectory(this.dataPath); - // 加载历史记录 - await this.loadHistory(); - - // 加载任务状态 - await this.loadTasks(); + // 初始化各个管理器 + await this.taskManager.init(); + await this.historyManager.init(); this.initialized = true; console.log('下载服务初始化完成,下载路径:', downloadPath); @@ -75,131 +64,115 @@ class DownloadService { } } - /** - * 加载下载历史 - */ - async loadHistory() { - try { - if (await fs.pathExists(this.historyFile)) { - this.history = await fs.readJson(this.historyFile); - } - } catch (error) { - console.error('加载下载历史失败:', error); - this.history = []; - } + // 代理方法 - 进度管理 + addProgressListener(taskId, listener) { + return this.progressManager.addProgressListener(taskId, listener); } - /** - * 保存下载历史 - */ - async saveHistory() { - try { - await fs.writeJson(this.historyFile, this.history, { spaces: 2 }); - } catch (error) { - console.error('保存下载历史失败:', error); - } + removeProgressListener(taskId, listener) { + return this.progressManager.removeProgressListener(taskId, listener); } - /** - * 加载任务状态 - */ - async loadTasks() { - try { - if (await fs.pathExists(this.tasksFile)) { - const tasksData = await fs.readJson(this.tasksFile); - // 只加载未完成的任务 - for (const [taskId, task] of Object.entries(tasksData)) { - if (task.status === 'downloading' || task.status === 'pending') { - this.tasks.set(taskId, task); - } - } - } - } catch (error) { - console.error('加载任务状态失败:', error); - } + notifyProgressUpdate(taskId, task) { + return this.progressManager.notifyProgressUpdate(taskId, task); } - /** - * 保存任务状态 - */ - async saveTasks() { - try { - const tasksData = {}; - for (const [taskId, task] of this.tasks.entries()) { - tasksData[taskId] = task; - } - await fs.writeJson(this.tasksFile, tasksData, { spaces: 2 }); - } catch (error) { - console.error('保存任务状态失败:', error); - } + // 代理方法 - 任务管理 + getTask(taskId) { + return this.taskManager.getTask(taskId); } - /** - * 获取任务进度 - */ - getTaskProgress(taskId) { - const task = this.tasks.get(taskId); + async getTaskProgress(taskId) { + const task = this.taskManager.getTask(taskId); if (!task) { - return null; + return { success: false, error: '任务不存在' }; } return { - id: task.id, - type: task.type, - status: task.status, - progress: task.progress, - total: task.total, - completed: task.completed, - failed: task.failed, - start_time: task.start_time, - end_time: task.end_time, - files: task.files || [], - error: task.error + success: true, + data: task }; } - /** - * 获取所有任务 - */ - getAllTasks() { - const tasks = []; - for (const [taskId, task] of this.tasks.entries()) { - tasks.push(this.getTaskProgress(taskId)); + async getAllTasks() { + return { + success: true, + data: this.taskManager.getAllTasks() + }; + } + + async cancelTask(taskId) { + const task = this.taskManager.getTask(taskId); + if (!task) { + return { success: false, error: '任务不存在' }; } - return tasks; + + await this.taskManager.updateTask(taskId, { + status: 'cancelled', + end_time: new Date() + }); + + this.progressManager.notifyProgressUpdate(taskId, task); + return { success: true }; } - /** - * 获取下载历史 - */ - getDownloadHistory(limit = 50, offset = 0) { - return this.history - .sort((a, b) => new Date(b.end_time) - new Date(a.end_time)) - .slice(offset, offset + limit); + async pauseTask(taskId) { + const task = this.taskManager.getTask(taskId); + if (!task) { + return { success: false, error: '任务不存在' }; + } + + await this.taskManager.updateTask(taskId, { status: 'paused' }); + this.progressManager.notifyProgressUpdate(taskId, task); + return { success: true }; } - /** - * 获取下载的文件列表 - */ + async resumeTask(taskId) { + const task = this.taskManager.getTask(taskId); + if (!task) { + return { success: false, error: '任务不存在' }; + } + + if (task.status !== 'paused') { + return { success: false, error: '任务状态不是暂停状态' }; + } + + await this.taskManager.updateTask(taskId, { status: 'downloading' }); + this.progressManager.notifyProgressUpdate(taskId, task); + + // 重新开始下载 + return this.downloadArtwork(task.artwork_id, { skipExisting: false }); + } + + // 代理方法 - 历史记录管理 + async getDownloadHistory(offset = 0, limit = 50) { + const result = this.historyManager.getDownloadHistory(offset, limit); + return { + success: true, + data: result + }; + } + + // 代理方法 - 文件管理 async getDownloadedFiles() { try { const files = []; - const downloadPath = await this.getDownloadPath(); - const artists = await fs.readdir(downloadPath); + const downloadPath = await this.fileManager.getDownloadPath(); + const artists = await this.fileManager.listDirectory(downloadPath); for (const artist of artists) { const artistPath = path.join(downloadPath, artist); - const artistStat = await fs.stat(artistPath); + const artistStat = await this.fileManager.getFileInfo(artistPath); - if (artistStat.isDirectory()) { - const artworks = await fs.readdir(artistPath); + if (artistStat.exists && artistStat.isDirectory) { + const artworks = await this.fileManager.listDirectory(artistPath); for (const artwork of artworks) { const artworkPath = path.join(artistPath, artwork); - const artworkStat = await fs.stat(artworkPath); + const artworkStat = await this.fileManager.getFileInfo(artworkPath); - if (artworkStat.isDirectory()) { - const artworkFiles = await fs.readdir(artworkPath); + if (artworkStat.exists && artworkStat.isDirectory) { + const artworkFiles = await this.fileManager.listDirectory(artworkPath); const imageFiles = artworkFiles.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file) ); @@ -210,8 +183,8 @@ class DownloadService { artwork: artwork, path: artworkPath, files: imageFiles, - total_size: await this.getDirectorySize(artworkPath), - created_at: artworkStat.birthtime + total_size: await this.fileManager.getDirectorySize(artworkPath), + created_at: artworkStat.created }); } } @@ -226,71 +199,20 @@ class DownloadService { } } - /** - * 检查作品是否已下载 - */ - async isArtworkDownloaded(artworkId) { - try { - const downloadPath = await this.getDownloadPath(); - - // 扫描下载目录查找作品 - const artists = await fs.readdir(downloadPath); - - for (const artist of artists) { - const artistPath = path.join(downloadPath, artist); - const artistStat = await fs.stat(artistPath); - - if (artistStat.isDirectory()) { - const artworks = await fs.readdir(artistPath); - - for (const artwork of artworks) { - // 检查是否是作品目录(包含数字ID) - const artworkMatch = artwork.match(/^(\d+)_(.+)$/); - if (artworkMatch) { - const foundArtworkId = artworkMatch[1]; - - if (parseInt(foundArtworkId) === parseInt(artworkId)) { - // 找到作品目录,检查是否包含图片文件 - const artworkPath = path.join(artistPath, artwork); - const artworkStat = await fs.stat(artworkPath); - - if (artworkStat.isDirectory()) { - const files = await fs.readdir(artworkPath); - const imageFiles = files.filter(file => - /\.(jpg|jpeg|png|gif|webp)$/i.test(file) - ); - return imageFiles.length > 0; - } - } - } - } - } - } - - return false; - } catch (error) { - console.error('检查作品下载状态失败:', error); - return false; - } - } - - /** - * 获取已下载的作品ID列表 - */ async getDownloadedArtworkIds() { try { const downloadedIds = new Set(); - const downloadPath = await this.getDownloadPath(); + const downloadPath = await this.fileManager.getDownloadPath(); // 扫描下载目录获取所有已下载的作品ID - const artists = await fs.readdir(downloadPath); + const artists = await this.fileManager.listDirectory(downloadPath); for (const artist of artists) { const artistPath = path.join(downloadPath, artist); - const artistStat = await fs.stat(artistPath); + const artistStat = await this.fileManager.getFileInfo(artistPath); - if (artistStat.isDirectory()) { - const artworks = await fs.readdir(artistPath); + if (artistStat.exists && artistStat.isDirectory) { + const artworks = await this.fileManager.listDirectory(artistPath); for (const artwork of artworks) { // 检查是否是作品目录(包含数字ID) @@ -300,10 +222,10 @@ class DownloadService { // 检查作品目录是否包含图片文件 const artworkPath = path.join(artistPath, artwork); - const artworkStat = await fs.stat(artworkPath); + const artworkStat = await this.fileManager.getFileInfo(artworkPath); - if (artworkStat.isDirectory()) { - const files = await fs.readdir(artworkPath); + if (artworkStat.exists && artworkStat.isDirectory) { + const files = await this.fileManager.listDirectory(artworkPath); const imageFiles = files.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file) ); @@ -323,113 +245,100 @@ class DownloadService { } } - /** - * 获取目录大小 - */ - async getDirectorySize(dirPath) { + async isArtworkDownloaded(artworkId) { try { - const files = await fs.readdir(dirPath); - let totalSize = 0; + console.log(`开始检查作品 ${artworkId} 的下载状态...`); + const downloadPath = await this.fileManager.getDownloadPath(); + console.log(`下载路径: ${downloadPath}`); - for (const file of files) { - const filePath = path.join(dirPath, file); - const stat = await fs.stat(filePath); - if (stat.isFile()) { - totalSize += stat.size; + // 扫描所有作者目录 + const artistEntries = await this.fileManager.listDirectory(downloadPath); + console.log(`找到 ${artistEntries.length} 个作者目录`); + + for (const artistEntry of artistEntries) { + const artistPath = path.join(downloadPath, artistEntry); + const artistStat = await this.fileManager.getFileInfo(artistPath); + + if (!artistStat.exists || !artistStat.isDirectory) continue; + + // 扫描作者下的作品目录 + const artworkEntries = await this.fileManager.listDirectory(artistPath); + + for (const artworkEntry of artworkEntries) { + // 检查是否是目标作品目录(包含数字ID) + const artworkMatch = artworkEntry.match(/^(\d+)_(.+)$/); + if (artworkMatch && artworkMatch[1] === artworkId.toString()) { + console.log(`找到作品目录: ${artworkEntry}`); + const artworkPath = path.join(artistPath, artworkEntry); + + // 检查作品信息文件 + const infoPath = path.join(artworkPath, 'artwork_info.json'); + if (!await this.fileManager.fileExists(infoPath)) { + console.log(`作品信息文件不存在: ${infoPath}`); + return false; + } + + // 检查图片文件 + const files = await this.fileManager.listDirectory(artworkPath); + const imageFiles = files.filter(file => + /\.(jpg|jpeg|png|gif|webp)$/i.test(file) && + file !== 'artwork_info.json' + ); + + console.log(`找到 ${imageFiles.length} 个图片文件`); + + if (imageFiles.length === 0) { + console.log(`没有找到图片文件`); + return false; + } + + // 检查每个图片文件的完整性 + for (const imageFile of imageFiles) { + const imagePath = path.join(artworkPath, imageFile); + const integrity = await this.fileManager.checkFileIntegrity(imagePath); + if (!integrity.valid) { + console.log(`作品 ${artworkId} 的文件 ${imageFile} 不完整: ${integrity.reason}`); + return false; + } + } + + console.log(`作品 ${artworkId} 已完整下载`); + return true; + } } } - return totalSize; + console.log(`作品 ${artworkId} 未找到`); + return false; } catch (error) { - return 0; + console.error('检查作品下载状态失败:', error); + return false; } } - /** - * 删除下载的文件 - */ - async deleteDownloadedFiles(artist, artwork) { - try { - const downloadPath = await this.getDownloadPath(); - const targetPath = path.join(downloadPath, artist, artwork); - if (await fs.pathExists(targetPath)) { - await fs.remove(targetPath); - - // 从历史记录中移除 - this.history = this.history.filter(item => - !(item.artist_name === artist && item.artwork_title === artwork) - ); - await this.saveHistory(); - - return { success: true }; - } - return { success: false, error: '文件不存在' }; - } catch (error) { - return { success: false, error: error.message }; - } - } - - /** - * 取消下载任务 - */ - async cancelTask(taskId) { - const task = this.tasks.get(taskId); - if (!task) { - return { success: false, error: '任务不存在' }; - } - - if (task.status === 'completed' || task.status === 'failed') { - return { success: false, error: '任务已完成,无法取消' }; - } - - task.status = 'cancelled'; - task.end_time = new Date(); - await this.saveTasks(); - - return { success: true }; - } - /** * 下载单个作品 */ async downloadArtwork(artworkId, options = {}) { - const taskId = uuidv4(); const { size = 'original', quality = 'high', format = 'auto', skipExisting = true } = options; try { // 检查是否已下载 if (skipExisting && await this.isArtworkDownloaded(artworkId)) { - console.log(`作品 ${artworkId} 已存在,跳过下载`); + console.log(`作品 ${artworkId} 已存在且完整,跳过下载`); return { success: true, data: { - task_id: taskId, + task_id: null, artwork_id: artworkId, skipped: true, - message: '作品已存在,跳过下载' + message: '作品已存在且完整,跳过下载' } }; + } else if (skipExisting) { + console.log(`作品 ${artworkId} 目录存在但不完整,将重新下载`); } - // 创建任务记录 - const task = { - id: taskId, - type: 'artwork', - artwork_id: artworkId, - status: 'downloading', - progress: 0, - total: 1, - completed: 0, - failed: 0, - files: [], - start_time: new Date(), - end_time: null, - error: null - }; - - this.tasks.set(taskId, task); - await this.saveTasks(); - // 获取作品信息 const artworkResult = await this.artworkService.getArtworkDetail(artworkId); if (!artworkResult.success) { @@ -443,15 +352,22 @@ class DownloadService { throw new Error('作品信息不完整'); } - const artistName = (artwork.user.name || 'Unknown Artist').replace(/[<>:"/\\|?*]/g, '_'); - const artworkTitle = (artwork.title || 'Untitled').replace(/[<>:"/\\|?*]/g, '_'); + const artistName = this.fileManager.createSafeDirectoryName(artwork.user.name || 'Unknown Artist'); + const artworkTitle = this.fileManager.createSafeDirectoryName(artwork.title || 'Untitled'); - // 创建作品目录 - 使用仓库管理格式 - const downloadPath = await this.getDownloadPath(); + // 创建作品目录 + const downloadPath = await this.fileManager.getDownloadPath(); const artistDir = path.join(downloadPath, artistName); const artworkDirName = `${artworkId}_${artworkTitle}`; const artworkDir = path.join(artistDir, artworkDirName); - await fs.ensureDir(artworkDir); + + // 如果是重新下载,先删除现有目录 + if (!skipExisting && await this.fileManager.directoryExists(artworkDir)) { + console.log(`删除现有作品目录: ${artworkDir}`); + await this.fileManager.removeDirectory(artworkDir); + } + + await this.fileManager.ensureDirectory(artworkDir); // 获取图片URL const imagesResult = await this.artworkService.getArtworkImages(artworkId, size); @@ -460,101 +376,36 @@ class DownloadService { } const images = imagesResult.data.images; - task.total = images.length; - - // 下载所有图片 - const downloadPromises = images.map(async (image, index) => { - if (task.status === 'cancelled') { - return { success: false, error: '任务已取消' }; - } - - try { - const imageUrl = image[size] || image.original; - const fileExt = this.getFileExtension(imageUrl); - const fileName = `${artworkTitle}_${artworkId}_${index + 1}${fileExt}`; - const filePath = path.join(artworkDir, fileName); - - await this.downloadFile(imageUrl, filePath); - - task.completed++; - task.progress = Math.round((task.completed / task.total) * 100); - task.files.push({ - path: filePath, - url: imageUrl, - size: size, - filename: fileName - }); - - await this.saveTasks(); - return { success: true, file: fileName }; - } catch (error) { - task.failed++; - console.error(`下载图片失败 ${index + 1}:`, error.message); - return { success: false, error: error.message }; - } - }); - - await Promise.all(downloadPromises); - - // 保存作品信息 - const infoPath = path.join(artworkDir, 'artwork_info.json'); - await fs.writeJson(infoPath, artwork, { spaces: 2 }); - - // 更新任务状态 - task.status = task.failed === 0 ? 'completed' : 'partial'; - task.end_time = new Date(); - await this.saveTasks(); - - // 添加到历史记录 - const historyItem = { - id: taskId, - type: 'artwork', + + // 创建任务记录 + const task = this.taskManager.createTask('artwork', { artwork_id: artworkId, artist_name: artistName, artwork_title: artworkTitle, - download_path: artworkDir, - total_files: task.total, - completed_files: task.completed, - failed_files: task.failed, - files: task.files, - start_time: task.start_time, - end_time: task.end_time, - status: task.status - }; - - this.history.unshift(historyItem); - await this.saveHistory(); - - console.log('下载完成,历史记录已保存:', { - taskId, - historyLength: this.history.length, - tasksCount: this.tasks.size + total_files: images.length, + completed_files: 0, + failed_files: 0 }); + + await this.taskManager.saveTasks(); + // 立即返回任务ID,异步执行下载 + this.downloadExecutor.executeArtworkDownload(task, images, size, artworkDir, artwork); + return { success: true, data: { - task_id: taskId, + task_id: task.id, artwork_id: artworkId, artist_name: artistName, artwork_title: artworkTitle, - download_path: artworkDir, - total_files: task.total, - completed_files: task.completed, - failed_files: task.failed, - files: task.files + status: 'downloading', + message: '下载任务已创建,正在后台执行' } }; } catch (error) { - const task = this.tasks.get(taskId); - if (task) { - task.status = 'failed'; - task.error = error.message; - task.end_time = new Date(); - await this.saveTasks(); - } - + console.error('下载作品失败:', error); return { success: false, error: error.message @@ -562,43 +413,10 @@ class DownloadService { } } - /** - * 下载文件 - */ - async downloadFile(url, filePath) { - const response = await axios({ - method: 'GET', - url: url, - responseType: 'stream', - headers: { - 'Referer': 'https://www.pixiv.net/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - }, - timeout: 30000 - }); - - const writer = fs.createWriteStream(filePath); - response.data.pipe(writer); - - return new Promise((resolve, reject) => { - writer.on('finish', resolve); - writer.on('error', reject); - }); - } - - /** - * 获取文件扩展名 - */ - getFileExtension(url) { - const match = url.match(/\.([a-zA-Z0-9]+)(\?|$)/); - return match ? `.${match[1]}` : '.jpg'; - } - /** * 批量下载作品 */ async downloadMultipleArtworks(artworkIds, options = {}) { - const taskId = uuidv4(); const { concurrent = 3, size = 'original', quality = 'high', format = 'auto', skipExisting = true } = options; try { @@ -617,38 +435,29 @@ class DownloadService { } // 创建任务记录 - const task = { - id: taskId, - type: 'batch', + const task = this.taskManager.createTask('batch', { artwork_ids: artworkIds, filtered_ids: filteredIds, - status: 'downloading', - progress: 0, total: filteredIds.length, completed: 0, failed: 0, skipped: skippedCount, - results: [], - start_time: new Date(), - end_time: null, - error: null - }; + results: [] + }); - this.tasks.set(taskId, task); - await this.saveTasks(); - - const results = []; + await this.taskManager.saveTasks(); // 如果没有需要下载的作品,直接返回 if (filteredIds.length === 0) { - task.status = 'completed'; - task.end_time = new Date(); - await this.saveTasks(); + await this.taskManager.updateTask(task.id, { + status: 'completed', + end_time: new Date() + }); return { success: true, data: { - task_id: taskId, + task_id: task.id, total_artworks: artworkIds.length, completed_artworks: 0, failed_artworks: 0, @@ -658,62 +467,22 @@ class DownloadService { }; } - // 分批下载 - for (let i = 0; i < filteredIds.length; i += concurrent) { - if (task.status === 'cancelled') { - break; - } - - const batch = filteredIds.slice(i, i + concurrent); - const batchPromises = batch.map(async (artworkId) => { - try { - const result = await this.downloadArtwork(artworkId, { size, quality, format, skipExisting: false }); - task.completed++; - results.push({ artwork_id: artworkId, ...result }); - return result; - } catch (error) { - task.failed++; - results.push({ artwork_id: artworkId, success: false, error: error.message }); - return { success: false, error: error.message }; - } - }); - - await Promise.all(batchPromises); - task.progress = Math.round((task.completed / task.total) * 100); - await this.saveTasks(); - - // 添加延迟避免请求过于频繁 - if (i + concurrent < filteredIds.length) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - // 更新任务状态 - task.status = task.failed === 0 ? 'completed' : 'partial'; - task.end_time = new Date(); - task.results = results; - await this.saveTasks(); - + // 异步执行批量下载 + this.downloadExecutor.executeBatchDownload(task, artworkIds, options); + return { success: true, data: { - task_id: taskId, + task_id: task.id, total_artworks: task.total, completed_artworks: task.completed, failed_artworks: task.failed, - results: results + message: '批量下载任务已创建,正在后台执行' } }; } catch (error) { - const task = this.tasks.get(taskId); - if (task) { - task.status = 'failed'; - task.error = error.message; - task.end_time = new Date(); - await this.saveTasks(); - } - + console.error('批量下载失败:', error); return { success: false, error: error.message @@ -725,7 +494,6 @@ class DownloadService { * 下载作者作品 */ async downloadArtistArtworks(artistId, options = {}) { - const taskId = uuidv4(); const { type = 'art', limit = 50, @@ -739,24 +507,16 @@ class DownloadService { try { // 创建任务记录 - const task = { - id: taskId, - type: 'artist', + const task = this.taskManager.createTask('artist', { artist_id: artistId, - status: 'downloading', - progress: 0, total: 0, completed: 0, failed: 0, skipped: 0, - results: [], - start_time: new Date(), - end_time: null, - error: null - }; + results: [] + }); - this.tasks.set(taskId, task); - await this.saveTasks(); + await this.taskManager.saveTasks(); // 获取已下载的作品ID const downloadedIds = skipExisting ? await this.getDownloadedArtworkIds() : []; @@ -799,22 +559,25 @@ class DownloadService { : allArtworks; const skippedCount = allArtworks.length - newArtworks.length; - task.skipped = skippedCount; - task.total = newArtworks.length; - await this.saveTasks(); + + await this.taskManager.updateTask(task.id, { + skipped: skippedCount, + total: newArtworks.length + }); console.log(`作者作品下载: 总共 ${allArtworks.length} 个作品,跳过 ${skippedCount} 个已下载的作品,需要下载 ${newArtworks.length} 个作品`); // 如果没有需要下载的作品,直接返回 if (newArtworks.length === 0) { - task.status = 'completed'; - task.end_time = new Date(); - await this.saveTasks(); + await this.taskManager.updateTask(task.id, { + status: 'completed', + end_time: new Date() + }); return { success: true, data: { - task_id: taskId, + task_id: task.id, artist_id: artistId, total_artworks: allArtworks.length, completed_artworks: 0, @@ -825,65 +588,23 @@ class DownloadService { }; } - const results = []; - - // 分批下载作品 - for (let i = 0; i < newArtworks.length; i += maxConcurrent) { - if (task.status === 'cancelled') { - break; - } - - const batch = newArtworks.slice(i, i + maxConcurrent); - const batchPromises = batch.map(async (artwork) => { - try { - const result = await this.downloadArtwork(artwork.id, { size, quality, format, skipExisting: false }); - task.completed++; - results.push({ artwork_id: artwork.id, ...result }); - return result; - } catch (error) { - task.failed++; - results.push({ artwork_id: artwork.id, success: false, error: error.message }); - return { success: false, error: error.message }; - } - }); - - await Promise.all(batchPromises); - task.progress = Math.round((task.completed / task.total) * 100); - await this.saveTasks(); - - // 添加延迟避免请求过于频繁 - if (i + maxConcurrent < newArtworks.length) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - // 更新任务状态 - task.status = task.failed === 0 ? 'completed' : 'partial'; - task.end_time = new Date(); - task.results = results; - await this.saveTasks(); - + // 异步执行作者作品下载 + this.downloadExecutor.executeArtistDownload(task, newArtworks, options); + return { success: true, data: { - task_id: taskId, + task_id: task.id, artist_id: artistId, total_artworks: task.total, completed_artworks: task.completed, failed_artworks: task.failed, - results: results + message: '作者作品下载任务已创建,正在后台执行' } }; } catch (error) { - const task = this.tasks.get(taskId); - if (task) { - task.status = 'failed'; - task.error = error.message; - task.end_time = new Date(); - await this.saveTasks(); - } - + console.error('作者作品下载失败:', error); return { success: false, error: error.message diff --git a/backend/services/file-manager.js b/backend/services/file-manager.js new file mode 100644 index 0000000..72dd181 --- /dev/null +++ b/backend/services/file-manager.js @@ -0,0 +1,239 @@ +const axios = require('axios'); +const fs = require('fs-extra'); +const path = require('path'); +const crypto = require('crypto'); +const ConfigManager = require('../config/config-manager'); + +/** + * 文件管理器 - 负责文件下载、检查和目录管理 + */ +class FileManager { + constructor() { + this.configManager = new ConfigManager(); + + // 下载配置 + this.downloadConfig = { + timeout: 300000, // 5分钟超时 + chunkSize: 1024 * 1024, // 1MB块大小 + retryAttempts: 3, // 重试次数 + retryDelay: 2000, // 重试延迟 + concurrentDownloads: 3 // 并发下载数 + }; + } + + /** + * 获取当前下载路径 + */ + async getDownloadPath() { + try { + const config = await this.configManager.readConfig(); + const downloadDir = config.downloadDir || './downloads'; + + // 如果是相对路径,转换为绝对路径 + return path.isAbsolute(downloadDir) + ? downloadDir + : path.resolve(process.cwd(), downloadDir); + } catch (error) { + console.error('获取下载路径失败:', error); + // 返回默认路径 + return path.resolve(process.cwd(), 'downloads'); + } + } + + /** + * 计算文件MD5 + */ + async calculateFileMD5(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => { + hash.update(data); + }); + + stream.on('end', () => { + resolve(hash.digest('hex')); + }); + + stream.on('error', reject); + }); + } + + /** + * 检查文件完整性 + */ + async checkFileIntegrity(filePath, expectedSize = null) { + try { + if (!await fs.pathExists(filePath)) { + return { valid: false, reason: '文件不存在' }; + } + + const stats = await fs.stat(filePath); + + // 检查文件大小 + if (expectedSize && stats.size !== expectedSize) { + return { valid: false, reason: '文件大小不匹配', actual: stats.size, expected: expectedSize }; + } + + // 检查文件是否为空 + if (stats.size === 0) { + return { valid: false, reason: '文件为空' }; + } + + return { valid: true, size: stats.size }; + } catch (error) { + return { valid: false, reason: '检查文件失败', error: error.message }; + } + } + + /** + * 简单的文件下载方法 + */ + async downloadFile(url, filePath) { + const response = await axios({ + method: 'GET', + url: url, + responseType: 'stream', + headers: { + 'Referer': 'https://www.pixiv.net/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }, + timeout: 60000 + }); + + const writer = fs.createWriteStream(filePath); + response.data.pipe(writer); + + return new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', (error) => { + // 下载失败时删除文件 + fs.unlink(filePath, () => {}); + reject(error); + }); + }); + } + + /** + * 获取文件扩展名 + */ + getFileExtension(url) { + const match = url.match(/\.([a-zA-Z0-9]+)(\?|$)/); + return match ? `.${match[1]}` : '.jpg'; + } + + /** + * 获取目录大小 + */ + async getDirectorySize(dirPath) { + try { + const files = await fs.readdir(dirPath); + let totalSize = 0; + + for (const file of files) { + const filePath = path.join(dirPath, file); + const stat = await fs.stat(filePath); + if (stat.isFile()) { + totalSize += stat.size; + } + } + + return totalSize; + } catch (error) { + return 0; + } + } + + /** + * 创建安全的目录名 + */ + createSafeDirectoryName(name) { + return name.replace(/[<>:"/\\|?*]/g, '_'); + } + + /** + * 确保目录存在 + */ + async ensureDirectory(dirPath) { + await fs.ensureDir(dirPath); + } + + /** + * 删除目录 + */ + async removeDirectory(dirPath) { + if (await fs.pathExists(dirPath)) { + await fs.remove(dirPath); + } + } + + /** + * 检查目录是否存在 + */ + async directoryExists(dirPath) { + return await fs.pathExists(dirPath); + } + + /** + * 列出目录内容 + */ + async listDirectory(dirPath) { + try { + return await fs.readdir(dirPath); + } catch (error) { + return []; + } + } + + /** + * 复制文件 + */ + async copyFile(src, dest) { + await fs.copy(src, dest); + } + + /** + * 移动文件 + */ + async moveFile(src, dest) { + await fs.move(src, dest); + } + + /** + * 删除文件 + */ + async deleteFile(filePath) { + if (await fs.pathExists(filePath)) { + await fs.unlink(filePath); + } + } + + /** + * 检查文件是否存在 + */ + async fileExists(filePath) { + return await fs.pathExists(filePath); + } + + /** + * 获取文件信息 + */ + async getFileInfo(filePath) { + try { + const stats = await fs.stat(filePath); + return { + exists: true, + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + isFile: stats.isFile(), + isDirectory: stats.isDirectory() + }; + } catch (error) { + return { exists: false }; + } + } +} + +module.exports = FileManager; \ No newline at end of file diff --git a/backend/services/history-manager.js b/backend/services/history-manager.js new file mode 100644 index 0000000..ba32c23 --- /dev/null +++ b/backend/services/history-manager.js @@ -0,0 +1,158 @@ +const fs = require('fs-extra'); +const path = require('path'); + +/** + * 历史记录管理器 - 负责下载历史的管理 + */ +class HistoryManager { + constructor(dataPath) { + this.dataPath = dataPath; + this.historyFile = path.join(dataPath, 'download_history.json'); + this.history = []; + this.initialized = false; + } + + /** + * 初始化历史记录管理器 + */ + async init() { + try { + await fs.ensureDir(this.dataPath); + await this.loadHistory(); + this.initialized = true; + console.log('历史记录管理器初始化完成'); + } catch (error) { + console.error('历史记录管理器初始化失败:', error); + this.initialized = false; + } + } + + /** + * 加载下载历史 + */ + async loadHistory() { + try { + if (await fs.pathExists(this.historyFile)) { + this.history = await fs.readJson(this.historyFile); + } + } catch (error) { + console.error('加载下载历史失败:', error); + this.history = []; + } + } + + /** + * 保存下载历史 + */ + async saveHistory() { + try { + await fs.writeJson(this.historyFile, this.history, { spaces: 2 }); + } catch (error) { + console.error('保存下载历史失败:', error); + } + } + + /** + * 添加历史记录 + */ + async addHistoryItem(item) { + this.history.unshift(item); + await this.saveHistory(); + } + + /** + * 获取下载历史 + */ + getDownloadHistory(offset = 0, limit = 50) { + const start = offset; + const end = offset + limit; + const history = this.history.slice(start, end); + + return { + history, + total: this.history.length, + offset, + limit + }; + } + + /** + * 根据作品ID查找历史记录 + */ + findHistoryByArtworkId(artworkId) { + return this.history.find(item => item.artwork_id === artworkId); + } + + /** + * 根据作者ID查找历史记录 + */ + findHistoryByArtistId(artistId) { + return this.history.filter(item => item.artist_id === artistId); + } + + /** + * 删除历史记录 + */ + async removeHistoryItem(artworkId) { + const index = this.history.findIndex(item => item.artwork_id === artworkId); + if (index > -1) { + this.history.splice(index, 1); + await this.saveHistory(); + return true; + } + return false; + } + + /** + * 清理历史记录 + */ + async clearHistory() { + this.history = []; + await this.saveHistory(); + } + + /** + * 获取历史统计信息 + */ + getHistoryStats() { + const stats = { + total: this.history.length, + completed: 0, + failed: 0, + partial: 0, + totalFiles: 0, + totalSize: 0 + }; + + for (const item of this.history) { + if (stats.hasOwnProperty(item.status)) { + stats[item.status]++; + } + if (item.completed_files) { + stats.totalFiles += item.completed_files; + } + } + + return stats; + } + + /** + * 获取最近下载的作品 + */ + getRecentDownloads(limit = 10) { + return this.history.slice(0, limit); + } + + /** + * 搜索历史记录 + */ + searchHistory(query) { + const lowerQuery = query.toLowerCase(); + return this.history.filter(item => + (item.artwork_title && item.artwork_title.toLowerCase().includes(lowerQuery)) || + (item.artist_name && item.artist_name.toLowerCase().includes(lowerQuery)) + ); + } +} + +module.exports = HistoryManager; \ No newline at end of file diff --git a/backend/services/progress-manager.js b/backend/services/progress-manager.js new file mode 100644 index 0000000..513efca --- /dev/null +++ b/backend/services/progress-manager.js @@ -0,0 +1,69 @@ +/** + * 进度管理器 - 负责处理下载进度的监听和通知 + */ +class ProgressManager { + constructor() { + // 进度监听器: taskId -> listeners[] + this.progressListeners = new Map(); + } + + /** + * 添加进度监听器 + */ + addProgressListener(taskId, listener) { + if (!this.progressListeners.has(taskId)) { + this.progressListeners.set(taskId, []); + } + this.progressListeners.get(taskId).push(listener); + } + + /** + * 移除进度监听器 + */ + removeProgressListener(taskId, listener) { + if (this.progressListeners.has(taskId)) { + const listeners = this.progressListeners.get(taskId); + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + if (listeners.length === 0) { + this.progressListeners.delete(taskId); + } + } + } + + /** + * 通知进度更新 + */ + notifyProgressUpdate(taskId, task) { + if (this.progressListeners.has(taskId)) { + const listeners = this.progressListeners.get(taskId); + listeners.forEach(listener => { + try { + listener(task); + } catch (error) { + console.error('进度监听器执行失败:', error); + } + }); + } + } + + /** + * 获取指定任务的监听器数量 + */ + getListenerCount(taskId) { + return this.progressListeners.has(taskId) + ? this.progressListeners.get(taskId).length + : 0; + } + + /** + * 清理所有监听器 + */ + clearAllListeners() { + this.progressListeners.clear(); + } +} + +module.exports = ProgressManager; \ No newline at end of file diff --git a/backend/services/task-manager.js b/backend/services/task-manager.js new file mode 100644 index 0000000..7367af8 --- /dev/null +++ b/backend/services/task-manager.js @@ -0,0 +1,177 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +/** + * 任务管理器 - 负责下载任务的生命周期管理 + */ +class TaskManager { + constructor(dataPath) { + this.dataPath = dataPath; + this.tasksFile = path.join(dataPath, 'download_tasks.json'); + this.tasks = new Map(); // 内存中的任务状态 + this.initialized = false; + } + + /** + * 初始化任务管理器 + */ + async init() { + try { + await fs.ensureDir(this.dataPath); + await this.loadTasks(); + this.initialized = true; + console.log('任务管理器初始化完成'); + } catch (error) { + console.error('任务管理器初始化失败:', error); + this.initialized = false; + } + } + + /** + * 加载任务状态 + */ + async loadTasks() { + try { + if (await fs.pathExists(this.tasksFile)) { + const tasksData = await fs.readJson(this.tasksFile); + this.tasks = new Map(Object.entries(tasksData)); + + // 恢复进行中的任务状态 + for (const [taskId, task] of this.tasks) { + if (task.status === 'downloading' || task.status === 'paused') { + task.status = 'paused'; // 重启后暂停所有进行中的任务 + } + } + } + } catch (error) { + console.error('加载任务状态失败:', error); + this.tasks = new Map(); + } + } + + /** + * 保存任务状态 + */ + async saveTasks() { + try { + const tasksData = Object.fromEntries(this.tasks); + await fs.writeJson(this.tasksFile, tasksData, { spaces: 2 }); + } catch (error) { + console.error('保存任务状态失败:', error); + } + } + + /** + * 创建新任务 + */ + createTask(type, data) { + const taskId = uuidv4(); + const task = { + id: taskId, + type, + status: 'downloading', + progress: 0, + start_time: new Date(), + end_time: null, + error: null, + ...data + }; + + this.tasks.set(taskId, task); + return task; + } + + /** + * 获取任务 + */ + getTask(taskId) { + return this.tasks.get(taskId); + } + + /** + * 更新任务 + */ + async updateTask(taskId, updates) { + const task = this.tasks.get(taskId); + if (!task) { + return false; + } + + Object.assign(task, updates); + await this.saveTasks(); + return true; + } + + /** + * 删除任务 + */ + async deleteTask(taskId) { + const deleted = this.tasks.delete(taskId); + if (deleted) { + await this.saveTasks(); + } + return deleted; + } + + /** + * 获取所有任务 + */ + getAllTasks() { + return Array.from(this.tasks.values()); + } + + /** + * 获取指定状态的任务 + */ + getTasksByStatus(status) { + return Array.from(this.tasks.values()).filter(task => task.status === status); + } + + /** + * 清理已完成的任务 + */ + async cleanupCompletedTasks() { + const completedStatuses = ['completed', 'failed', 'cancelled', 'partial']; + let cleanedCount = 0; + + for (const [taskId, task] of this.tasks) { + if (completedStatuses.includes(task.status)) { + this.tasks.delete(taskId); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + await this.saveTasks(); + console.log(`清理了 ${cleanedCount} 个已完成的任务`); + } + + return cleanedCount; + } + + /** + * 获取任务统计信息 + */ + getTaskStats() { + const stats = { + total: this.tasks.size, + downloading: 0, + paused: 0, + completed: 0, + failed: 0, + cancelled: 0, + partial: 0 + }; + + for (const task of this.tasks.values()) { + if (stats.hasOwnProperty(task.status)) { + stats[task.status]++; + } + } + + return stats; + } +} + +module.exports = TaskManager; \ No newline at end of file diff --git a/scripts/create-portable.js b/scripts/create-portable.js index 7813a30..c3c9b4d 100644 --- a/scripts/create-portable.js +++ b/scripts/create-portable.js @@ -65,9 +65,9 @@ pause 2. 在浏览器中访问 http://localhost:3000 3. 按 Ctrl+C 停止服务器 -## 代理设置 +## 代理设置(重要) -如需使用代理,请编辑 \`start.bat\` 文件,修改第6行的端口号: +如需使用代理,请用记事本编辑 \`start.bat\` 文件,修改(PROXY_PORT=xxxx)的端口号: - Clash: 7890 - V2Ray: 10809 - Shadowsocks: 1080 @@ -76,6 +76,7 @@ pause - 首次运行可能需要几秒钟启动时间 - 程序会在当前目录创建数据文件夹 +- 没代理或者代理设置错误无法成功登录,注意仔细检查,获取code的时间比较短,记得快速操作 - 支持Windows 10/11 64位系统 `; diff --git a/ui/dist.zip b/ui/dist.zip index 99b9f17..d9f5bef 100644 Binary files a/ui/dist.zip and b/ui/dist.zip differ diff --git a/ui/src/components/download/DownloadProgress.vue b/ui/src/components/download/DownloadProgress.vue new file mode 100644 index 0000000..d5f8046 --- /dev/null +++ b/ui/src/components/download/DownloadProgress.vue @@ -0,0 +1,416 @@ + + + + + \ No newline at end of file diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index 85a3d61..269635b 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -10,7 +10,7 @@ class ApiService { constructor() { this.client = axios.create({ baseURL: API_BASE_URL, - timeout: 30000, + timeout: 60000, // 增加到60秒 headers: { 'Content-Type': 'application/json', }, diff --git a/ui/src/services/download.ts b/ui/src/services/download.ts index 1082509..9d0eed4 100644 --- a/ui/src/services/download.ts +++ b/ui/src/services/download.ts @@ -9,10 +9,60 @@ class DownloadService { size?: string; quality?: string; format?: string; + skipExisting?: boolean; } = {}) { return apiService.post(`/api/download/artwork/${artworkId}`, options); } + /** + * 获取任务进度 + */ + async getTaskProgress(taskId: string) { + return apiService.get(`/api/download/progress/${taskId}`); + } + + /** + * 获取所有任务 + */ + async getAllTasks() { + return apiService.get('/api/download/tasks'); + } + + /** + * 暂停任务 + */ + async pauseTask(taskId: string) { + return apiService.post(`/api/download/pause/${taskId}`); + } + + /** + * 恢复任务 + */ + async resumeTask(taskId: string) { + return apiService.post(`/api/download/resume/${taskId}`); + } + + /** + * 取消任务 + */ + async cancelTask(taskId: string) { + return apiService.delete(`/api/download/cancel/${taskId}`); + } + + /** + * 获取下载历史 + */ + async getDownloadHistory(offset = 0, limit = 50) { + return apiService.get('/api/download/history', { params: { offset, limit } }); + } + + /** + * 检查作品是否已下载 + */ + async checkArtworkDownloaded(artworkId: number) { + return apiService.get(`/api/download/check/${artworkId}`); + } + /** * 批量下载作品 */ @@ -42,56 +92,12 @@ class DownloadService { } /** - * 获取任务进度 - */ - async getTaskProgress(taskId: string) { - return apiService.get(`/api/download/progress/${taskId}`); - } - - /** - * 获取所有任务 - */ - async getAllTasks() { - return apiService.get('/api/download/tasks'); - } - - /** - * 取消下载任务 - */ - async cancelTask(taskId: string) { - return apiService.post(`/api/download/cancel/${taskId}`); - } - - /** - * 获取下载历史 - */ - async getDownloadHistory(limit: number = 50, offset: number = 0) { - return apiService.get('/api/download/history', { - params: { limit, offset } - }); - } - - /** - * 获取下载的文件列表 + * 获取已下载的文件列表 */ async getDownloadedFiles() { return apiService.get('/api/download/files'); } - /** - * 检查作品是否已下载 - */ - async checkArtworkDownloaded(artworkId: number) { - return apiService.get(`/api/download/check/${artworkId}`); - } - - /** - * 获取已下载的作品ID列表 - */ - async getDownloadedArtworkIds() { - return apiService.get('/api/download/downloaded-ids'); - } - /** * 删除下载的文件 */ @@ -100,6 +106,47 @@ class DownloadService { data: { artist, artwork } }); } + + /** + * 获取已下载的作品ID列表 + */ + async getDownloadedArtworkIds() { + return apiService.get('/api/download/downloaded-ids'); + } + + /** + * 使用SSE监听下载进度 + */ + streamTaskProgress(taskId: string, onProgress: (task: DownloadTask) => void, onComplete?: () => void) { + const eventSource = new EventSource(`http://localhost:3000/api/download/stream/${taskId}`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'progress') { + onProgress(data.data); + } else if (data.type === 'complete') { + onProgress(data.data); + if (onComplete) { + onComplete(); + } + eventSource.close(); + } + } catch (error) { + console.error('解析SSE数据失败:', error); + } + }; + + eventSource.onerror = (error) => { + console.error('SSE连接错误:', error); + eventSource.close(); + }; + + // 返回关闭函数 + return () => { + eventSource.close(); + }; + } } export default new DownloadService(); \ No newline at end of file diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 815e22c..ac2257b 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -98,22 +98,17 @@ export interface LoginStatus { export interface DownloadTask { id: string; type: 'artwork' | 'batch' | 'artist'; - status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled'; + status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled' | 'paused'; progress: number; - total: number; - completed: number; - failed: number; + total_files: number; + completed_files: number; + failed_files: number; + artwork_id?: number; + artist_name?: string; + artwork_title?: string; start_time: string; end_time?: string; error?: string; - artwork_id?: number; - artist_id?: number; - files?: Array<{ - path: string; - url: string; - size: string; - filename: string; - }>; results?: any[]; } diff --git a/ui/src/views/ArtworkView.vue b/ui/src/views/ArtworkView.vue index 5745b41..34c1e03 100644 --- a/ui/src/views/ArtworkView.vue +++ b/ui/src/views/ArtworkView.vue @@ -55,8 +55,12 @@ {{ artwork.is_bookmarked ? '取消收藏' : '收藏' }} + + + +
-
+
@@ -64,6 +68,15 @@ 已下载到本地
+ + +
@@ -182,9 +195,10 @@ import artworkService from '@/services/artwork'; import artistService from '@/services/artist'; import downloadService from '@/services/download'; import { useRepositoryStore } from '@/stores/repository'; -import type { Artwork } from '@/types'; +import type { Artwork, DownloadTask } from '@/types'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import ErrorMessage from '@/components/common/ErrorMessage.vue'; +import DownloadProgress from '@/components/download/DownloadProgress.vue'; const route = useRoute(); const router = useRouter(); @@ -202,6 +216,10 @@ const downloading = ref(false); const isDownloaded = ref(false); const checkingDownloadStatus = ref(false); +// 下载任务状态 +const currentTask = ref(null); +const sseConnection = ref<(() => void) | null>(null); + // 导航相关状态 const artistArtworks = ref([]); const currentArtworkIndex = ref(-1); @@ -255,6 +273,10 @@ const fetchArtworkDetail = async () => { imageError.value = false; currentPage.value = 0; + // 清理之前的任务状态 + currentTask.value = null; + stopTaskStreaming(); + const response = await artworkService.getArtworkDetail(artworkId); if (response.success && response.data) { @@ -299,15 +321,40 @@ const handleDownload = async () => { try { downloading.value = true; - const response = await downloadService.downloadArtwork(artwork.value.id); + // 如果已经下载过,则强制重新下载(跳过现有文件检查) + const skipExisting = !isDownloaded.value; + const response = await downloadService.downloadArtwork(artwork.value.id, { + skipExisting + }); if (response.success) { - // 可以显示下载成功提示 - console.log('下载任务已创建:', response.data); - // 下载完成后重新检查下载状态 - setTimeout(() => { - checkDownloadStatus(artwork.value!.id); - }, 2000); // 等待2秒让下载完成 + console.log('下载响应:', response.data); + + // 检查是否跳过下载 + if (response.data.skipped) { + console.log('作品已存在,跳过下载'); + // 重新检查下载状态 + await checkDownloadStatus(artwork.value.id); + return; + } + + // 如果是新任务,开始监听进度 + if (response.data.task_id) { + currentTask.value = { + id: response.data.task_id, + type: 'artwork', + status: 'downloading', + progress: 0, + total_files: 0, + completed_files: 0, + failed_files: 0, + artwork_id: artwork.value.id, + start_time: new Date().toISOString() + }; + + // 开始SSE监听任务进度 + startTaskStreaming(response.data.task_id); + } } else { throw new Error(response.error || '下载失败'); } @@ -319,6 +366,72 @@ const handleDownload = async () => { } }; +// 开始SSE监听任务进度 +const startTaskStreaming = (taskId: string) => { + // 清除之前的连接 + if (sseConnection.value) { + sseConnection.value(); + } + + console.log('开始SSE监听任务进度:', taskId); + + // 建立SSE连接 + sseConnection.value = downloadService.streamTaskProgress( + taskId, + (task) => { + console.log('收到SSE进度更新:', { + taskId, + status: task.status, + progress: task.progress, + completed: task.completed_files, + total: task.total_files + }); + + currentTask.value = task; + + // 如果任务完成,清理连接并检查下载状态 + if (['completed', 'failed', 'cancelled', 'partial'].includes(task.status)) { + console.log('任务完成,关闭SSE连接'); + stopTaskStreaming(); + + // 延迟检查下载状态,确保文件写入完成 + setTimeout(async () => { + await checkDownloadStatus(artwork.value!.id); + // 清理任务状态,显示下载完成状态 + currentTask.value = null; + }, 1000); + } + }, + () => { + console.log('SSE连接完成'); + stopTaskStreaming(); + } + ); +}; + + + +// 停止SSE监听 +const stopTaskStreaming = () => { + if (sseConnection.value) { + sseConnection.value(); + sseConnection.value = null; + } +}; + +// 更新任务状态 +const updateTask = (task: DownloadTask) => { + currentTask.value = task; +}; + +// 移除任务 +const removeTask = (taskId: string) => { + if (currentTask.value?.id === taskId) { + currentTask.value = null; + stopTaskStreaming(); + } +}; + // 收藏/取消收藏 const handleBookmark = () => { // 这里可以添加收藏功能 @@ -423,6 +536,10 @@ const goBackToArtist = () => { // 监听路由变化,重新获取作品详情和导航数据 watch(() => route.params.id, () => { + // 清理之前的任务状态 + currentTask.value = null; + stopTaskStreaming(); + // 重新获取作品详情 fetchArtworkDetail(); @@ -458,9 +575,10 @@ onMounted(() => { document.addEventListener('keydown', handleKeydown); }); -// 组件卸载时移除事件监听 +// 组件卸载时移除事件监听和清理SSE连接 onUnmounted(() => { document.removeEventListener('keydown', handleKeydown); + stopTaskStreaming(); }); @@ -578,6 +696,10 @@ onUnmounted(() => { } .artwork-header { + margin-bottom: 1.5rem; +} + +.download-section { margin-bottom: 2rem; } @@ -649,7 +771,7 @@ onUnmounted(() => { padding: 1.5rem; background: #f8fafc; border-radius: 0.75rem; - margin-bottom: 2rem; + margin-bottom: 1.5rem; } .artist-avatar { @@ -814,11 +936,10 @@ onUnmounted(() => { } .download-status { - margin-top: 1rem; - padding: 0.75rem 1rem; + padding: 1rem 1.25rem; background: #f0f9ff; border: 1px solid #bae6fd; - border-radius: 0.5rem; + border-radius: 0.75rem; display: flex; align-items: center; gap: 0.5rem; diff --git a/ui/src/views/DownloadsView.vue b/ui/src/views/DownloadsView.vue index 7c41602..5f1d6c2 100644 --- a/ui/src/views/DownloadsView.vue +++ b/ui/src/views/DownloadsView.vue @@ -92,7 +92,7 @@ >
- {{ task.completed }}/{{ task.total }} ({{ task.progress }}%) + {{ task.completed_files }}/{{ task.total_files }} ({{ task.progress }}%)
@@ -271,9 +271,9 @@ const getTaskTitle = (task: DownloadTask) => { if (task.type === 'artwork') { return `作品 ${task.artwork_id}`; } else if (task.type === 'artist') { - return `作者 ${task.artist_id} 的作品`; + return `作者作品`; } else if (task.type === 'batch') { - return `批量下载 ${task.total} 个作品`; + return `批量下载 ${task.total_files} 个作品`; } return '未知任务'; }; @@ -521,6 +521,7 @@ onUnmounted(() => { border-radius: 0.5rem; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); overflow: hidden; + margin-top: 1rem; } .loading-section, @@ -559,17 +560,26 @@ onUnmounted(() => { .tasks-list, .history-list, .files-list { - padding: 1rem; + padding: 1.5rem; } .task-card, .history-card, .file-card { border: 1px solid #e5e7eb; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 1.5rem; background: white; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; +} + +.task-card:hover, +.history-card:hover, +.file-card:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border-color: #d1d5db; } .task-header,