This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
// HMAC key for signing session cookies. Persisted in the DB (settings.session_secret) and injected
|
||||
// via setSessionSecret() at startup, so sessions survive restarts with zero config. The random
|
||||
// fallback only covers the brief window before init (and is replaced once the DB value loads).
|
||||
let sessionSecret = crypto.randomBytes(32).toString('hex');
|
||||
function setSessionSecret(secret) { if (secret) sessionSecret = String(secret); }
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
const hash = crypto.pbkdf2Sync(String(password), salt, 210000, 32, 'sha256').toString('hex');
|
||||
return `pbkdf2_sha256$210000$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password, stored) {
|
||||
const parts = String(stored || '').split('$');
|
||||
if (parts.length !== 4 || parts[0] !== 'pbkdf2_sha256') return false;
|
||||
const [, roundsText, salt, expected] = parts;
|
||||
const actual = crypto.pbkdf2Sync(String(password), salt, Number(roundsText), 32, 'sha256').toString('hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex'));
|
||||
}
|
||||
|
||||
function sign(value) {
|
||||
return crypto.createHmac('sha256', sessionSecret).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
function createSessionCookie() {
|
||||
const payload = Buffer.from(JSON.stringify({ iat: Date.now(), exp: Date.now() + SESSION_TTL_MS })).toString('base64url');
|
||||
return `${payload}.${sign(payload)}`;
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
for (const part of String(req.headers.cookie || '').split(';')) {
|
||||
const index = part.indexOf('=');
|
||||
if (index > -1) out[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
const token = parseCookies(req).bv_session;
|
||||
if (!token || !token.includes('.')) return false;
|
||||
const [payload, sig] = token.split('.');
|
||||
if (!sig || sign(payload) !== sig) return false;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
return Number(data.exp || 0) > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SESSION_TTL_MS, setSessionSecret,
|
||||
hashPassword, verifyPassword, sign, createSessionCookie, parseCookies, isAuthed
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.bgm.tv';
|
||||
let apiBase = DEFAULT_API_BASE;
|
||||
// Set by runSync from settings.bgmApiBase (sync is locked/serial, so a module-level value is safe).
|
||||
function setApiBase(base) { apiBase = (typeof base === 'string' && base.trim()) ? base.trim().replace(/\/+$/, '') : DEFAULT_API_BASE; }
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function bgmFetch(path, token) {
|
||||
const res = await fetch(apiBase + path, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'BangumiVaultDocker/1.0',
|
||||
...(token ? { 'Authorization': 'Bearer ' + String(token).trim() } : {})
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`HTTP ${res.status}: ${String(t).slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchMe(token) {
|
||||
return bgmFetch('/v0/me', token);
|
||||
}
|
||||
|
||||
async function fetchCollectionAll(username, st, ct, token, onPage) {
|
||||
const limit = 50; let offset = 0; let total = Infinity; let page = 0; const out = [];
|
||||
while (offset < total) {
|
||||
const path = `/v0/users/${encodeURIComponent(username)}/collections?subject_type=${st}&type=${ct}&limit=${limit}&offset=${offset}`;
|
||||
const js = await bgmFetch(path, token); const data = Array.isArray(js) ? js : (js.data || []);
|
||||
total = Number.isFinite(js.total) ? js.total : (offset + data.length + (data.length === limit ? limit : 0));
|
||||
out.push(...data.map(x => ({ ...x, __fallback: { subject_type: st, collection_type: ct } }))); page++;
|
||||
if (onPage) onPage(page, out.length);
|
||||
if (!data.length || data.length < limit) break;
|
||||
offset += limit; await sleep(110);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchCollectionRecent(username, st, ct, token, cutoff, onPage) {
|
||||
const limit = 50; let offset = 0; let total = Infinity; let page = 0; const out = []; const cutoffMs = cutoff.getTime();
|
||||
while (offset < total) {
|
||||
const path = `/v0/users/${encodeURIComponent(username)}/collections?subject_type=${st}&type=${ct}&limit=${limit}&offset=${offset}`;
|
||||
const js = await bgmFetch(path, token); const data = Array.isArray(js) ? js : (js.data || []);
|
||||
total = Number.isFinite(js.total) ? js.total : (offset + data.length + (data.length === limit ? limit : 0));
|
||||
const decorated = data.map(x => ({ ...x, __fallback: { subject_type: st, collection_type: ct } }));
|
||||
const recent = decorated.filter(x => { const t = Date.parse(x.updated_at || x.updatedAt || ''); return !Number.isFinite(t) || t >= cutoffMs; });
|
||||
out.push(...recent); page++;
|
||||
const stopByTime = data.length > 0 && recent.length === 0 && data.every(x => { const t = Date.parse(x.updated_at || x.updatedAt || ''); return Number.isFinite(t) && t < cutoffMs; });
|
||||
if (onPage) onPage(page, out.length, stopByTime);
|
||||
if (!data.length || data.length < limit || stopByTime) break;
|
||||
offset += limit; await sleep(80);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchSubjectDetail(subjectId, token) {
|
||||
try {
|
||||
return await bgmFetch(`/v0/subjects/${encodeURIComponent(subjectId)}`, token || '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_API_BASE, setApiBase, sleep, bgmFetch, fetchMe, fetchCollectionAll, fetchCollectionRecent, fetchSubjectDetail };
|
||||
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
// Subject types in the order the sync iterates them.
|
||||
const SUBJECT_TYPES = [2, 1, 4, 3, 6];
|
||||
// Collection types: 1 想看 / 2 看过 / 3 在看 / 4 搁置 / 5 抛弃.
|
||||
const COLLECTION_TYPES = [1, 2, 3, 4, 5];
|
||||
|
||||
const STATUS = { 0: '全部', 1: '想看', 2: '看过', 3: '在看', 4: '搁置', 5: '抛弃', 99: '安全箱' };
|
||||
const SUBJECT_LABEL = { 1: '书籍', 2: '动画', 3: '音乐', 4: '游戏', 6: '三次元' };
|
||||
|
||||
module.exports = { SUBJECT_TYPES, COLLECTION_TYPES, STATUS, SUBJECT_LABEL };
|
||||
@@ -0,0 +1,111 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const MAX_BODY_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
function isCompressible(contentType) {
|
||||
const t = String(contentType || '');
|
||||
return /^text\//.test(t) || /application\/(json|javascript)/.test(t);
|
||||
}
|
||||
|
||||
function send(res, status, body = '', contentType = 'text/plain; charset=utf-8', headers = {}) {
|
||||
let bytes = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
||||
const outHeaders = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-store',
|
||||
...headers
|
||||
};
|
||||
// Gzip text/JSON responses when the client accepts it (single-user app: sync gzip is fine).
|
||||
if (res._acceptGzip && isCompressible(contentType) && bytes.length > 1024) {
|
||||
bytes = zlib.gzipSync(bytes);
|
||||
outHeaders['Content-Encoding'] = 'gzip';
|
||||
outHeaders['Vary'] = 'Accept-Encoding';
|
||||
}
|
||||
outHeaders['Content-Length'] = bytes.length;
|
||||
res.writeHead(status, outHeaders);
|
||||
res.end(bytes);
|
||||
}
|
||||
|
||||
function json(res, status, data, headers = {}) {
|
||||
send(res, status, JSON.stringify(data), 'application/json; charset=utf-8', headers);
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw new Error('request body too large');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const body = await readBody(req);
|
||||
if (!body.length) return {};
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
}
|
||||
|
||||
function safeName(name, fallback = 'file') {
|
||||
return String(name || fallback)
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180) || fallback;
|
||||
}
|
||||
|
||||
function mimeByExt(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (ext === '.html') return 'text/html; charset=utf-8';
|
||||
if (ext === '.js' || ext === '.mjs') return 'text/javascript; charset=utf-8';
|
||||
if (ext === '.css') return 'text/css; charset=utf-8';
|
||||
if (ext === '.json') return 'application/json; charset=utf-8';
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.svg') return 'image/svg+xml';
|
||||
if (ext === '.woff2') return 'font/woff2';
|
||||
if (ext === '.woff') return 'font/woff';
|
||||
if (ext === '.ttf') return 'font/ttf';
|
||||
if (ext === '.otf') return 'font/otf';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function extFromContentType(type, sourceUrl) {
|
||||
const contentType = String(type || '').toLowerCase();
|
||||
if (contentType.includes('png')) return '.png';
|
||||
if (contentType.includes('webp')) return '.webp';
|
||||
if (contentType.includes('gif')) return '.gif';
|
||||
if (contentType.includes('jpeg') || contentType.includes('jpg')) return '.jpg';
|
||||
try {
|
||||
return path.extname(new URL(sourceUrl).pathname) || '.jpg';
|
||||
} catch {
|
||||
return '.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { send, json, readBody, readJson, safeName, mimeByExt, extFromContentType, MAX_BODY_BYTES };
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { createStore } = require('./store');
|
||||
const { createHandler } = require('./routes');
|
||||
const { ensureDirs, FILES_DIR } = require('./covers');
|
||||
const auth = require('./auth');
|
||||
const sync = require('./sync');
|
||||
const { json } = require('./http');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
|
||||
async function ensureAdminPassword(store) {
|
||||
if (await store.getSetting('admin_password_hash')) return;
|
||||
const initial = crypto.randomBytes(18).toString('base64url');
|
||||
await store.setSetting('admin_password_hash', auth.hashPassword(initial));
|
||||
// Printed in reverse order so it reads top-to-bottom in newest-first log viewers.
|
||||
console.log('');
|
||||
console.log('============================================================');
|
||||
console.log('This password is printed only once for this database volume.');
|
||||
console.log('Open the web UI, log in with this password, then change it in Settings.');
|
||||
console.log(initial);
|
||||
console.log('Bangumi Vault initial admin password:');
|
||||
console.log('============================================================');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Session cookie HMAC secret persisted in the DB → survives restarts/rebuilds with no env var or re-login.
|
||||
async function ensureSessionSecret(store) {
|
||||
let secret = await store.getSetting('session_secret');
|
||||
if (!secret) {
|
||||
secret = crypto.randomBytes(32).toString('hex');
|
||||
await store.setSetting('session_secret', secret);
|
||||
}
|
||||
auth.setSessionSecret(secret);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureDirs();
|
||||
const store = await createStore(process.env.DATABASE_URL);
|
||||
await ensureAdminPassword(store);
|
||||
await ensureSessionSecret(store);
|
||||
|
||||
const handleRequest = createHandler(store);
|
||||
const server = http.createServer((req, res) => {
|
||||
handleRequest(req, res).catch(err => {
|
||||
console.error(err);
|
||||
json(res, 500, { ok: false, error: err.message || String(err) });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Bangumi Vault web server listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Database: ${store.kind}`);
|
||||
console.log(`Files directory: ${FILES_DIR}`);
|
||||
});
|
||||
|
||||
// Background scheduler: checks once a minute whether an auto-sync is due.
|
||||
setInterval(() => {
|
||||
sync.checkSchedule(store).catch(err => console.error('schedule check failed:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const { STATUS } = require('./constants');
|
||||
|
||||
function nowISO() { return new Date().toISOString(); }
|
||||
|
||||
function uniqStrings(arr) {
|
||||
const seen = new Set(); const out = [];
|
||||
for (const v of arr || []) {
|
||||
const name = String(v ?? '').trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name); out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (!tags) return [];
|
||||
if (Array.isArray(tags)) return uniqStrings(tags.map(t => typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || '')));
|
||||
if (typeof tags === 'string') return uniqStrings(tags.split(/[,,\s]+/));
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizePublicTags(tags) {
|
||||
if (!tags) return [];
|
||||
const list = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(/[,,\s]+/) : []);
|
||||
const out = []; const seen = new Set();
|
||||
for (const t of list) {
|
||||
const name = String(typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || '')).trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
const count = typeof t === 'object' && t ? Number(t.count ?? t.total_cont ?? t.total ?? t.num ?? 0) || 0 : 0;
|
||||
out.push(count ? { name, count } : { name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function myTagsOf(c) { return normalizeTags(c?.myTags || c?.my_tags || c?.tags || []); }
|
||||
function publicTagsOf(c) { return normalizePublicTags(c?.publicTags || c?.public_tags || c?.subjectTags || c?.raw?.subject?.tags || []); }
|
||||
|
||||
function normalizeCollection(item, fallbackType) {
|
||||
const s = item.subject || {};
|
||||
const imgs = s.images || {};
|
||||
const st = item.subject_type || s.type || fallbackType?.subject_type || 0;
|
||||
const ct = item.type || fallbackType?.collection_type || 0;
|
||||
const title = s.name_cn || s.name || `Subject ${item.subject_id || s.id}`;
|
||||
const raw = item;
|
||||
return {
|
||||
subject_id: Number(item.subject_id || s.id), subject_type: Number(st), collection_type: Number(ct),
|
||||
title, name: s.name || '', name_cn: s.name_cn || '', date: s.date || '', eps: s.eps ?? '', volumes: s.volumes ?? '',
|
||||
bgm_score: s.score ?? s.rating?.score ?? '', rank: s.rank ?? '',
|
||||
summary: s.summary || s.short_summary || '', image: imgs.large || imgs.common || imgs.medium || imgs.grid || imgs.small || '', images: imgs,
|
||||
rate: item.rate ?? 0, comment: item.comment || '', tags: normalizeTags(item.tags), myTags: normalizeTags(item.tags), publicTags: normalizePublicTags(s.tags || item.publicTags || item.public_tags), ep_status: item.ep_status ?? '', vol_status: item.vol_status ?? '',
|
||||
private: !!item.private,
|
||||
collection_created_at: item.created_at || item.createdAt || item.collect_created_at || '',
|
||||
bangumi_updated_at: item.updated_at || item.updatedAt || '', raw
|
||||
};
|
||||
}
|
||||
|
||||
function applySubjectDetail(n, detail) {
|
||||
if (!detail) return n;
|
||||
return {
|
||||
...n,
|
||||
platform: detail.platform || '',
|
||||
nsfw: !!detail.nsfw,
|
||||
infobox: Array.isArray(detail.infobox) ? detail.infobox : [],
|
||||
meta_tags: Array.isArray(detail.meta_tags) ? detail.meta_tags : [],
|
||||
series: !!detail.series,
|
||||
locked: !!detail.locked,
|
||||
rating_detail: detail.rating || {},
|
||||
collection_stats: detail.collection || {},
|
||||
total_episodes: detail.total_episodes ?? n.eps ?? '',
|
||||
publicTags: detail.tags ? normalizePublicTags(detail.tags) : n.publicTags,
|
||||
subject_raw: detail,
|
||||
subject_fetched_at: nowISO(),
|
||||
};
|
||||
}
|
||||
|
||||
function stableStringify(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
||||
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
||||
return '{' + Object.keys(obj).sort().map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
|
||||
}
|
||||
|
||||
function hashString(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16); }
|
||||
|
||||
function comparable(n) {
|
||||
return {
|
||||
subject_id: n.subject_id, subject_type: n.subject_type, collection_type: n.collection_type, title: n.title, name: n.name, name_cn: n.name_cn,
|
||||
date: n.date, eps: n.eps, volumes: n.volumes, bgm_score: n.bgm_score, rank: n.rank, summary: n.summary, image: n.image, rate: n.rate, comment: n.comment,
|
||||
tags: myTagsOf(n), publicTags: publicTagsOf(n), ep_status: n.ep_status, vol_status: n.vol_status, private: n.private,
|
||||
collection_created_at: n.collection_created_at, bangumi_updated_at: n.bangumi_updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(n) { return { ...n, content_hash: hashString(stableStringify(comparable(n))) }; }
|
||||
|
||||
// --- change-detection for history entries (server-side simplified formatting) ---
|
||||
function fmtDate(s) { if (!s) return ''; const d = new Date(s); return isNaN(d) ? String(s) : d.toLocaleString(); }
|
||||
function fmtScore(v) { const n = Number(v); if (!n) return ''; return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ''); }
|
||||
function shortValue(v, max = 80) { const t = Array.isArray(v) ? v.join(' / ') : String(v ?? ''); return t.length > max ? t.slice(0, max) + '…' : t; }
|
||||
function valuesSame(a, b) { return stableStringify(a ?? '') === stableStringify(b ?? ''); }
|
||||
|
||||
function collectionFieldDiff(oldC, newC) {
|
||||
const fields = [
|
||||
['collection_type', '收藏状态', v => STATUS[v] || v || '-'],
|
||||
['rate', '我的评分', v => v || '未评分'],
|
||||
['ep_status', '章节进度', v => v || '-'],
|
||||
['vol_status', '卷进度', v => v || '-'],
|
||||
['comment', '吐槽/观后感', v => shortValue(v || '无吐槽', 120)],
|
||||
['private', '私密状态', v => v ? '私密' : '公开'],
|
||||
['tags', '我的标签', v => shortValue(normalizeTags(v), 120)],
|
||||
['publicTags', '公共标签', v => shortValue(normalizePublicTags(v).map(x => x.count ? `${x.name}(${x.count})` : x.name), 120)],
|
||||
['bangumi_updated_at', 'API 更新时间', v => fmtDate(v) || '-'],
|
||||
['title', '标题', v => v || '-'],
|
||||
['date', '上映日期', v => v || '-'],
|
||||
['bgm_score', 'Bangumi 均分', v => fmtScore(v) || '-'],
|
||||
['rank', '排名', v => v || '-']
|
||||
];
|
||||
const changes = [];
|
||||
for (const [key, label, fmt] of fields) {
|
||||
const oldVal = key === 'tags' ? myTagsOf(oldC) : (key === 'publicTags' ? publicTagsOf(oldC) : oldC?.[key]);
|
||||
const newVal = key === 'tags' ? myTagsOf(newC) : (key === 'publicTags' ? publicTagsOf(newC) : newC?.[key]);
|
||||
if (!valuesSame(oldVal, newVal)) changes.push({ field: key, label, from: fmt(oldVal), to: fmt(newVal), old: oldVal ?? '', new: newVal ?? '' });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nowISO, uniqStrings, normalizeTags, normalizePublicTags, myTagsOf, publicTagsOf,
|
||||
normalizeCollection, applySubjectDetail, stableStringify, hashString, comparable, makeSnapshot, collectionFieldDiff
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
'use strict';
|
||||
|
||||
const fsp = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const { send, json, readJson, safeName, mimeByExt } = require('./http');
|
||||
const auth = require('./auth');
|
||||
const covers = require('./covers');
|
||||
const sync = require('./sync');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const APP_DIR = path.join(ROOT, 'app');
|
||||
const APP_HTML = path.join(APP_DIR, 'BangumiVault.html');
|
||||
const JS_DIR = path.join(APP_DIR, 'js');
|
||||
const CSS_DIR = path.join(APP_DIR, 'css');
|
||||
const FONTS_DIR = path.join(APP_DIR, 'fonts');
|
||||
|
||||
function createHandler(store) {
|
||||
let publicModeCache = null;
|
||||
// Version-keyed cache of the client state (plain + gzip), so repeated refreshes without any
|
||||
// data change serve a 304 / cached bytes instead of re-stripping+re-gzipping the big JSON.
|
||||
// It's also pre-warmed in the background (startup + after writes) so the build cost stays off
|
||||
// the request path and even the first load after a change is fast.
|
||||
let stateCache = null; // { version, plain, gzip, etag }
|
||||
let warming = null;
|
||||
async function buildStateCache(version) {
|
||||
const state = await store.getClientState();
|
||||
if (!state) { stateCache = null; return null; }
|
||||
const plain = Buffer.from(JSON.stringify(state), 'utf8');
|
||||
stateCache = { version, plain, gzip: zlib.gzipSync(plain), etag: `W/"st-${version}"` };
|
||||
return stateCache;
|
||||
}
|
||||
function ensureWarm() {
|
||||
const v = store.getStateVersion();
|
||||
if ((stateCache && stateCache.version === v) || warming) return;
|
||||
warming = buildStateCache(v).catch(() => {}).finally(() => { warming = null; });
|
||||
}
|
||||
const warmTimer = setInterval(ensureWarm, 2000);
|
||||
if (warmTimer.unref) warmTimer.unref();
|
||||
ensureWarm(); // pre-warm at startup
|
||||
async function getPublicMode() {
|
||||
if (publicModeCache === null) {
|
||||
publicModeCache = (await store.getSetting('public_mode')) === 'true';
|
||||
}
|
||||
return publicModeCache;
|
||||
}
|
||||
|
||||
// Serve a file with conditional caching (ETag from size+mtime). Returns 304 when unchanged.
|
||||
async function serveFileCached(req, res, target, cacheControl) {
|
||||
try {
|
||||
const st = await fsp.stat(target);
|
||||
const etag = `W/"${st.size}-${Math.floor(st.mtimeMs)}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.writeHead(304, { 'ETag': etag, 'Cache-Control': cacheControl });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const buf = await fsp.readFile(target);
|
||||
send(res, 200, buf, mimeByExt(target), {
|
||||
'ETag': etag,
|
||||
'Last-Modified': st.mtime.toUTCString(),
|
||||
'Cache-Control': cacheControl
|
||||
});
|
||||
} catch {
|
||||
send(res, 404, 'Not found');
|
||||
}
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, dir, name) {
|
||||
const file = safeName(path.basename(name), '');
|
||||
if (!file) { send(res, 404, 'Not found'); return; }
|
||||
await serveFileCached(req, res, path.join(dir, file), 'no-cache');
|
||||
}
|
||||
|
||||
return async function handleRequest(req, res) {
|
||||
res._acceptGzip = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const pathname = decodeURIComponent(requestUrl.pathname);
|
||||
|
||||
// --- public assets (no auth) ---
|
||||
if (req.method === 'GET' && (pathname === '/' || pathname === '/BangumiVault.html')) {
|
||||
await serveFileCached(req, res, APP_HTML, 'no-cache');
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && pathname.startsWith('/js/')) {
|
||||
await serveStatic(req, res, JS_DIR, pathname.slice('/js/'.length));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && pathname.startsWith('/css/')) {
|
||||
await serveStatic(req, res, CSS_DIR, pathname.slice('/css/'.length));
|
||||
return;
|
||||
}
|
||||
// Self-hosted web fonts (public, immutable). Allows the /fonts/<dir>/files/<name> subpaths.
|
||||
if (req.method === 'GET' && pathname.startsWith('/fonts/')) {
|
||||
const target = path.normalize(path.join(FONTS_DIR, pathname.slice('/fonts/'.length)));
|
||||
if (target !== FONTS_DIR && !target.startsWith(FONTS_DIR + path.sep)) { send(res, 404, 'Not found'); return; }
|
||||
await serveFileCached(req, res, target, 'public, max-age=31536000, immutable');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/session') {
|
||||
const pm = await getPublicMode();
|
||||
const psn = (await store.getSetting('public_show_nsfw')) === 'true';
|
||||
json(res, 200, { authenticated: auth.isAuthed(req), publicMode: pm, publicShowNsfw: psn });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/login') {
|
||||
const body = await readJson(req);
|
||||
if (!auth.verifyPassword(body.password || '', await store.getSetting('admin_password_hash'))) {
|
||||
json(res, 401, { ok: false, error: 'invalid password' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { ok: true }, {
|
||||
'Set-Cookie': `bv_session=${encodeURIComponent(auth.createSessionCookie())}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(auth.SESSION_TTL_MS / 1000)}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/logout') {
|
||||
json(res, 200, { ok: true }, { 'Set-Cookie': 'bv_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/ping') {
|
||||
json(res, 200, { ok: true, docker: true, database: store.kind, filesDir: covers.FILES_DIR });
|
||||
return;
|
||||
}
|
||||
|
||||
// --- auth gate (public mode allows read of state + images) ---
|
||||
if ((pathname.startsWith('/api/') || pathname.startsWith('/images/')) && !auth.isAuthed(req)) {
|
||||
const pm = await getPublicMode();
|
||||
const readOk = pm && (
|
||||
(req.method === 'GET' && pathname === '/api/state') ||
|
||||
(req.method === 'GET' && pathname === '/api/history' && requestUrl.searchParams.get('subject_id')) ||
|
||||
(req.method === 'GET' && pathname === '/api/subject-meta' && requestUrl.searchParams.get('subject_id')) ||
|
||||
(req.method === 'GET' && pathname === '/api/infoboxes') ||
|
||||
(req.method === 'GET' && pathname.startsWith('/images/'))
|
||||
);
|
||||
if (!readOk) {
|
||||
json(res, 401, { ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/admin/password') {
|
||||
const body = await readJson(req);
|
||||
const currentPassword = String(body.currentPassword || '');
|
||||
const newPassword = String(body.newPassword || '');
|
||||
if (!auth.verifyPassword(currentPassword, await store.getSetting('admin_password_hash'))) {
|
||||
json(res, 400, { ok: false, error: 'current password is incorrect' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 10) {
|
||||
json(res, 400, { ok: false, error: 'new password must be at least 10 characters' });
|
||||
return;
|
||||
}
|
||||
await store.setSetting('admin_password_hash', auth.hashPassword(newPassword));
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/admin/public-mode') {
|
||||
const body = await readJson(req);
|
||||
const enabled = !!body.enabled;
|
||||
const showNsfw = !!body.showNsfw;
|
||||
await store.setSetting('public_mode', String(enabled));
|
||||
await store.setSetting('public_show_nsfw', String(showNsfw));
|
||||
publicModeCache = enabled;
|
||||
json(res, 200, { ok: true, publicMode: enabled, publicShowNsfw: showNsfw });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/state') {
|
||||
// History is excluded here (lazy-loaded per subject) to keep this payload small.
|
||||
const version = store.getStateVersion();
|
||||
if (!stateCache || stateCache.version !== version) {
|
||||
if (warming) { await warming; }
|
||||
if (!stateCache || stateCache.version !== version) { await buildStateCache(version); }
|
||||
}
|
||||
if (!stateCache) { send(res, 204, '', 'application/json; charset=utf-8'); return; }
|
||||
const c = stateCache;
|
||||
if (req.headers['if-none-match'] === c.etag) {
|
||||
res.writeHead(304, { 'ETag': c.etag, 'Cache-Control': 'no-cache', 'Vary': 'Accept-Encoding' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const useGz = res._acceptGzip;
|
||||
const body = useGz ? c.gzip : c.plain;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': body.length,
|
||||
'Cache-Control': 'no-cache',
|
||||
'ETag': c.etag,
|
||||
'Vary': 'Accept-Encoding'
|
||||
};
|
||||
if (useGz) headers['Content-Encoding'] = 'gzip';
|
||||
res.writeHead(200, headers);
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/history') {
|
||||
const subjectId = requestUrl.searchParams.get('subject_id');
|
||||
const history = subjectId ? await store.getSubjectHistory(subjectId) : await store.getAllHistory();
|
||||
send(res, 200, JSON.stringify({ history }), 'application/json; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// Subject infobox (lazy-loaded by the detail drawer; stripped from /api/state for payload size).
|
||||
if (req.method === 'GET' && pathname === '/api/subject-meta') {
|
||||
const subjectId = requestUrl.searchParams.get('subject_id');
|
||||
const infobox = subjectId ? await store.getSubjectInfobox(subjectId) : [];
|
||||
send(res, 200, JSON.stringify({ infobox }), 'application/json; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// All infoboxes as a map (background-prefetched by the client so drawers show 条目资料 instantly).
|
||||
if (req.method === 'GET' && pathname === '/api/infoboxes') {
|
||||
send(res, 200, JSON.stringify({ infoboxes: await store.getAllInfoboxes() }), 'application/json; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// Full replace — backup import only.
|
||||
if (req.method === 'POST' && pathname === '/api/state') {
|
||||
await store.persistState(await readJson(req));
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge partial settings without touching collections.
|
||||
if (req.method === 'POST' && pathname === '/api/settings') {
|
||||
const body = await readJson(req);
|
||||
await store.setStateSettings(body.settings || {});
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update a single collection's status fields (safety box).
|
||||
if (req.method === 'POST' && pathname === '/api/collections/status') {
|
||||
const body = await readJson(req);
|
||||
if (!body.subject_id) { json(res, 400, { ok: false, error: 'missing subject_id' }); return; }
|
||||
const patch = {};
|
||||
if (body.status !== undefined) patch.status = body.status;
|
||||
if (body.missing_count !== undefined) patch.missing_count = body.missing_count;
|
||||
const merged = await store.patchCollection(body.subject_id, patch);
|
||||
json(res, merged ? 200 : 404, merged ? { ok: true, collection: merged } : { ok: false, error: 'not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/sync') {
|
||||
const body = await readJson(req);
|
||||
const mode = body.mode === 'full' ? 'full' : 'recent';
|
||||
const opts = {};
|
||||
if (body.recentMonths !== undefined) opts.recentMonths = Math.max(1, Math.min(120, Number(body.recentMonths) || 3));
|
||||
if (body.cacheCovers !== undefined) opts.cacheCovers = !!body.cacheCovers;
|
||||
if (body.refreshSubjects !== undefined) opts.refreshSubjects = !!body.refreshSubjects;
|
||||
const started = sync.startSync(store, mode, 'manual', opts);
|
||||
if (started) json(res, 200, { ok: true, started: true });
|
||||
else json(res, 409, { ok: false, started: false, error: 'sync already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/sync/status') {
|
||||
json(res, 200, sync.getStatus());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/cache-cover') {
|
||||
const body = await readJson(req);
|
||||
const remoteUrl = String(body.url || '').trim();
|
||||
if (!body.subject_id || !remoteUrl) {
|
||||
json(res, 400, { ok: false, error: 'missing subject_id or url' });
|
||||
return;
|
||||
}
|
||||
const r = await covers.cacheCover(body.subject_id, remoteUrl);
|
||||
json(res, 200, { ok: true, url: r.url, file: r.file });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname.startsWith('/images/')) {
|
||||
const file = safeName(path.basename(pathname.slice('/images/'.length)), 'image.jpg');
|
||||
// Covers are effectively immutable per subject; cache for a day so refreshes don't re-download.
|
||||
await serveFileCached(req, res, path.join(covers.COVERS_DIR, file), 'public, max-age=86400');
|
||||
return;
|
||||
}
|
||||
|
||||
send(res, 404, 'Not found');
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createHandler };
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
'use strict';
|
||||
|
||||
async function createStore(databaseUrl) {
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required. Run with docker compose so the PostgreSQL container is available.');
|
||||
}
|
||||
const { Pool } = require('pg');
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
|
||||
// Bumped on every write that changes app_state.json, so the route layer can cache /api/state
|
||||
// and serve 304s until the data actually changes.
|
||||
let stateVersion = 1;
|
||||
const bumpState = () => { stateVersion++; };
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema INTEGER,
|
||||
json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
subject_id TEXT PRIMARY KEY,
|
||||
json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id TEXT,
|
||||
json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS history_subject_idx ON history(subject_id);
|
||||
`);
|
||||
|
||||
// One-time migration: move the (huge) history array out of app_state.json into the history table,
|
||||
// so the main state row stays small and GET /api/state never pays to strip a 60+MB history blob.
|
||||
// Atomic: either history is fully moved and stripped, or nothing changes (no data loss).
|
||||
{
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const probe = await client.query(
|
||||
`SELECT jsonb_typeof(json->'history') AS t, COALESCE(jsonb_array_length(json->'history'), 0) AS n
|
||||
FROM app_state WHERE id = 1 FOR UPDATE`
|
||||
);
|
||||
const row = probe.rows[0];
|
||||
if (row && row.t === 'array' && Number(row.n) > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO history(subject_id, json, created_at)
|
||||
SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW())
|
||||
FROM app_state, jsonb_array_elements(json->'history') e
|
||||
WHERE app_state.id = 1`
|
||||
);
|
||||
await client.query(`UPDATE app_state SET json = json - 'history' WHERE id = 1`);
|
||||
console.log(`Migrated ${row.n} history entries from app_state.json into the history table.`);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCollectionsTable(client, collections) {
|
||||
await client.query('DELETE FROM collections');
|
||||
for (const [id, row] of Object.entries(collections || {})) {
|
||||
await client.query(
|
||||
'INSERT INTO collections(subject_id, json, updated_at) VALUES ($1, $2::jsonb, NOW()) ON CONFLICT(subject_id) DO UPDATE SET json = EXCLUDED.json, updated_at = EXCLUDED.updated_at',
|
||||
[String(id), JSON.stringify(row)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRunsTable(client, runs) {
|
||||
await client.query('DELETE FROM runs');
|
||||
for (const run of runs || []) {
|
||||
await client.query('INSERT INTO runs(json, created_at) VALUES ($1::jsonb, $2)', [
|
||||
JSON.stringify(run),
|
||||
run.finished_at || run.started_at || new Date().toISOString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'postgres',
|
||||
|
||||
getStateVersion() { return stateVersion; },
|
||||
|
||||
async getSetting(key) {
|
||||
const result = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||
return result.rows[0]?.value || '';
|
||||
},
|
||||
|
||||
async setSetting(key, value) {
|
||||
await pool.query(
|
||||
'INSERT INTO settings(key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at',
|
||||
[key, String(value)]
|
||||
);
|
||||
},
|
||||
|
||||
async getState() {
|
||||
const result = await pool.query('SELECT json FROM app_state WHERE id = 1');
|
||||
return result.rows[0]?.json || null;
|
||||
},
|
||||
|
||||
// Client-facing state: history is in its own table (lazy-loaded per subject), and the heavy
|
||||
// per-collection blobs the frontend never reads (`subject_raw`, `infobox`) are stripped here in
|
||||
// Postgres so they never get serialized/gzipped/transferred on load.
|
||||
async getClientState() {
|
||||
// Cheap in Postgres: just drop history. Strip the unused heavy per-collection blobs in JS
|
||||
// (fast key deletes) — doing it via jsonb_each/jsonb_object_agg in SQL is far more CPU.
|
||||
const result = await pool.query(`SELECT json - 'history' AS json FROM app_state WHERE id = 1`);
|
||||
const j = result.rows[0]?.json;
|
||||
if (!j) return null;
|
||||
const cols = j.collections;
|
||||
if (cols && typeof cols === 'object') {
|
||||
for (const k in cols) {
|
||||
const c = cols[k];
|
||||
if (c) { delete c.subject_raw; delete c.infobox; }
|
||||
}
|
||||
}
|
||||
return j;
|
||||
},
|
||||
|
||||
// History entries for one subject (detail drawer).
|
||||
async getSubjectHistory(subjectId) {
|
||||
const result = await pool.query(
|
||||
`SELECT coalesce(jsonb_agg(json ORDER BY id), '[]'::jsonb) AS history FROM history WHERE subject_id = $1`,
|
||||
[String(subjectId)]
|
||||
);
|
||||
return result.rows[0]?.history || [];
|
||||
},
|
||||
|
||||
// Subject infobox for one collection (game type/engine/developer…). Stripped from /api/state to
|
||||
// keep that payload small, so the detail drawer lazy-loads it per subject like history does.
|
||||
async getSubjectInfobox(subjectId) {
|
||||
const result = await pool.query(
|
||||
`SELECT json -> 'collections' -> $1 -> 'infobox' AS infobox FROM app_state WHERE id = 1`,
|
||||
[String(subjectId)]
|
||||
);
|
||||
const ib = result.rows[0]?.infobox;
|
||||
return Array.isArray(ib) ? ib : [];
|
||||
},
|
||||
|
||||
// All infoboxes as a { subject_id: infobox } map (one row de-TOAST). Lets the client prefetch every
|
||||
// infobox in the background after load, so opening any drawer shows 条目资料 instantly from cache.
|
||||
async getAllInfoboxes() {
|
||||
const result = await pool.query(
|
||||
`SELECT coalesce(jsonb_object_agg(e.key, e.value -> 'infobox')
|
||||
FILTER (WHERE jsonb_typeof(e.value -> 'infobox') = 'array' AND e.value -> 'infobox' <> '[]'::jsonb),
|
||||
'{}'::jsonb) AS infoboxes
|
||||
FROM app_state, jsonb_each(app_state.json -> 'collections') AS e
|
||||
WHERE app_state.id = 1`
|
||||
);
|
||||
return result.rows[0]?.infoboxes || {};
|
||||
},
|
||||
|
||||
// Full history array (backup export).
|
||||
async getAllHistory() {
|
||||
const result = await pool.query(`SELECT coalesce(jsonb_agg(json ORDER BY id), '[]'::jsonb) AS history FROM history`);
|
||||
return result.rows[0]?.history || [];
|
||||
},
|
||||
|
||||
// Full replace — backup import and in-app full saves. History now lives in its own table and is
|
||||
// NOT stored in app_state.json. The browser no longer holds history, so a historyless payload must
|
||||
// NOT wipe server history: only replace the history table when the payload carries a non-empty
|
||||
// history (backup import); otherwise leave the existing history table untouched.
|
||||
async persistState(state) {
|
||||
const incomingHasHistory = Array.isArray(state.history) && state.history.length > 0;
|
||||
const jsonNoHistory = { ...state };
|
||||
delete jsonNoHistory.history;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
'INSERT INTO app_state(id, schema, json, updated_at) VALUES (1, $1, $2::jsonb, NOW()) ON CONFLICT(id) DO UPDATE SET schema = EXCLUDED.schema, json = EXCLUDED.json, updated_at = EXCLUDED.updated_at',
|
||||
[Number(state.schema || 0), JSON.stringify(jsonNoHistory)]
|
||||
);
|
||||
if (incomingHasHistory) {
|
||||
await client.query('DELETE FROM history');
|
||||
await client.query(
|
||||
`INSERT INTO history(subject_id, json, created_at)
|
||||
SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW())
|
||||
FROM jsonb_array_elements($1::jsonb) e`,
|
||||
[JSON.stringify(state.history)]
|
||||
);
|
||||
}
|
||||
await refreshCollectionsTable(client, state.collections);
|
||||
await refreshRunsTable(client, state.runs);
|
||||
await client.query('COMMIT');
|
||||
bumpState();
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
|
||||
// Merge a partial settings object into app_state.json.settings without touching collections.
|
||||
async setStateSettings(partial) {
|
||||
await pool.query(
|
||||
`INSERT INTO app_state(id, schema, json, updated_at)
|
||||
VALUES (1, 9, jsonb_build_object('schema', 9, 'settings', $1::jsonb, 'collections', '{}'::jsonb, 'runs', '[]'::jsonb), NOW())
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
json = jsonb_set(coalesce(app_state.json, '{}'::jsonb), '{settings}',
|
||||
coalesce(app_state.json->'settings', '{}'::jsonb) || $1::jsonb, true),
|
||||
updated_at = NOW()`,
|
||||
[JSON.stringify(partial || {})]
|
||||
);
|
||||
bumpState();
|
||||
},
|
||||
|
||||
// Update a single collection's fields (safety-box actions). Reflects into both app_state.json and the collections table.
|
||||
async patchCollection(subjectId, patch) {
|
||||
const id = String(subjectId);
|
||||
const cur = await pool.query(`SELECT json->'collections'->$1 AS row FROM app_state WHERE id = 1`, [id]);
|
||||
const existing = cur.rows[0]?.row;
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...(patch || {}) };
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE app_state SET json = jsonb_set(json, ARRAY['collections', $1], $2::jsonb, true), updated_at = NOW() WHERE id = 1`,
|
||||
[id, JSON.stringify(merged)]
|
||||
);
|
||||
await client.query(
|
||||
'INSERT INTO collections(subject_id, json, updated_at) VALUES ($1, $2::jsonb, NOW()) ON CONFLICT(subject_id) DO UPDATE SET json = EXCLUDED.json, updated_at = EXCLUDED.updated_at',
|
||||
[id, JSON.stringify(merged)]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
bumpState();
|
||||
return merged;
|
||||
},
|
||||
|
||||
// Sync end: replace collections/runs and settings.autoSyncLastRunDate in app_state.json (history is
|
||||
// NOT in json anymore), and APPEND the run's new history entries to the history table.
|
||||
// `history` here is the new entries produced by this run (runSync starts from an empty history).
|
||||
async persistSyncResult({ collections, history, runs, lastRunDate }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`INSERT INTO app_state(id, schema, json, updated_at)
|
||||
VALUES (1, 9, jsonb_build_object('schema', 9, 'settings', jsonb_build_object('autoSyncLastRunDate', $3::text),
|
||||
'collections', $1::jsonb, 'runs', $2::jsonb), NOW())
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
json = jsonb_set(jsonb_set(jsonb_set(
|
||||
app_state.json,
|
||||
'{collections}', $1::jsonb),
|
||||
'{runs}', $2::jsonb),
|
||||
'{settings,autoSyncLastRunDate}', to_jsonb($3::text), true),
|
||||
updated_at = NOW()`,
|
||||
[JSON.stringify(collections || {}), JSON.stringify(runs || []), String(lastRunDate || '')]
|
||||
);
|
||||
const newHistory = Array.isArray(history) ? history : [];
|
||||
if (newHistory.length) {
|
||||
await client.query(
|
||||
`INSERT INTO history(subject_id, json, created_at)
|
||||
SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW())
|
||||
FROM jsonb_array_elements($1::jsonb) e`,
|
||||
[JSON.stringify(newHistory)]
|
||||
);
|
||||
}
|
||||
await refreshCollectionsTable(client, collections);
|
||||
await refreshRunsTable(client, runs);
|
||||
await client.query('COMMIT');
|
||||
bumpState();
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore };
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
'use strict';
|
||||
|
||||
const { SUBJECT_TYPES, COLLECTION_TYPES, SUBJECT_LABEL, STATUS } = require('./constants');
|
||||
const { sleep, fetchMe, fetchCollectionAll, fetchCollectionRecent, fetchSubjectDetail, setApiBase } = require('./bgm');
|
||||
const { nowISO, normalizeCollection, applySubjectDetail, makeSnapshot, collectionFieldDiff } = require('./normalize');
|
||||
const { cacheCoversForState } = require('./covers');
|
||||
|
||||
let syncStatus = {
|
||||
running: false, mode: null, trigger: null, phase: 'idle', percent: 0,
|
||||
processed: 0, total: 0, startedAt: null, finishedAt: null, message: '', lastResult: null, error: null
|
||||
};
|
||||
let lock = false;
|
||||
|
||||
function getStatus() { return syncStatus; }
|
||||
|
||||
function emptyState() { return { schema: 9, settings: {}, collections: {}, history: [], runs: [] }; }
|
||||
|
||||
// Previous cron fire time before `from` (scans backward minute by minute, capped). Used by the
|
||||
// "interval" auto-sync range mode: the lookback window is one scheduling period.
|
||||
function cronPrevRun(expr, from) {
|
||||
const d = new Date(from.getTime());
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(d.getMinutes() - 1);
|
||||
const limit = 45 * 24 * 60; // ~45 days covers weekly/monthly schedules
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (cronMatches(expr, d)) return new Date(d.getTime());
|
||||
d.setMinutes(d.getMinutes() - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-sync "fixed" range cutoff = now − (autoSyncRangeValue × autoSyncRangeUnit).
|
||||
function fixedCutoff(state) {
|
||||
const unit = state.settings.autoSyncRangeUnit || 'month';
|
||||
const val = Math.max(1, Number(state.settings.autoSyncRangeValue != null ? state.settings.autoSyncRangeValue : (state.settings.recentMonths || 3)) || 1);
|
||||
const d = new Date();
|
||||
if (unit === 'minute') d.setMinutes(d.getMinutes() - val);
|
||||
else if (unit === 'hour') d.setHours(d.getHours() - val);
|
||||
else if (unit === 'day') d.setDate(d.getDate() - val);
|
||||
else d.setMonth(d.getMonth() - val);
|
||||
return d;
|
||||
}
|
||||
|
||||
function computeCutoff(state, mode, trigger, recentMonthsOverride) {
|
||||
if (mode !== 'recent') return null;
|
||||
const months = recentMonthsOverride != null ? recentMonthsOverride : Number(state.settings.recentMonths || 3);
|
||||
const recentMonths = Math.max(1, Math.min(120, months));
|
||||
const monthsCutoff = () => { const d = new Date(); d.setMonth(d.getMonth() - recentMonths); return d; };
|
||||
if (trigger === 'auto') {
|
||||
if (state.settings.autoSyncRangeMode === 'interval') {
|
||||
const cron = state.settings.autoSyncCron || legacyToCron(state.settings) || '0 2 * * *';
|
||||
const prev = cronPrevRun(cron, new Date()); // ≈ now − one scheduling interval
|
||||
if (prev) return prev;
|
||||
// fall through to the fixed range if no previous fire was found
|
||||
}
|
||||
return fixedCutoff(state); // value + unit (minute/hour/day/month)
|
||||
}
|
||||
return monthsCutoff(); // manual sync still uses months (from the dialog / recentMonths)
|
||||
}
|
||||
|
||||
async function runSync({ store, mode, trigger, opts = {}, onProgress }) {
|
||||
const report = (p) => { try { onProgress && onProgress(p); } catch {} };
|
||||
// Per-run options (manual sync passes these; auto sync falls back to settings).
|
||||
const refreshSubjects = opts.refreshSubjects !== false;
|
||||
|
||||
const state = (await store.getState()) || emptyState();
|
||||
state.settings = state.settings || {};
|
||||
state.collections = state.collections || {};
|
||||
state.history = state.history || [];
|
||||
state.runs = state.runs || [];
|
||||
|
||||
setApiBase(state.settings.bgmApiBase); // custom API base / mirror (empty = default api.bgm.tv)
|
||||
|
||||
const token = state.settings.token;
|
||||
if (!token) throw new Error('未设置 Access Token,无法同步');
|
||||
|
||||
let username = state.settings.username;
|
||||
if (!username) {
|
||||
report({ phase: 'auth', percent: 2, message: '读取用户名…' });
|
||||
const me = await fetchMe(token);
|
||||
username = me.username || me.name;
|
||||
if (!username) throw new Error('无法自动获取用户名,请在设置中手动填写 Bangumi 用户名');
|
||||
state.settings.username = username;
|
||||
}
|
||||
|
||||
const effectiveMonths = mode === 'recent'
|
||||
? Math.max(1, Math.min(120, opts.recentMonths != null ? opts.recentMonths : Number(state.settings.recentMonths || 3)))
|
||||
: null;
|
||||
const cacheCovers = opts.cacheCovers !== undefined ? opts.cacheCovers : state.settings.autoCacheCovers;
|
||||
const cutoff = computeCutoff(state, mode, trigger, effectiveMonths);
|
||||
const getAll = () => Object.values(state.collections);
|
||||
|
||||
// --- phase 1: fetch collections ---
|
||||
report({ phase: 'fetching', percent: 4, message: `开始${mode === 'recent' ? '增量' : '完整'}同步 @${username}` });
|
||||
const remote = [];
|
||||
const totalTasks = SUBJECT_TYPES.length * COLLECTION_TYPES.length;
|
||||
let taskDone = 0;
|
||||
for (const st of SUBJECT_TYPES) {
|
||||
for (const ct of COLLECTION_TYPES) {
|
||||
const onPage = (page, count) => report({
|
||||
phase: 'fetching', percent: 4 + Math.round(taskDone / totalTasks * 36),
|
||||
processed: taskDone, total: totalTasks,
|
||||
message: `${SUBJECT_LABEL[st] || st} / ${STATUS[ct] || ct}:第 ${page} 页,累计 ${count}`
|
||||
});
|
||||
const got = mode === 'recent'
|
||||
? await fetchCollectionRecent(username, st, ct, token, cutoff, onPage)
|
||||
: await fetchCollectionAll(username, st, ct, token, onPage);
|
||||
remote.push(...got);
|
||||
taskDone++;
|
||||
report({ phase: 'fetching', percent: 4 + Math.round(taskDone / totalTasks * 36), processed: taskDone, total: totalTasks });
|
||||
await sleep(mode === 'recent' ? 80 : 160);
|
||||
}
|
||||
}
|
||||
|
||||
// --- phase 2: subject details (skipped when refreshSubjects is off) ---
|
||||
const subjectDetails = new Map();
|
||||
if (refreshSubjects) {
|
||||
report({ phase: 'details', percent: 40, processed: 0, total: remote.length, message: `远端 ${remote.length} 条,获取条目详情…` });
|
||||
for (let i = 0; i < remote.length; i++) {
|
||||
const sid = remote[i].subject_id || remote[i].subject?.id;
|
||||
if (!sid) continue;
|
||||
const detail = await fetchSubjectDetail(sid, token);
|
||||
if (detail) subjectDetails.set(String(sid), detail);
|
||||
if ((i + 1) % 10 === 0 || i === remote.length - 1) {
|
||||
report({ phase: 'details', percent: 40 + Math.round((i + 1) / Math.max(1, remote.length) * 45), processed: i + 1, total: remote.length, message: `条目详情 ${i + 1}/${remote.length}` });
|
||||
}
|
||||
await sleep(110);
|
||||
}
|
||||
} else {
|
||||
report({ phase: 'details', percent: 85, message: '已跳过条目详情刷新' });
|
||||
}
|
||||
|
||||
// --- phase 3: normalize + compare ---
|
||||
report({ phase: 'merging', percent: 86, message: '比对本地…' });
|
||||
const seen = new Map();
|
||||
for (const item of remote) {
|
||||
let n = normalizeCollection(item, item.__fallback);
|
||||
const sid = String(n.subject_id);
|
||||
if (subjectDetails.has(sid)) n = applySubjectDetail(n, subjectDetails.get(sid));
|
||||
const snap = makeSnapshot(n);
|
||||
if (snap.subject_id) seen.set(sid, snap);
|
||||
}
|
||||
|
||||
// safety threshold (full only)
|
||||
if (mode === 'full') {
|
||||
const activeLocal = getAll().filter(c => c.status !== 'confirmed_deleted' && c.status !== 'protected_local');
|
||||
const missingPreview = activeLocal.filter(c => !seen.has(String(c.subject_id)));
|
||||
if (activeLocal.length && missingPreview.length > Math.max(10, Math.floor(activeLocal.length * 0.03))) {
|
||||
const run = { id: Date.now().toString(), mode, trigger, started_at: nowISO(), finished_at: nowISO(), success: false, audit_only: true, fetched_total: seen.size, missing_preview: missingPreview.length, error: '远端缺失数量超过安全阈值,本次不写入本地' };
|
||||
state.runs.push(run);
|
||||
await store.persistSyncResult({ collections: state.collections, history: state.history, runs: state.runs, lastRunDate: new Date().toLocaleDateString('sv') });
|
||||
return { aborted: true, reason: 'safety', missing_preview: missingPreview.length, active: activeLocal.length };
|
||||
}
|
||||
}
|
||||
|
||||
let added = 0, modified = 0, restored = 0, missing = 0;
|
||||
const runId = Date.now().toString(); const started_at = nowISO();
|
||||
for (const [id, n] of seen) {
|
||||
const old = state.collections[id];
|
||||
if (!old) {
|
||||
const commentTime = String(n.comment || '').trim() ? (n.bangumi_updated_at || n.collection_created_at || started_at) : '';
|
||||
state.collections[id] = { ...n, status: 'active', marked_at: n.collection_created_at || '', mark_changed_at: n.bangumi_updated_at || n.collection_created_at || started_at, first_seen_at: started_at, last_seen_at: started_at, last_synced_at: started_at, comment_updated_at: commentTime, missing_count: 0, last_sync_run_id: runId };
|
||||
added++;
|
||||
state.history.push({ subject_id: n.subject_id, change_type: 'added', old: null, new: structuredClone(state.collections[id]), created_at: started_at, sync_run_id: runId, note: mode === 'recent' ? '最近同步首次备份' : '首次备份' });
|
||||
} else {
|
||||
if (old.status === 'missing_candidate' || old.status === 'protected_local' || old.status === 'confirmed_deleted') {
|
||||
restored++;
|
||||
state.history.push({ subject_id: n.subject_id, change_type: 'restored', old: structuredClone(old), new: structuredClone(n), changes: collectionFieldDiff(old, n), created_at: started_at, sync_run_id: runId, note: '远端重新出现' });
|
||||
}
|
||||
if (old.content_hash !== n.content_hash) {
|
||||
const changes = collectionFieldDiff(old, n); modified++;
|
||||
state.history.push({ subject_id: n.subject_id, change_type: 'modified', old: structuredClone(old), new: structuredClone(n), changes, created_at: started_at, sync_run_id: runId, note: '收藏字段、吐槽、标签、评分、进度或条目资料变化' });
|
||||
}
|
||||
const statusChanged = old.collection_type !== n.collection_type;
|
||||
const markChangedAt = statusChanged ? (n.bangumi_updated_at || started_at) : (old.mark_changed_at || n.bangumi_updated_at || old.collection_created_at || '');
|
||||
state.collections[id] = { ...old, ...n, status: 'active', mark_changed_at: markChangedAt, first_seen_at: old.first_seen_at || started_at, last_seen_at: started_at, last_synced_at: started_at, missing_count: 0, last_sync_run_id: runId };
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'full') {
|
||||
for (const c of getAll()) {
|
||||
const id = String(c.subject_id); if (seen.has(id)) continue;
|
||||
if (c.status === 'confirmed_deleted' || c.status === 'protected_local') continue;
|
||||
if (c.status !== 'missing_candidate') {
|
||||
state.history.push({ subject_id: c.subject_id, change_type: 'missing', old: structuredClone(c), new: null, created_at: started_at, sync_run_id: runId, note: '远端完整同步后未出现;未删除,仅进入安全箱' });
|
||||
}
|
||||
c.status = 'missing_candidate'; c.missing_count = (c.missing_count || 0) + 1; c.last_sync_run_id = runId; missing++;
|
||||
}
|
||||
}
|
||||
|
||||
state.runs.push({ id: runId, mode, trigger, recent_months: effectiveMonths, started_at, finished_at: nowISO(), success: true, fetched_total: seen.size, added, modified, restored, missing });
|
||||
|
||||
// --- phase 4: covers ---
|
||||
// Only cache covers for items fetched in THIS sync (the `seen` set), not the whole library.
|
||||
// So a recent sync caches just the recently-updated items; a full sync caches everything fetched.
|
||||
if (cacheCovers) {
|
||||
report({ phase: 'covers', percent: 95, message: '缓存封面…' });
|
||||
const seenColl = {};
|
||||
for (const id of seen.keys()) { if (state.collections[id]) seenColl[id] = state.collections[id]; }
|
||||
await cacheCoversForState(seenColl, (done, total) => {
|
||||
report({ phase: 'covers', percent: 95 + Math.round(done / Math.max(1, total) * 4), processed: done, total, message: `封面 ${done}/${total}` });
|
||||
}, state.settings.imageProxy);
|
||||
}
|
||||
|
||||
report({ phase: 'saving', percent: 99, message: '写入数据库…' });
|
||||
await store.persistSyncResult({ collections: state.collections, history: state.history, runs: state.runs, lastRunDate: new Date().toLocaleDateString('sv') });
|
||||
|
||||
return { added, modified, restored, missing, fetched_total: seen.size };
|
||||
}
|
||||
|
||||
function startSync(store, mode, trigger, opts = {}) {
|
||||
if (lock) return false;
|
||||
lock = true;
|
||||
syncStatus = {
|
||||
running: true, mode, trigger, phase: 'starting', percent: 0, processed: 0, total: 0,
|
||||
startedAt: new Date().toISOString(), finishedAt: null, message: '准备同步…', lastResult: null, error: null
|
||||
};
|
||||
runSync({ store, mode, trigger, opts, onProgress: p => Object.assign(syncStatus, p) })
|
||||
.then(r => { syncStatus.lastResult = r; syncStatus.phase = r?.aborted ? 'aborted' : 'done'; syncStatus.percent = 100; syncStatus.message = r?.aborted ? '安全保护触发,本次未覆盖本地' : '同步完成'; })
|
||||
.catch(e => { syncStatus.error = String(e?.message || e); syncStatus.phase = 'error'; syncStatus.message = '同步失败:' + syncStatus.error; })
|
||||
.finally(() => { syncStatus.running = false; syncStatus.finishedAt = new Date().toISOString(); lock = false; });
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- cron scheduling (no external deps) ---
|
||||
|
||||
// Parse one cron field into a Set of allowed values. Supports '*', '*/n', 'a', 'a-b', 'a-b/n', and comma lists.
|
||||
function parseCronField(field, min, max) {
|
||||
const out = new Set();
|
||||
for (const part of String(field).split(',')) {
|
||||
const m = part.trim().match(/^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/);
|
||||
if (!m) continue;
|
||||
const step = m[3] ? Number(m[3]) : 1;
|
||||
if (step < 1) continue;
|
||||
let lo, hi;
|
||||
if (m[1] === '*') { lo = min; hi = max; }
|
||||
else { lo = Number(m[1]); hi = m[2] !== undefined ? Number(m[2]) : (m[3] ? max : lo); }
|
||||
for (let v = lo; v <= hi; v += step) { if (v >= min && v <= max) out.add(v); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Match a 5-field cron expression (min hour day-of-month month day-of-week) against a Date (local time).
|
||||
function cronMatches(expr, date) {
|
||||
const parts = String(expr).trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
const [mi, ho, dom, mon, dow] = parts;
|
||||
if (!parseCronField(mi, 0, 59).has(date.getMinutes())) return false;
|
||||
if (!parseCronField(ho, 0, 23).has(date.getHours())) return false;
|
||||
if (!parseCronField(mon, 1, 12).has(date.getMonth() + 1)) return false;
|
||||
const domRestricted = dom.trim() !== '*';
|
||||
const dowRestricted = dow.trim() !== '*';
|
||||
const domOk = parseCronField(dom, 1, 31).has(date.getDate());
|
||||
const dowOk = parseCronField(dow, 0, 6).has(date.getDay());
|
||||
// Standard cron: when both DOM and DOW are restricted, match if either matches.
|
||||
if (domRestricted && dowRestricted) return domOk || dowOk;
|
||||
if (domRestricted) return domOk;
|
||||
if (dowRestricted) return dowOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: derive a cron from the legacy autoSyncTime/autoSyncDays settings.
|
||||
function legacyToCron(s) {
|
||||
if (!s.autoSyncTime && !Array.isArray(s.autoSyncDays)) return null;
|
||||
const [h, m] = String(s.autoSyncTime || '02:00').split(':').map(Number);
|
||||
const days = Array.isArray(s.autoSyncDays) ? s.autoSyncDays : [];
|
||||
const dow = (days.length === 0 || days.length >= 7) ? '*' : [...days].sort((a, b) => a - b).join(',');
|
||||
return `${m || 0} ${h || 0} * * ${dow}`;
|
||||
}
|
||||
|
||||
let lastAutoRunKey = '';
|
||||
|
||||
async function checkSchedule(store) {
|
||||
if (lock) return;
|
||||
let state;
|
||||
try { state = await store.getState(); } catch { return; }
|
||||
const s = (state && state.settings) || {};
|
||||
if (s.autoSyncEnabled !== true && s.autoSyncEnabled !== 'true') return; // accept boolean or legacy string
|
||||
const cron = s.autoSyncCron || legacyToCron(s) || '0 2 * * *';
|
||||
const now = new Date();
|
||||
const key = now.toLocaleString('sv').slice(0, 16); // local 'YYYY-MM-DD HH:MM'
|
||||
if (lastAutoRunKey === key) return; // in-memory dedup (this minute already handled)
|
||||
if (s.autoSyncLastRunKey === key) return; // persisted dedup (survives container restart)
|
||||
if (!cronMatches(cron, now)) return;
|
||||
lastAutoRunKey = key;
|
||||
try { await store.setStateSettings({ autoSyncLastRunKey: key }); } catch {}
|
||||
startSync(store, s.autoSyncMode || 'recent', 'auto');
|
||||
}
|
||||
|
||||
module.exports = { runSync, startSync, getStatus, checkSchedule, cronMatches, parseCronField };
|
||||
Reference in New Issue
Block a user