mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
Compare commits
7 Commits
dfde19cc4d
...
780a3fa8cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
780a3fa8cb
|
|||
|
3d715c8b89
|
|||
|
cbb19864c4
|
|||
|
32c62b076b
|
|||
|
2d2813ec2b
|
|||
|
d57eae4863
|
|||
|
4a43232ffa
|
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: launcher
|
area: launcher
|
||||||
|
|
||||||
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or an mpv session is active (`--force` overrides), keeps that guard in place through local/remote merges, reports remote stderr on failures, and aborts on stats schema version mismatches.
|
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or a live mpv session is active (`--force` overrides), ignores stale mpv socket files, keeps the guard in place through local/remote merges, supplies standard SubMiner and Bun paths to non-interactive SSH commands, verifies the remote launcher starts, reports remote stderr on failures, and aborts on stats schema version mismatches.
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
|
|||||||
|
|
||||||
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
|
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
|
||||||
|
|
||||||
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch.
|
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
|
||||||
|
|
||||||
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||||
- `subminer config`: config file helpers (`path`, `show`).
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Remote Sync Runtime PATH Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`subminer sync <host>` can locate a remote launcher while still failing to run
|
||||||
|
it. The generated launcher uses `#!/usr/bin/env bun`, and non-interactive SSH
|
||||||
|
shells commonly omit Bun's install directory from `PATH`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Remote launcher probes and invocations will use a deterministic `PATH` prefix
|
||||||
|
covering SubMiner and Bun's supported/common locations:
|
||||||
|
|
||||||
|
- `$HOME/.local/bin`
|
||||||
|
- `$HOME/.bun/bin`
|
||||||
|
- `/opt/homebrew/bin`
|
||||||
|
- `/usr/local/bin`
|
||||||
|
- `/usr/bin`
|
||||||
|
- `/bin`
|
||||||
|
|
||||||
|
Resolution will run `<candidate> --help` under that PATH instead of only using
|
||||||
|
`command -v`. This confirms both the launcher and its shebang runtime work
|
||||||
|
before sync creates or transfers snapshots. The same prefixed command returned
|
||||||
|
by resolution will be used for remote snapshot and merge operations.
|
||||||
|
|
||||||
|
`--remote-cmd` remains a launcher path override and receives the same runtime
|
||||||
|
PATH. Remote commands continue to be shell-quoted where user-controlled.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Add focused resolver coverage that simulates an SSH shell where only the
|
||||||
|
prefixed PATH can start SubMiner. Run launcher unit tests, type checking, docs
|
||||||
|
tests/build, and the SubMiner launcher/docs verification lanes.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# SSH Resolver Testability Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The remote launcher resolver test installs a temporary `ssh` executable and
|
||||||
|
mutates `process.env.PATH` after Bun starts. Bun's synchronous process launcher
|
||||||
|
does not reliably use that late PATH change, so the focused test fails locally
|
||||||
|
and in CI even though the command-building behavior is correct.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Allow `resolveRemoteSubminerCommand` to accept an optional remote runner that
|
||||||
|
defaults to the production `runSsh` function. Existing callers retain the same
|
||||||
|
behavior and two-argument API. The focused test will provide a small runner,
|
||||||
|
exercise the real candidate and PATH construction, and assert the host and
|
||||||
|
remote command passed across the SSH boundary.
|
||||||
|
|
||||||
|
Alternatives rejected:
|
||||||
|
|
||||||
|
- Explicitly forwarding `process.env` to `spawnSync` still depends on Bun's
|
||||||
|
executable-resolution behavior and changes production code to support a test
|
||||||
|
harness.
|
||||||
|
- Module-level mocking couples the test to loader behavior and obscures the
|
||||||
|
resolver's actual dependency.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
First update the focused test and confirm it remains red while the resolver
|
||||||
|
ignores the injected runner. Then add the minimal runner parameter and confirm
|
||||||
|
the focused test passes. Run the launcher verification lane, type checking,
|
||||||
|
and the repository's default handoff gate.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Sync Stale Socket Guard Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The sync quiescence guard treats any configured mpv socket path as a running
|
||||||
|
session when the filesystem entry exists. Unix socket files can remain after
|
||||||
|
mpv exits, so a stale `/tmp/subminer-socket` blocks the remote merge even when
|
||||||
|
nothing is listening.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Reuse the launcher's existing Unix socket connection check. The sync guard will
|
||||||
|
block only when it can connect to the configured socket. Missing, stale, or
|
||||||
|
otherwise unreachable socket paths will not block sync, and sync will not
|
||||||
|
delete them.
|
||||||
|
|
||||||
|
Because socket connection checks are asynchronous, the sync dispatch, merge
|
||||||
|
mode, host mode, and quiescence checks will become async. The already-async
|
||||||
|
launcher entrypoint will await sync dispatch. Snapshot, transfer, merge,
|
||||||
|
cleanup, and error behavior otherwise remain unchanged.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Add focused tests proving a stale socket is ignored and a live socket remains a
|
||||||
|
hard failure. Re-run sync command and socket tests, type checking, launcher
|
||||||
|
tests, docs checks, and the SubMiner launcher/docs verification lanes.
|
||||||
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
|
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||||
|
|
||||||
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +35,29 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
|
|||||||
return { status: 0, stdout, stderr: '' };
|
return { status: 0, stdout, stderr: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', () => {
|
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
|
||||||
|
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
|
||||||
|
context.mpvSocketPath = '/tmp/subminer-socket';
|
||||||
|
let socketConnectable = false;
|
||||||
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
|
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
|
||||||
|
findLiveStatsDaemonPid: () => null,
|
||||||
|
canConnectUnixSocket: async () => socketConnectable,
|
||||||
|
fail: (message: string): never => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
|
||||||
|
|
||||||
|
socketConnectable = true;
|
||||||
|
await assert.rejects(
|
||||||
|
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
|
||||||
|
/mpv\/SubMiner session appears to be running/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
createDbSnapshot: (dbPath: string, outPath: string) => {
|
createDbSnapshot: (dbPath: string, outPath: string) => {
|
||||||
@@ -46,7 +68,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
|
|||||||
return createEmptyMergeSummary();
|
return createEmptyMergeSummary();
|
||||||
},
|
},
|
||||||
formatMergeSummary: () => 'summary',
|
formatMergeSummary: () => 'summary',
|
||||||
ensureTrackerQuiescent: () => {
|
ensureTrackerQuiescent: async () => {
|
||||||
calls.push('quiescent');
|
calls.push('quiescent');
|
||||||
},
|
},
|
||||||
assertSafeSshHost: (host: string) => {
|
assertSafeSshHost: (host: string) => {
|
||||||
@@ -66,7 +88,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
runSyncCommand(
|
await runSyncCommand(
|
||||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||||
deps,
|
deps,
|
||||||
),
|
),
|
||||||
@@ -74,23 +96,26 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
|
|||||||
);
|
);
|
||||||
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
||||||
|
|
||||||
runSyncCommand(
|
await runSyncCommand(
|
||||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
|
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
assert.ok(calls.includes('quiescent'));
|
assert.ok(calls.includes('quiescent'));
|
||||||
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
|
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
|
||||||
|
|
||||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
|
await runSyncCommand(
|
||||||
|
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
|
||||||
|
deps,
|
||||||
|
);
|
||||||
assert.ok(calls.includes('host:media-box'));
|
assert.ok(calls.includes('host:media-box'));
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
|
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
|
||||||
/sync requires a host, --snapshot <file>, or --merge <file>/,
|
/sync requires a host, --snapshot <file>, or --merge <file>/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', () => {
|
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let localTmpDir = '';
|
let localTmpDir = '';
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
@@ -103,7 +128,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
|
|||||||
return createEmptyMergeSummary();
|
return createEmptyMergeSummary();
|
||||||
},
|
},
|
||||||
formatMergeSummary: () => 'summary',
|
formatMergeSummary: () => 'summary',
|
||||||
ensureTrackerQuiescent: () => {
|
ensureTrackerQuiescent: async () => {
|
||||||
calls.push('quiescent');
|
calls.push('quiescent');
|
||||||
},
|
},
|
||||||
assertSafeSshHost: () => {},
|
assertSafeSshHost: () => {},
|
||||||
@@ -127,7 +152,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||||
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
|
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
|
||||||
@@ -139,12 +164,12 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
|
|||||||
assert.equal(fs.existsSync(localTmpDir), false);
|
assert.equal(fs.existsSync(localTmpDir), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runHostSync includes remote snapshot stderr in failures', () => {
|
test('runHostSync includes remote snapshot stderr in failures', async () => {
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||||
fs.writeFileSync(outPath, 'snapshot');
|
fs.writeFileSync(outPath, 'snapshot');
|
||||||
},
|
},
|
||||||
ensureTrackerQuiescent: () => {},
|
ensureTrackerQuiescent: async () => {},
|
||||||
assertSafeSshHost: () => {},
|
assertSafeSshHost: () => {},
|
||||||
resolveRemoteSubminerCommand: () => 'subminer',
|
resolveRemoteSubminerCommand: () => 'subminer',
|
||||||
runSsh: (_host: string, command: string) => {
|
runSsh: (_host: string, command: string) => {
|
||||||
@@ -157,7 +182,7 @@ test('runHostSync includes remote snapshot stderr in failures', () => {
|
|||||||
runScp: () => {},
|
runScp: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||||
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
|
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
shellQuote,
|
shellQuote,
|
||||||
} from '../sync/ssh.js';
|
} from '../sync/ssh.js';
|
||||||
import { resolvePathMaybe } from '../util.js';
|
import { resolvePathMaybe } from '../util.js';
|
||||||
|
import { canConnectUnixSocket } from '../mpv.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import type { RemoteRunResult } from '../sync/ssh.js';
|
import type { RemoteRunResult } from '../sync/ssh.js';
|
||||||
|
|
||||||
@@ -31,13 +32,13 @@ export interface SyncCommandDeps {
|
|||||||
runSsh: typeof runSsh;
|
runSsh: typeof runSsh;
|
||||||
fail: typeof fail;
|
fail: typeof fail;
|
||||||
log: typeof log;
|
log: typeof log;
|
||||||
existsSync: typeof fs.existsSync;
|
canConnectUnixSocket: typeof canConnectUnixSocket;
|
||||||
realpathSync: typeof fs.realpathSync;
|
realpathSync: typeof fs.realpathSync;
|
||||||
mkdtempSync: typeof fs.mkdtempSync;
|
mkdtempSync: typeof fs.mkdtempSync;
|
||||||
rmSync: typeof fs.rmSync;
|
rmSync: typeof fs.rmSync;
|
||||||
consoleLog: typeof console.log;
|
consoleLog: typeof console.log;
|
||||||
writeStdout: typeof process.stdout.write;
|
writeStdout: typeof process.stdout.write;
|
||||||
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => void;
|
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDbPath(context: LauncherCommandContext): string {
|
function resolveDbPath(context: LauncherCommandContext): string {
|
||||||
@@ -54,11 +55,11 @@ function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureTrackerQuiescent(
|
export async function ensureTrackerQuiescent(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
inputDeps: Partial<SyncCommandDeps> = {},
|
||||||
): void {
|
): Promise<void> {
|
||||||
const deps = resolveSyncCommandDeps(inputDeps);
|
const deps = resolveSyncCommandDeps(inputDeps);
|
||||||
if (context.args.syncForce) return;
|
if (context.args.syncForce) return;
|
||||||
// A running SubMiner only holds the tracker's own database; --db pointed
|
// A running SubMiner only holds the tracker's own database; --db pointed
|
||||||
@@ -70,7 +71,7 @@ export function ensureTrackerQuiescent(
|
|||||||
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
|
`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 && deps.existsSync(context.mpvSocketPath)) {
|
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
|
||||||
deps.fail(
|
deps.fail(
|
||||||
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
|
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
|
||||||
);
|
);
|
||||||
@@ -88,13 +89,13 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
|
|||||||
runSsh,
|
runSsh,
|
||||||
fail,
|
fail,
|
||||||
log,
|
log,
|
||||||
existsSync: fs.existsSync,
|
canConnectUnixSocket,
|
||||||
realpathSync: fs.realpathSync,
|
realpathSync: fs.realpathSync,
|
||||||
mkdtempSync: fs.mkdtempSync,
|
mkdtempSync: fs.mkdtempSync,
|
||||||
rmSync: fs.rmSync,
|
rmSync: fs.rmSync,
|
||||||
consoleLog: console.log,
|
consoleLog: console.log,
|
||||||
writeStdout: process.stdout.write.bind(process.stdout),
|
writeStdout: process.stdout.write.bind(process.stdout),
|
||||||
ensureTrackerQuiescent: (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
|
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
|
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
|
||||||
@@ -112,13 +113,13 @@ export function runSnapshotMode(
|
|||||||
deps.consoleLog(outPath);
|
deps.consoleLog(outPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runMergeMode(
|
export async function runMergeMode(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
inputDeps: Partial<SyncCommandDeps> = {},
|
||||||
): void {
|
): Promise<void> {
|
||||||
const deps = resolveSyncCommandDeps(inputDeps);
|
const deps = resolveSyncCommandDeps(inputDeps);
|
||||||
deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
|
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
|
||||||
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
|
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
|
||||||
deps.consoleLog(deps.formatMergeSummary(summary));
|
deps.consoleLog(deps.formatMergeSummary(summary));
|
||||||
@@ -134,17 +135,17 @@ function formatRemoteRunError(message: string, run: RemoteRunResult): string {
|
|||||||
return stderr ? `${message}\n${stderr}` : message;
|
return stderr ? `${message}\n${stderr}` : message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runHostSync(
|
export async function runHostSync(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
inputDeps: Partial<SyncCommandDeps> = {},
|
||||||
): void {
|
): Promise<void> {
|
||||||
const deps = resolveSyncCommandDeps(inputDeps);
|
const deps = resolveSyncCommandDeps(inputDeps);
|
||||||
const { args } = context;
|
const { args } = context;
|
||||||
const host = args.syncHost;
|
const host = args.syncHost;
|
||||||
deps.assertSafeSshHost(host);
|
deps.assertSafeSshHost(host);
|
||||||
|
|
||||||
deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
|
|
||||||
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
|
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
|
||||||
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
|
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
|
||||||
@@ -183,12 +184,12 @@ export function runHostSync(
|
|||||||
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
||||||
|
|
||||||
deps.consoleLog(`\nMerging ${host} -> local:`);
|
deps.consoleLog(`\nMerging ${host} -> local:`);
|
||||||
deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
||||||
deps.consoleLog(deps.formatMergeSummary(summary));
|
deps.consoleLog(deps.formatMergeSummary(summary));
|
||||||
|
|
||||||
deps.consoleLog(`\nMerging local -> ${host}:`);
|
deps.consoleLog(`\nMerging local -> ${host}:`);
|
||||||
deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
const mergeRun = deps.runSsh(
|
const mergeRun = deps.runSsh(
|
||||||
host,
|
host,
|
||||||
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
|
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
|
||||||
@@ -216,10 +217,10 @@ export function runHostSync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runSyncCommand(
|
export async function runSyncCommand(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
inputDeps: Partial<SyncCommandDeps> = {},
|
||||||
): boolean {
|
): Promise<boolean> {
|
||||||
const deps = resolveSyncCommandDeps(inputDeps);
|
const deps = resolveSyncCommandDeps(inputDeps);
|
||||||
const { args } = context;
|
const { args } = context;
|
||||||
if (!args.sync) return false;
|
if (!args.sync) return false;
|
||||||
@@ -228,9 +229,9 @@ export function runSyncCommand(
|
|||||||
if (args.syncSnapshotPath) {
|
if (args.syncSnapshotPath) {
|
||||||
runSnapshotMode(context, dbPath, deps);
|
runSnapshotMode(context, dbPath, deps);
|
||||||
} else if (args.syncMergePath) {
|
} else if (args.syncMergePath) {
|
||||||
runMergeMode(context, dbPath, deps);
|
await runMergeMode(context, dbPath, deps);
|
||||||
} else if (args.syncHost) {
|
} else if (args.syncHost) {
|
||||||
runHostSync(context, dbPath, deps);
|
await runHostSync(context, dbPath, deps);
|
||||||
} else {
|
} else {
|
||||||
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
|
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runSyncCommand(context)) {
|
if (await runSyncCommand(context)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1683,7 +1683,7 @@ async function sleepMs(ms: number): Promise<void> {
|
|||||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
export async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||||
return await new Promise<boolean>((resolve) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
const socket = net.createConnection(socketPath);
|
const socket = net.createConnection(socketPath);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { assertSafeSshHost, runScp, shellQuote } from './ssh.js';
|
import { assertSafeSshHost, resolveRemoteSubminerCommand, runScp, shellQuote } from './ssh.js';
|
||||||
|
|
||||||
test('assertSafeSshHost rejects option-like hosts', () => {
|
test('assertSafeSshHost rejects option-like hosts', () => {
|
||||||
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
||||||
@@ -29,3 +29,23 @@ test('runScp rejects option-like remote host components', () => {
|
|||||||
/SSH host that looks like an option/,
|
/SSH host that looks like an option/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
|
||||||
|
const calls: Array<{ host: string; remoteCommand: string }> = [];
|
||||||
|
const command = resolveRemoteSubminerCommand('macbook', null, (host, remoteCommand) => {
|
||||||
|
calls.push({ host, remoteCommand });
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
command,
|
||||||
|
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{
|
||||||
|
host: 'macbook',
|
||||||
|
remoteCommand:
|
||||||
|
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
+18
-10
@@ -71,23 +71,31 @@ export function shellQuote(value: string): string {
|
|||||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REMOTE_RUNTIME_PATH =
|
||||||
|
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-interactive SSH shells often miss ~/.local/bin in PATH, so probe the
|
* Non-interactive SSH shells often miss user-installed launchers and Bun.
|
||||||
* configured command first and fall back to the default install location.
|
* Probe the launcher under the same deterministic PATH used by sync itself.
|
||||||
*/
|
*/
|
||||||
export function resolveRemoteSubminerCommand(host: string, preferred: string | null): string {
|
export function resolveRemoteSubminerCommand(
|
||||||
// Trusted defaults stay unquoted so the remote shell expands `~`; the
|
host: string,
|
||||||
|
preferred: string | null,
|
||||||
|
runRemote: typeof runSsh = runSsh,
|
||||||
|
): string {
|
||||||
|
// Trusted defaults stay unquoted so the remote shell expands `~`; a
|
||||||
// user-supplied override is shell-quoted to prevent command injection.
|
// user-supplied override is shell-quoted to prevent command injection.
|
||||||
const candidates: Array<{ value: string; probe: string }> = preferred
|
const candidates: Array<{ value: string; invocation: string }> = preferred
|
||||||
? [{ value: preferred, probe: shellQuote(preferred) }]
|
? [{ value: preferred, invocation: shellQuote(preferred) }]
|
||||||
: [
|
: [
|
||||||
{ value: 'subminer', probe: 'subminer' },
|
{ value: 'subminer', invocation: 'subminer' },
|
||||||
{ value: '~/.local/bin/subminer', probe: '~/.local/bin/subminer' },
|
{ value: '~/.local/bin/subminer', invocation: '~/.local/bin/subminer' },
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const probe = runSsh(host, `command -v ${candidate.probe} >/dev/null 2>&1`);
|
const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`;
|
||||||
|
const probe = runRemote(host, `${command} --help >/dev/null 2>&1`);
|
||||||
if (probe.status === 0) {
|
if (probe.status === 0) {
|
||||||
return candidate.value;
|
return command;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function insertFixtureSession(dbPath: string, input: FixtureSessionInput)
|
|||||||
const db = new Database(dbPath, { readwrite: true });
|
const db = new Database(dbPath, { readwrite: true });
|
||||||
const stamp = String(input.startedAtMs);
|
const stamp = String(input.startedAtMs);
|
||||||
try {
|
try {
|
||||||
|
db.run('PRAGMA foreign_keys = ON');
|
||||||
let animeId: number | null = null;
|
let animeId: number | null = null;
|
||||||
if (input.animeTitleKey) {
|
if (input.animeTitleKey) {
|
||||||
const existing = db
|
const existing = db
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database as BunDatabase } from 'bun:sqlite';
|
import { Database as BunDatabase } from 'bun:sqlite';
|
||||||
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
|
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
|
||||||
import { createImmersionDbFixture } from './immersion-db-fixture.js';
|
import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
|
||||||
|
|
||||||
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
|
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
|
||||||
|
|
||||||
@@ -107,3 +107,35 @@ test('fixture schema stays aligned with production sync-touched tables and index
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fixture session inserts enforce foreign keys', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
|
||||||
|
const fixturePath = path.join(dir, 'fixture.sqlite');
|
||||||
|
try {
|
||||||
|
createImmersionDbFixture(fixturePath);
|
||||||
|
const db = new BunDatabase(fixturePath, { readwrite: true });
|
||||||
|
try {
|
||||||
|
db.run(`
|
||||||
|
CREATE TRIGGER remove_fixture_video
|
||||||
|
BEFORE INSERT ON imm_sessions
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM imm_videos WHERE video_id = NEW.video_id;
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
insertFixtureSession(fixturePath, {
|
||||||
|
uuid: 'foreign-key-check',
|
||||||
|
videoKey: 'foreign-key-check',
|
||||||
|
startedAtMs: Date.UTC(2026, 6, 9),
|
||||||
|
}),
|
||||||
|
/FOREIGN KEY constraint failed/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user