'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 };