'use strict'; const http = require('node:http'); const https = require('node:https'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); const path = require('node:path'); const { URL } = require('node:url'); const { safeName, extFromContentType } = require('./http'); const FILES_DIR = process.env.BANGUMI_VAULT_FILES_DIR || '/data/files'; const COVERS_DIR = path.join(FILES_DIR, 'covers'); const LOGS_DIR = path.join(FILES_DIR, 'logs'); function ensureDirs() { fs.mkdirSync(COVERS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true }); } function downloadBuffer(remoteUrl) { return new Promise((resolve, reject) => { let parsed; try { parsed = new URL(remoteUrl); } catch { reject(new Error('invalid url')); return; } if (!['http:', 'https:'].includes(parsed.protocol)) { reject(new Error('unsupported url protocol')); return; } const client = parsed.protocol === 'https:' ? https : http; const req = client.get(parsed, { headers: { 'User-Agent': 'BangumiVaultDocker/1.0', 'Referer': 'https://bgm.tv/' }, timeout: 30000 }, res => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); downloadBuffer(new URL(res.headers.location, parsed).toString()).then(resolve, reject); return; } if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); reject(new Error(`HTTP ${res.statusCode}`)); return; } const chunks = []; let size = 0; res.on('data', chunk => { size += chunk.length; if (size > 25 * 1024 * 1024) { req.destroy(new Error('image too large')); return; } chunks.push(chunk); }); res.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || '' })); }); req.on('timeout', () => req.destroy(new Error('download timeout'))); req.on('error', reject); }); } // Rewrite an online image URL through the configured proxy/mirror (empty = no change). function applyImageProxy(url, proxy) { proxy = (proxy || '').trim(); if (!proxy || !url || !/^https?:\/\//i.test(url)) return url; return proxy.includes('{url}') ? proxy.replace('{url}', encodeURIComponent(url)) : proxy + url; } // Download one cover and write it to disk. Returns { url, file } paths used by the app. async function cacheCover(subjectId, remoteUrl, imageProxy) { const sid = safeName(subjectId, 'subject'); const downloaded = await downloadBuffer(applyImageProxy(remoteUrl, imageProxy)); const file = `${sid}${extFromContentType(downloaded.contentType, remoteUrl)}`; await fsp.writeFile(path.join(COVERS_DIR, file), downloaded.buffer); return { url: `/images/${file}`, file: `covers/${file}` }; } function onlineCoverUrl(c) { const imgs = c.images || {}; return c.image || imgs.large || imgs.common || imgs.medium || imgs.grid || imgs.small || ''; } // Cache covers for every collection that doesn't already have a local copy. // Mutates each row in-place (cover_local_url / cover_local_file / cover_cached_at). async function cacheCoversForState(collections, onProgress, imageProxy) { const rows = Object.values(collections || {}).filter(c => c.status !== 'confirmed_deleted' && !c.cover_local_url); let ok = 0, fail = 0; for (let i = 0; i < rows.length; i++) { const c = rows[i]; const url = onlineCoverUrl(c); if (!url) { fail++; continue; } try { const r = await cacheCover(c.subject_id, url, imageProxy); c.cover_local_url = r.url; c.cover_local_file = r.file; c.cover_cached_at = new Date().toISOString(); ok++; } catch { fail++; } if (onProgress) onProgress(i + 1, rows.length, ok, fail); await new Promise(r => setTimeout(r, 35)); } return { ok, fail, total: rows.length }; } module.exports = { FILES_DIR, COVERS_DIR, LOGS_DIR, ensureDirs, downloadBuffer, cacheCover, cacheCoversForState };