Files
Bangumi-Vault/server/sync.js
T
Stardream e3bf77296c
Build and Push Docker Image / build (push) Successful in 17s
Initial release
2026-06-16 20:27:36 +10:00

291 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };