import * as fs from 'fs'; import * as path from 'path'; import { ensureDir } from '../../shared/fs-utils'; import { buildBuildingMessage, buildCheckingMessage, buildFailedMessage, buildGeneratingMessage, buildImportingMessage, buildReadyMessage, formatCharacterDictionaryProgressDetail, joinGeneratingDetail, } from './character-dictionary-auto-sync-messages'; import { readDictionaryZipRevision } from '../character-dictionary-runtime/zip'; import type { AnilistCharacterDictionaryProfileScope } from '../../types'; import type { CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotResult, CharacterDictionarySnapshotStageProgress, MergedCharacterDictionaryBuildResult, } from '../character-dictionary-runtime'; const DEFAULT_IMPORT_TIMEOUT_BASE_MS = 120_000; const IMPORT_TIMEOUT_MS_PER_MB = 6_000; const IMPORT_TIMEOUT_MAX_MS = 1_800_000; type AutoSyncMediaEntry = { mediaId: number; label: string; }; type AutoSyncState = { activeMediaIds: AutoSyncMediaEntry[]; mergedRevision: string | null; mergedDictionaryTitle: string | null; }; type AutoSyncDictionaryInfo = { title: string; revision?: string | number; }; export interface CharacterDictionaryManagerEntry { mediaId: number; label: string; title: string; current: boolean; } export interface CharacterDictionaryManagerSnapshot { entries: CharacterDictionaryManagerEntry[]; } export type CharacterDictionaryManagerMutationResult = | (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean }) | { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] }; export interface CharacterDictionaryAutoSyncConfig { enabled: boolean; maxLoaded: number; profileScope: AnilistCharacterDictionaryProfileScope; } export interface CharacterDictionaryAutoSyncStatusEvent { phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; mediaId?: number; mediaTitle?: string; message: string; changed?: boolean; } export interface CharacterDictionaryAutoSyncRuntimeDeps { userDataPath: string; getConfig: () => CharacterDictionaryAutoSyncConfig; getOrCreateCurrentSnapshot: ( targetPath?: string, progress?: CharacterDictionarySnapshotProgressCallbacks, ) => Promise; buildMergedDictionary: (mediaIds: number[]) => Promise; waitForYomitanMutationReady?: () => Promise; getYomitanDictionaryInfo: () => Promise; importYomitanDictionary: (zipPath: string) => Promise; deleteYomitanDictionary: (dictionaryTitle: string) => Promise; upsertYomitanDictionarySettings: ( dictionaryTitle: string, profileScope: AnilistCharacterDictionaryProfileScope, ) => Promise; now: () => number; schedule?: (fn: () => void, delayMs: number) => ReturnType; clearSchedule?: (timer: ReturnType) => void; /** Budget for the quick Yomitan queries (dictionary info, settings upsert). */ operationTimeoutMs?: number; /** * Base budget for the slow Yomitan mutations (delete + import). The effective budget grows with * the merged ZIP size, because importing a large dictionary can take several minutes. */ dictionaryImportTimeoutBaseMs?: number; heartbeatMs?: number; progressThrottleMs?: number; logInfo?: (message: string) => void; logWarn?: (message: string) => void; onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void; onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void; } function normalizeMediaId(rawMediaId: number): number | null { const mediaId = Math.max(1, Math.floor(rawMediaId)); return Number.isFinite(mediaId) ? mediaId : null; } function parseActiveMediaEntry(rawEntry: unknown): AutoSyncMediaEntry | null { if (typeof rawEntry === 'number') { const mediaId = normalizeMediaId(rawEntry); if (mediaId === null) { return null; } return { mediaId, label: String(mediaId) }; } if (typeof rawEntry !== 'string') { return null; } const trimmed = rawEntry.trim(); if (!trimmed) { return null; } const [rawId, ...rawTitleParts] = trimmed.split(' - '); if (!rawId || !/^\d+$/.test(rawId)) { return null; } const mediaId = normalizeMediaId(Number.parseInt(rawId ?? '', 10)); if (mediaId === null || mediaId <= 0) { return null; } const rawLabel = rawTitleParts.length > 0 ? rawTitleParts.join(' - ').trim() : ''; return { mediaId, label: rawLabel ? `${mediaId} - ${rawLabel}` : String(mediaId) }; } function buildActiveMediaLabel(mediaId: number, mediaTitle: string | null | undefined): string { const normalizedId = normalizeMediaId(mediaId); const trimmedTitle = typeof mediaTitle === 'string' ? mediaTitle.trim() : ''; if (normalizedId === null) { return trimmedTitle; } return trimmedTitle.length > 0 ? `${normalizedId} - ${trimmedTitle}` : String(normalizedId); } function readAutoSyncState(statePath: string): AutoSyncState { try { const raw = fs.readFileSync(statePath, 'utf8'); const parsed = JSON.parse(raw) as Partial; const activeMediaIds: AutoSyncMediaEntry[] = []; const activeMediaIdSet = new Set(); if (Array.isArray(parsed.activeMediaIds)) { for (const value of parsed.activeMediaIds) { const entry = parseActiveMediaEntry(value); if (entry && !activeMediaIdSet.has(entry.mediaId)) { activeMediaIdSet.add(entry.mediaId); activeMediaIds.push(entry); } } } return { activeMediaIds, mergedRevision: typeof parsed.mergedRevision === 'string' && parsed.mergedRevision.length > 0 ? parsed.mergedRevision : null, mergedDictionaryTitle: typeof parsed.mergedDictionaryTitle === 'string' && parsed.mergedDictionaryTitle.length > 0 ? parsed.mergedDictionaryTitle : null, }; } catch { return { activeMediaIds: [], mergedRevision: null, mergedDictionaryTitle: null, }; } } function writeAutoSyncState(statePath: string, state: AutoSyncState): void { ensureDir(path.dirname(statePath)); const persistedState = { activeMediaIds: state.activeMediaIds.map((entry) => entry.label), mergedRevision: state.mergedRevision, mergedDictionaryTitle: state.mergedDictionaryTitle, }; fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8'); } function getAutoSyncStatePath(userDataPath: string): string { return path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); } function parseActiveMediaTitle(entry: AutoSyncMediaEntry): string { const prefix = `${entry.mediaId} - `; if (entry.label.startsWith(prefix)) { return entry.label.slice(prefix.length).trim(); } return entry.label === String(entry.mediaId) ? '' : entry.label.trim(); } function resolveCurrentManagerMediaId( state: AutoSyncState, currentMediaId?: number | null, ): number | null { const normalizedCurrentMediaId = typeof currentMediaId === 'number' ? normalizeMediaId(currentMediaId) : null; if (normalizedCurrentMediaId !== null) return normalizedCurrentMediaId; return state.activeMediaIds[0]?.mediaId ?? null; } function toManagerEntries( state: AutoSyncState, currentMediaId?: number | null, ): CharacterDictionaryManagerEntry[] { const resolvedCurrentMediaId = resolveCurrentManagerMediaId(state, currentMediaId); return state.activeMediaIds.map((entry, index) => ({ mediaId: entry.mediaId, label: entry.label, title: parseActiveMediaTitle(entry), current: resolvedCurrentMediaId !== null ? entry.mediaId === resolvedCurrentMediaId : index === 0, })); } export function getCharacterDictionaryManagerSnapshot( userDataPath: string, currentMediaId?: number | null, ): CharacterDictionaryManagerSnapshot { return { entries: toManagerEntries( readAutoSyncState(getAutoSyncStatePath(userDataPath)), currentMediaId, ), }; } export function moveCharacterDictionaryManagedEntry( userDataPath: string, mediaId: number, direction: 1 | -1, currentMediaId?: number | null, ): CharacterDictionaryManagerMutationResult { const statePath = getAutoSyncStatePath(userDataPath); const state = readAutoSyncState(statePath); const managerEntries = toManagerEntries(state, currentMediaId); const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); if (index < 0) { return { ok: false, message: 'Character dictionary entry not found.', entries: managerEntries, }; } if (managerEntries[index]?.current) { return { ok: false, message: 'The current anime stays anchored while you are watching it.', entries: managerEntries, }; } const targetIndex = Math.min(state.activeMediaIds.length - 1, Math.max(0, index + direction)); if (targetIndex === index) { return { ok: true, entries: managerEntries }; } const nextActiveMediaIds = [...state.activeMediaIds]; const [entry] = nextActiveMediaIds.splice(index, 1); if (entry) { nextActiveMediaIds.splice(targetIndex, 0, entry); } const nextState = { ...state, activeMediaIds: nextActiveMediaIds, mergedRevision: null }; writeAutoSyncState(statePath, nextState); return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true }; } export function removeCharacterDictionaryManagedEntry( userDataPath: string, mediaId: number, currentMediaId?: number | null, ): CharacterDictionaryManagerMutationResult { const statePath = getAutoSyncStatePath(userDataPath); const state = readAutoSyncState(statePath); const managerEntries = toManagerEntries(state, currentMediaId); const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); if (index < 0) { return { ok: false, message: 'Character dictionary entry not found.', entries: managerEntries, }; } if (managerEntries[index]?.current) { return { ok: false, message: 'The current anime stays loaded while you are watching it.', entries: managerEntries, }; } const nextState = { ...state, activeMediaIds: state.activeMediaIds.filter((entry) => entry.mediaId !== mediaId), mergedRevision: null, }; writeAutoSyncState(statePath, nextState); return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true }; } export function replaceCharacterDictionaryManagedEntry( userDataPath: string, mediaId: number, replacement: { mediaId: number; mediaTitle: string }, ): CharacterDictionaryManagerMutationResult { const statePath = getAutoSyncStatePath(userDataPath); const state = readAutoSyncState(statePath); const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); if (index < 0) { return { ok: false, message: 'Character dictionary entry not found.', entries: toManagerEntries(state), }; } const normalizedReplacementMediaId = normalizeMediaId(replacement.mediaId); const mediaTitle = replacement.mediaTitle.trim(); if (normalizedReplacementMediaId === null || !mediaTitle) { return { ok: false, message: 'Invalid replacement AniList media.', entries: toManagerEntries(state), }; } const replacementEntry = { mediaId: normalizedReplacementMediaId, label: buildActiveMediaLabel(normalizedReplacementMediaId, mediaTitle), }; const nextActiveMediaIds = state.activeMediaIds .map((entry, entryIndex) => (entryIndex === index ? replacementEntry : entry)) .filter( (entry, entryIndex, entries) => entries.findIndex((candidate) => candidate.mediaId === entry.mediaId) === entryIndex, ); const nextState = { ...state, activeMediaIds: nextActiveMediaIds, mergedRevision: null, }; writeAutoSyncState(statePath, nextState); return { ok: true, entries: toManagerEntries(nextState), rebuildRequired: true }; } function arraysEqual(left: number[], right: number[]): boolean { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { if (left[i] !== right[i]) return false; } return true; } function sameMembership(left: number[], right: number[]): boolean { if (left.length !== right.length) return false; const leftSorted = [...left].sort((a, b) => a - b); const rightSorted = [...right].sort((a, b) => a - b); return arraysEqual(leftSorted, rightSorted); } export function createCharacterDictionaryAutoSyncRuntimeService( deps: CharacterDictionaryAutoSyncRuntimeDeps, ): { scheduleSync: () => void; runSyncNow: () => Promise; getCurrentMediaId: () => number | null; } { const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries'); const statePath = getAutoSyncStatePath(deps.userDataPath); const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs)); const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer)); const debounceMs = 800; const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000)); const dictionaryImportTimeoutBaseMs = Math.max( 1, Math.floor(deps.dictionaryImportTimeoutBaseMs ?? DEFAULT_IMPORT_TIMEOUT_BASE_MS), ); const heartbeatMs = Math.max(1, Math.floor(deps.heartbeatMs ?? 5_000)); const progressThrottleMs = Math.max(0, Math.floor(deps.progressThrottleMs ?? 1_000)); let debounceTimer: ReturnType | null = null; let syncInFlight = false; let runQueued = false; let activeCurrentMediaId: number | null = null; const withTimeout = async ( label: string, promise: Promise, timeoutMs: number, ): Promise => { let timer: ReturnType | null = null; try { return await Promise.race([ promise, new Promise((_resolve, reject) => { timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)); }, timeoutMs); }), ]); } finally { if (timer !== null) { clearTimeout(timer); } } }; const withOperationTimeout = (label: string, promise: Promise): Promise => withTimeout(label, promise, operationTimeoutMs); /** * Importing a merged dictionary means Yomitan writing every term and image into IndexedDB, which * scales with the ZIP: a single-season dictionary lands in seconds, One Piece takes minutes. Size * the budget off the archive instead of failing a healthy import on a flat deadline. */ const resolveImportTimeoutMs = (zipPath: string | null): number => { let bytes = 0; if (zipPath) { try { bytes = fs.statSync(zipPath).size; } catch { bytes = 0; } } const sizeAllowanceMs = (bytes / (1024 * 1024)) * IMPORT_TIMEOUT_MS_PER_MB; return Math.min( IMPORT_TIMEOUT_MAX_MS, Math.max( dictionaryImportTimeoutBaseMs, Math.round(dictionaryImportTimeoutBaseMs + sizeAllowanceMs), ), ); }; /** Keeps the persistent notification ticking so a multi-minute import never looks hung. */ const withHeartbeat = async ( run: () => Promise, onTick: (elapsedMs: number) => void, ): Promise => { const startedAt = deps.now(); let stopped = false; let timer: ReturnType | null = null; const tick = (): void => { if (stopped) return; onTick(deps.now() - startedAt); timer = schedule(tick, heartbeatMs); }; timer = schedule(tick, heartbeatMs); try { return await run(); } finally { stopped = true; if (timer !== null) { clearSchedule(timer); } } }; const runSyncOnce = async (): Promise => { const config = deps.getConfig(); if (!config.enabled) { activeCurrentMediaId = null; return; } let currentMediaId: number | undefined; let currentMediaTitle: string | null = null; let lastProgressAt = Number.NEGATIVE_INFINITY; let lastProgressStage: CharacterDictionarySnapshotStageProgress['stage'] | null = null; let generating: { mediaId: number; mediaTitle: string; startedAt: number; detail: string | null; } | null = null; let imageRate: { startedAt: number; startCompleted: number } | null = null; const emitGeneratingStatus = (): void => { if (!generating) { return; } deps.onSyncStatus?.({ phase: 'generating', mediaId: generating.mediaId, mediaTitle: generating.mediaTitle, message: buildGeneratingMessage( generating.mediaTitle, joinGeneratingDetail(generating.detail, deps.now() - generating.startedAt), ), }); }; // Image downloads are serial and evenly paced, so the observed rate predicts the tail well. const estimateRemainingImageMs = ( progress: CharacterDictionarySnapshotStageProgress, nowMs: number, ): number | null => { if (progress.stage !== 'images' || progress.total === null || imageRate === null) { return null; } const done = progress.completed - imageRate.startCompleted; const elapsedMs = nowMs - imageRate.startedAt; const remaining = progress.total - progress.completed; if (done <= 0 || elapsedMs <= 0 || remaining <= 0) { return null; } return Math.round((elapsedMs / done) * remaining); }; try { deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot'); const snapshot = await withHeartbeat( () => deps.getOrCreateCurrentSnapshot(undefined, { onChecking: ({ mediaId, mediaTitle }) => { currentMediaId = mediaId; currentMediaTitle = mediaTitle; activeCurrentMediaId = mediaId; deps.onSyncStatus?.({ phase: 'checking', mediaId, mediaTitle, message: buildCheckingMessage(mediaTitle), }); }, onGenerating: ({ mediaId, mediaTitle }) => { currentMediaId = mediaId; currentMediaTitle = mediaTitle; activeCurrentMediaId = mediaId; lastProgressAt = Number.NEGATIVE_INFINITY; lastProgressStage = null; imageRate = null; generating = { mediaId, mediaTitle, startedAt: deps.now(), detail: null }; emitGeneratingStatus(); }, // Long-running work (AniList character pages, then one image download per character // and voice actor, then MeCab name splits) reports counts so the notification shows // movement instead of a frozen "Generating..." for the minutes a large series takes. onGenerateProgress: (progress) => { const nowMs = deps.now(); if (!generating) { generating = { mediaId: progress.mediaId, mediaTitle: progress.mediaTitle, startedAt: nowMs, detail: null, }; } if (progress.stage === 'images' && imageRate === null) { imageRate = { startedAt: nowMs, startCompleted: progress.completed }; } // Stage changes and the last item of a stage always report; the throttle only thins // out the run of identical-looking updates in between. const isFinal = progress.total !== null && progress.completed >= progress.total; const isStageChange = progress.stage !== lastProgressStage; if (!isFinal && !isStageChange && nowMs - lastProgressAt < progressThrottleMs) { return; } lastProgressAt = nowMs; lastProgressStage = progress.stage; generating.mediaId = progress.mediaId; generating.mediaTitle = progress.mediaTitle; generating.detail = formatCharacterDictionaryProgressDetail( progress, estimateRemainingImageMs(progress, nowMs), ); emitGeneratingStatus(); }, }), // Ticks even when a step stalls, so the message keeps moving while the counts do not. () => emitGeneratingStatus(), ); generating = null; currentMediaId = snapshot.mediaId; currentMediaTitle = snapshot.mediaTitle; activeCurrentMediaId = snapshot.mediaId; const state = readAutoSyncState(statePath); const staleMediaIds = new Set( (snapshot.staleMediaIds ?? []) .map((mediaId) => normalizeMediaId(mediaId)) .filter((mediaId): mediaId is number => mediaId !== null), ); const nextActiveMediaIds = [ { mediaId: snapshot.mediaId, label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle), }, ...state.activeMediaIds.filter( (entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId), ), ].slice(0, Math.max(1, Math.floor(config.maxLoaded))); const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId); deps.logInfo?.( `[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds .map((entry) => entry.label) .join(', ')}`, ); const stateMediaIds = state.activeMediaIds.map((entry) => entry.mediaId); const retainedOrderChanged = !arraysEqual(nextActiveMediaIdValues, stateMediaIds); const retainedMembershipChanged = !sameMembership(nextActiveMediaIdValues, stateMediaIds); let merged: MergedCharacterDictionaryBuildResult | null = null; if ( retainedMembershipChanged || !state.mergedRevision || !state.mergedDictionaryTitle || !snapshot.fromCache ) { deps.onSyncStatus?.({ phase: 'building', mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, message: buildBuildingMessage(snapshot.mediaTitle), }); deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set'); merged = await deps.buildMergedDictionary(nextActiveMediaIdValues); } const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle; const revision = merged?.revision ?? state.mergedRevision; if (!dictionaryTitle || !revision) { throw new Error('Merged character dictionary state is incomplete.'); } writeAutoSyncState(statePath, { activeMediaIds: nextActiveMediaIds, mergedRevision: merged?.revision ?? revision, mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle, }); await deps.waitForYomitanMutationReady?.(); const dictionaryInfo = await withOperationTimeout( 'getYomitanDictionaryInfo', deps.getYomitanDictionaryInfo(), ); const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null; const existingRevision = existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number') ? String(existing.revision) : null; const shouldImport = merged !== null || existing === null || existingRevision === null || existingRevision !== revision; let changed = merged !== null || retainedOrderChanged; if (shouldImport) { deps.onSyncStatus?.({ phase: 'importing', mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, message: buildImportingMessage(snapshot.mediaTitle), }); await withHeartbeat( async () => { const importTimeoutMs = resolveImportTimeoutMs( merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'), ); if (existing !== null) { await withTimeout( `deleteYomitanDictionary(${dictionaryTitle})`, deps.deleteYomitanDictionary(dictionaryTitle), importTimeoutMs, ); } if (merged === null) { // The cached archive only stands in for the recorded state when its own index.json // agrees. A stale or half-written ZIP would be imported under the wrong revision, // and every later sync would then see a revision mismatch and re-import it. const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip'); const existingMergedRevision = readDictionaryZipRevision(existingMergedZipPath); if (existingMergedRevision === revision) { merged = { zipPath: existingMergedZipPath, revision, dictionaryTitle, entryCount: snapshot.entryCount, }; } else { deps.logInfo?.( `[dictionary:auto-sync] cached merged ZIP unusable (revision ${existingMergedRevision ?? 'unreadable'}, expected ${revision}); rebuilding`, ); merged = await deps.buildMergedDictionary(nextActiveMediaIdValues); } } const mergedZipPath = merged.zipPath; const mergedImportTimeoutMs = resolveImportTimeoutMs(mergedZipPath); deps.logInfo?.( `[dictionary:auto-sync] importing merged dictionary: ${mergedZipPath} (timeout ${mergedImportTimeoutMs}ms)`, ); const imported = await withTimeout( `importYomitanDictionary(${path.basename(mergedZipPath)})`, deps.importYomitanDictionary(mergedZipPath), mergedImportTimeoutMs, ); if (!imported) { throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`); } }, (elapsedMs) => { deps.onSyncStatus?.({ phase: 'importing', mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, message: buildImportingMessage(snapshot.mediaTitle, elapsedMs), }); }, ); changed = true; } deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`); const settingsUpdated = await withOperationTimeout( `upsertYomitanDictionarySettings(${dictionaryTitle})`, deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope), ); changed = changed || settingsUpdated === true; deps.logInfo?.( `[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`, ); deps.onSyncStatus?.({ phase: 'ready', mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, message: buildReadyMessage(snapshot.mediaTitle), changed, }); deps.onSyncComplete?.({ mediaId: snapshot.mediaId, mediaTitle: snapshot.mediaTitle, changed, }); } catch (error) { const errorMessage = (error as Error)?.message ?? String(error); deps.onSyncStatus?.({ phase: 'failed', mediaId: currentMediaId, mediaTitle: currentMediaTitle ?? undefined, message: buildFailedMessage(currentMediaTitle, errorMessage), }); throw error; } }; const enqueueSync = (): void => { runQueued = true; if (syncInFlight) { return; } syncInFlight = true; void (async () => { while (runQueued) { runQueued = false; try { await runSyncOnce(); } catch (error) { deps.logWarn?.( `[dictionary:auto-sync] sync failed: ${(error as Error)?.message ?? String(error)}`, ); } } })().finally(() => { syncInFlight = false; }); }; return { scheduleSync: () => { const config = deps.getConfig(); if (!config.enabled) { return; } if (debounceTimer !== null) { clearSchedule(debounceTimer); } debounceTimer = schedule(() => { debounceTimer = null; enqueueSync(); }, debounceMs); }, runSyncNow: async () => { await runSyncOnce(); }, getCurrentMediaId: () => activeCurrentMediaId, }; }