mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user