feat(launcher): add sync command to merge stats and history between machines over SSH

subminer sync <host> exchanges VACUUM INTO snapshots over ssh/scp and each
side merges the other's data as an insert-only union keyed on session UUIDs,
video keys, series title keys, and word/kanji identity. Lifetime totals and
daily/monthly rollups are applied incrementally so pre-retention history
survives, remote-only historical rollups are copied, and re-syncing is
idempotent. sync --snapshot/--merge expose the underlying steps for manual
transfers; a pid-file/mpv-socket guard refuses to run while SubMiner may be
writing the database and schema-version mismatches abort the merge.
This commit is contained in:
2026-07-08 23:28:10 -07:00
parent cdb1475a54
commit be31f96f02
21 changed files with 2762 additions and 0 deletions
+32
View File
@@ -135,6 +135,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -181,6 +189,14 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -220,6 +236,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -257,6 +281,14 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
+17
View File
@@ -199,6 +199,13 @@ export function createDefaultArgs(
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -264,6 +271,16 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
) {
fail('Dictionary target path is required.');
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? '';
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
+55
View File
@@ -38,6 +38,14 @@ export interface CliInvocations {
statsCleanupVocab: boolean;
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncHost: string | null;
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
@@ -98,6 +106,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'dictionary',
'dict',
'stats',
'sync',
'texthooker',
'app',
'bin',
@@ -160,6 +169,14 @@ export function parseCliPrograms(
let statsCleanupVocab = false;
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncHost: string | null = null;
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
@@ -289,6 +306,36 @@ export function parseCliPrograms(
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('sync')
.description('Sync stats and watch history with another machine over SSH')
.argument('[host]', 'SSH destination (user@host or an ssh config alias)')
.option('--snapshot <file>', 'Write a consistent snapshot of the local stats database')
.option('--merge <file>', 'Merge a snapshot database file into the local stats database')
.option('--db <file>', 'Override the local stats database path')
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
.option('-f, --force', 'Skip the running-app safety check')
.option('--log-level <level>', 'Log level')
.action((host: string | undefined, options: Record<string, unknown>) => {
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
}
syncTriggered = true;
syncHost = host?.trim() || null;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncRemoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('doctor')
.description('Run dependency and environment checks')
@@ -400,6 +447,14 @@ export function parseCliPrograms(
statsCleanupVocab,
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncHost,
syncSnapshotPath,
syncMergePath,
syncRemoteCmd,
syncDbPath,
syncForce,
syncLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,