mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -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:
@@ -0,0 +1,149 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fail, log } from '../log.js';
|
||||
import { resolveImmersionDbPath } from '../history-db.js';
|
||||
import {
|
||||
createDbSnapshot,
|
||||
findLiveStatsDaemonPid,
|
||||
formatMergeSummary,
|
||||
mergeSnapshotIntoDb,
|
||||
} from '../sync/sync-db.js';
|
||||
import { resolveRemoteSubminerCommand, runScp, runSsh, shellQuote } from '../sync/ssh.js';
|
||||
import { resolvePathMaybe } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function resolveDbPath(context: LauncherCommandContext): string {
|
||||
const override = context.args.syncDbPath.trim();
|
||||
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
|
||||
}
|
||||
|
||||
function isTrackerDb(dbPath: string): boolean {
|
||||
const trackerDbPath = resolveImmersionDbPath();
|
||||
try {
|
||||
return fs.realpathSync(dbPath) === fs.realpathSync(trackerDbPath);
|
||||
} catch {
|
||||
return dbPath === trackerDbPath;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrackerQuiescent(context: LauncherCommandContext, dbPath: string): void {
|
||||
if (context.args.syncForce) return;
|
||||
// A running SubMiner only holds the tracker's own database; --db pointed
|
||||
// elsewhere needs no guard.
|
||||
if (!isTrackerDb(dbPath)) return;
|
||||
const daemonPid = findLiveStatsDaemonPid(dbPath);
|
||||
if (daemonPid !== null) {
|
||||
fail(
|
||||
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
|
||||
);
|
||||
}
|
||||
if (context.mpvSocketPath && fs.existsSync(context.mpvSocketPath)) {
|
||||
fail(
|
||||
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runSnapshotMode(context: LauncherCommandContext, dbPath: string): void {
|
||||
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
|
||||
createDbSnapshot(dbPath, outPath);
|
||||
console.log(outPath);
|
||||
}
|
||||
|
||||
function runMergeMode(context: LauncherCommandContext, dbPath: string): void {
|
||||
ensureTrackerQuiescent(context, dbPath);
|
||||
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
|
||||
const summary = mergeSnapshotIntoDb(dbPath, snapshotPath);
|
||||
console.log(formatMergeSummary(summary));
|
||||
}
|
||||
|
||||
function cleanupRemote(host: string, remoteTmpDir: string): void {
|
||||
if (!remoteTmpDir.startsWith('/tmp/')) return;
|
||||
runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
|
||||
}
|
||||
|
||||
function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
||||
const { args } = context;
|
||||
const host = args.syncHost;
|
||||
|
||||
ensureTrackerQuiescent(context, dbPath);
|
||||
|
||||
const remoteCmd = resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
|
||||
log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
|
||||
|
||||
const localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
|
||||
let remoteTmpDir = '';
|
||||
try {
|
||||
const mktemp = runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
|
||||
remoteTmpDir = mktemp.stdout.trim();
|
||||
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
|
||||
fail(`Could not create a temporary directory on ${host}.`);
|
||||
}
|
||||
|
||||
const forceFlag = args.syncForce ? ' --force' : '';
|
||||
|
||||
console.log(`Snapshotting local database (${dbPath})...`);
|
||||
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
|
||||
createDbSnapshot(dbPath, localSnapshot);
|
||||
|
||||
console.log(`Snapshotting ${host}...`);
|
||||
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
|
||||
const snapshotRun = runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
|
||||
);
|
||||
if (snapshotRun.status !== 0) {
|
||||
fail(`Remote snapshot failed on ${host}.`);
|
||||
}
|
||||
|
||||
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
|
||||
runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
|
||||
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
|
||||
runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
||||
|
||||
console.log(`\nMerging ${host} -> local:`);
|
||||
const summary = mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
||||
console.log(formatMergeSummary(summary));
|
||||
|
||||
console.log(`\nMerging local -> ${host}:`);
|
||||
const mergeRun = runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
|
||||
);
|
||||
process.stdout.write(mergeRun.stdout);
|
||||
if (mergeRun.status !== 0) {
|
||||
fail(
|
||||
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\nSync complete.');
|
||||
} finally {
|
||||
fs.rmSync(localTmpDir, { recursive: true, force: true });
|
||||
if (remoteTmpDir) {
|
||||
try {
|
||||
cleanupRemote(host, remoteTmpDir);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runSyncCommand(context: LauncherCommandContext): boolean {
|
||||
const { args } = context;
|
||||
if (!args.sync) return false;
|
||||
|
||||
const dbPath = resolveDbPath(context);
|
||||
if (args.syncSnapshotPath) {
|
||||
runSnapshotMode(context, dbPath);
|
||||
} else if (args.syncMergePath) {
|
||||
runMergeMode(context, dbPath);
|
||||
} else if (args.syncHost) {
|
||||
runHostSync(context, dbPath);
|
||||
} else {
|
||||
fail('sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user