UNPKG

mira-app-server

Version:

Mira Server - standalone server application using mira-app-core

216 lines 9.79 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DownloadExecutorService = void 0; const axios_1 = __importDefault(require("axios")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const promises_1 = require("stream/promises"); const crypto_1 = require("crypto"); const p_queue_1 = __importDefault(require("p-queue")); /** * 下载执行器:消费用户已配置的 cookie 站点,把一批图片 URL 下载并入库到指定素材库。 * * 流程(每个 URL): * 1. 按 host 匹配该用户的 cookie 组(is_default 优先,否则同 url 第一组),拼 Cookie header * 2. axios stream 下载到 backend.dataPath/temp * 3. libraryService.createFileFromPath(tmp, { uploader, folder_id }, { importType:'move' }) * —— 自动 hash 去重 + 文件搬运 + 触发 file::created(生成缩略图) * 4. WebSocket 推送 download::progress / download::item 到对应 library * * 并发用 p-queue(同 MetadataService 模式);进度存内存 Map,重启清空。 */ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; class DownloadExecutorService { constructor(backend) { this.queue = new p_queue_1.default({ concurrency: 3 }); this.progress = new Map(); this.backend = backend; } /** 拿 UserStorage(通过 httpServer.authRouter) */ getUserStorage() { return this.backend.httpServer.authRouter.getAuthService().getUserStorage(); } /** 入队一批下载任务,返回 batchId */ async enqueueBatch(tasks) { const batchId = (0, crypto_1.randomUUID)(); const progress = { batchId, total: tasks.length, completed: 0, failed: 0, skipped: 0, done: false, }; this.progress.set(batchId, progress); console.log(`📥 [download] batch ${batchId} 入队,共 ${tasks.length} 个任务`); for (const task of tasks) { void this.queue.add(() => this.runOne(batchId, task, progress)); } return batchId; } getProgress(batchId) { return this.progress.get(batchId) ?? null; } async runOne(batchId, task, progress) { const { url, libraryId, userId, folderId, clientId } = task; const ws = this.backend.webSocketServer; const t0 = Date.now(); try { // 校验 library const libObj = this.backend.libraries?.getLibrary(libraryId); if (!libObj?.libraryService) throw new Error('素材库不可用'); // 1. 匹配 cookie const cookieHeader = await this.matchCookieHeader(userId, url); // 2. 下载到 temp const tempDir = path.join(this.backend.dataPath, 'temp'); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); const basename = this.guessFilename(url); const tmpPath = path.join(tempDir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${basename}`); console.log(`⬇️ [download] batch ${batchId} 开始: ${url}${cookieHeader ? ' 【携带cookie】' : ''}`); const resp = await axios_1.default.get(url, { responseType: 'stream', headers: { 'User-Agent': USER_AGENT, ...(cookieHeader ? { Cookie: cookieHeader } : {}), }, timeout: 60000, maxRedirects: 5, }); await (0, promises_1.pipeline)(resp.data, fs.createWriteStream(tmpPath)); // 3. 入库(move 自动清理 temp + hash 去重) const result = await libObj.libraryService.createFileFromPath(tmpPath, { uploader: userId, folder_id: folderId ?? null }, { importType: 'move' }); const isDup = result?.duplicate === true; console.log(`✅ [download] batch ${batchId} ${isDup ? '重复跳过' : '完成'} (${Date.now() - t0}ms): ${url} → fileId=${result?.id}`); if (!isDup && result?.id != null) { // 补写来源 URL 到 custom_fields,便于追溯(与插件窗口 addFromUrl 模式一致) try { await libObj.libraryService.updateFile(result.id, { custom_fields: { ...(result.custom_fields || {}), source_url: url }, }); } catch { /* 元数据写入失败不影响下载结果 */ } const eventData = { ...result, libraryId, batchImport: true, batchId, }; ws?.broadcastPluginEvent('file::created', { message: { type: 'file', action: 'create' }, result: eventData, libraryId, }); ws?.broadcastLibraryEvent(libraryId, 'file::created', eventData); } if (isDup) progress.skipped++; else progress.completed++; // 4. 推送 ws?.broadcastLibraryEvent(libraryId, 'download::item', { batchId, url, status: isDup ? 'duplicate' : 'success', file: result, libraryId, }); if (clientId) { const wsClient = ws?.getWsClientById(libraryId, clientId); wsClient && ws?.sendToWebsocket(wsClient, { eventName: 'download::item', data: { batchId, url, status: isDup ? 'duplicate' : 'success', libraryId }, }); } } catch (e) { progress.failed++; console.log(`❌ [download] batch ${batchId} 失败 (${Date.now() - t0}ms): ${url}${e?.message || e}`); ws?.broadcastLibraryEvent(libraryId, 'download::item', { batchId, url, status: 'failed', error: e?.message || String(e), libraryId, }); } finally { // 更新整体进度并推送 progress.done = progress.completed + progress.failed + progress.skipped >= progress.total; if (progress.done) { console.log(`📦 [download] batch ${batchId} 全部完成: 成功 ${progress.completed} / 失败 ${progress.failed} / 重复 ${progress.skipped}`); } ws?.broadcastLibraryEvent(libraryId, 'download::progress', { ...progress, libraryId }); if (clientId) { const wsClient = ws?.getWsClientById(libraryId, clientId); wsClient && ws?.sendToWebsocket(wsClient, { eventName: 'download::progress', data: { ...progress, libraryId }, }); } } } /** 按 host 匹配该用户的 cookie 组:is_default 优先,否则该 url 第一组。返回 "a=b; c=d" 或空串 */ async matchCookieHeader(userId, url) { try { const host = new URL(url).host; const sites = await this.getUserStorage().listCookieSites(userId); const sameHost = sites.filter((s) => { try { return new URL(s.url).host === host; } catch { return false; } }); const chosen = sameHost.find((s) => s.isDefault) || sameHost[0]; const cookies = chosen?.cookies || []; return cookies.map((c) => `${c.name}=${c.value}`).filter(Boolean).join('; '); } catch { return ''; } } /** 从 URL 猜测文件名,失败则用占位名 */ guessFilename(url) { try { const u = new URL(url); const last = u.pathname.split('/').filter(Boolean).pop(); if (last && /\.[A-Za-z0-9]{2,5}$/.test(last)) return last.slice(0, 120); } catch { /* ignore */ } return `mira-${Date.now()}.bin`; } } exports.DownloadExecutorService = DownloadExecutorService; //# sourceMappingURL=DownloadExecutorService.js.map