diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index d20d4ec2..eb4ce9be 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -114,6 +114,8 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t `subminer sync --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes). +`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp ` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically. + ### Sync window `subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine: diff --git a/docs-site/usage.md b/docs-site/usage.md index 28d1d96a..23ad7a3c 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -122,8 +122,13 @@ subminer mpv idle # Launch detached idle mpv with SubMiner defau subminer sync media-box # Sync stats/watch history with an SSH host subminer sync media-box --push # Merge this machine's stats into the host only subminer sync media-box --pull # Merge the host's stats into this machine only +subminer sync media-box --check # Verify SSH and remote SubMiner without syncing +subminer sync media-box --json # Emit machine-readable NDJSON progress +subminer sync --ui # Open the Sync Stats & History window subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB +subminer sync --make-temp # Create an internal sync temp directory +subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import) subminer dictionary --candidates /path/to/file.mkv subminer dictionary --select 21355 /path/to/file.mkv @@ -159,12 +164,16 @@ SubMiner.AppImage --jellyfin-libraries SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20 SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start) SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check +SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help +SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series SubMiner.AppImage --help # Show all options ``` +`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically. + The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk. Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. @@ -367,7 +376,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh | Keybind | Action | Scope | | ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | | `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) | -| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window | +| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window | `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. diff --git a/launcher/history-db.ts b/launcher/history-db.ts index 66dc20b7..e9199043 100644 --- a/launcher/history-db.ts +++ b/launcher/history-db.ts @@ -6,6 +6,12 @@ import { resolveConfigDir } from '../src/config/path-resolution.js'; import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import type { HistoryVideoRow } from './history-types.js'; import { resolvePathMaybe } from './util.js'; +import { + isReadonlyWalRetryError, + withReadonlyWalRetry, +} from '../src/core/services/stats-sync/wal-retry.js'; + +export { isReadonlyWalRetryError, withReadonlyWalRetry }; export function resolveImmersionDbPath(): string { const root = readLauncherMainConfigObject(); @@ -43,12 +49,6 @@ export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] { return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options)); } -export { - withReadonlyWalRetry, - isReadonlyWalRetryError, -} from '../src/core/services/stats-sync/wal-retry.js'; -import { withReadonlyWalRetry } from '../src/core/services/stats-sync/wal-retry.js'; - function tableExists(db: Database, tableName: string): boolean { return Boolean( db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName), diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 208887e4..8b18250d 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; +import type { StdioOptions } from 'node:child_process'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js'; import { @@ -1444,22 +1445,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget } export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void { - if (maybeCaptureAppArgs(appArgs)) { - process.exit(0); - } - - const target = resolveAppSpawnTarget(appPath, appArgs); - const proc = spawn(target.command, target.args, { - stdio: ['ignore', 'pipe', 'pipe'], - env: buildAppEnv(process.env, target.env), - }); - attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); - proc.once('error', (error) => { - fail(`Failed to run app command: ${error.message}`); - }); - proc.once('close', (code) => { - process.exit(code ?? 0); - }); + runAppCommand(appPath, appArgs, ['ignore', 'pipe', 'pipe'], true); } /** @@ -1469,15 +1455,27 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo * Used for `subminer sync`, which proxies to the app's --sync-cli mode. */ export function runAppCommandInteractive(appPath: string, appArgs: string[]): void { + runAppCommand(appPath, appArgs, ['inherit', 'inherit', 'inherit'], false); +} + +function runAppCommand( + appPath: string, + appArgs: string[], + stdio: StdioOptions, + attachLogging: boolean, +): void { if (maybeCaptureAppArgs(appArgs)) { process.exit(0); } const target = resolveAppSpawnTarget(appPath, appArgs); const proc = spawn(target.command, target.args, { - stdio: ['inherit', 'inherit', 'inherit'], + stdio, env: buildAppEnv(process.env, target.env), }); + if (attachLogging) { + attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); + } proc.once('error', (error) => { fail(`Failed to run app command: ${error.message}`); }); diff --git a/src/cli/help.ts b/src/cli/help.ts index 158a58c0..cb0e1e47 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,7 +82,7 @@ ${B}Jellyfin${R} ${B}Stats sync${R} --sync-window Open the stats sync window --sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R} - ${D}run --sync-cli --help for details)${R} + ${D}run SubMiner --sync-cli --help for details)${R} ${B}Options${R} --socket ${D}PATH${R} mpv IPC socket path diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index fc88263f..b939d3d9 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -140,6 +140,38 @@ test('startAppLifecycle still acquires lock for startup commands', () => { assert.equal(getLockCalls(), 1); }); +test('startAppLifecycle defers quit until async cleanup settles', async () => { + let willQuit: ((event: { preventDefault(): void }) => void) | null = null; + let releaseCleanup: (() => void) | null = null; + const cleanupDone = new Promise((resolve) => { + releaseCleanup = resolve; + }); + let prevented = false; + const { deps, calls } = createDeps({ + shouldStartApp: () => true, + onWillQuit: (handler) => { + willQuit = handler; + }, + onWillQuitCleanup: () => cleanupDone, + }); + + startAppLifecycle(makeArgs({ start: true }), deps); + assert.ok(willQuit); + (willQuit as (event: { preventDefault(): void }) => void)({ + preventDefault: () => { + prevented = true; + }, + }); + assert.equal(prevented, true); + assert.deepEqual(calls, []); + + assert.ok(releaseCleanup); + (releaseCleanup as () => void)(); + await cleanupDone; + await Promise.resolve(); + assert.deepEqual(calls, ['quitApp']); +}); + test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => { const { deps, calls, getLockCalls } = createDeps({ shouldStartApp: () => false, @@ -252,7 +284,7 @@ test('startAppLifecycle routes control socket commands through the second-instan }, }); - let willQuitHandler: (() => void) | null = null; + let willQuitHandler: ((event: { preventDefault(): void }) => void) | null = null; deps.onWillQuit = (handler) => { willQuitHandler = handler; }; @@ -274,7 +306,7 @@ test('startAppLifecycle routes control socket commands through the second-instan assert.deepEqual(handled, ['ready', 'second-instance:start']); assert.ok(willQuitHandler); - (willQuitHandler as () => void)(); + (willQuitHandler as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} }); assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); }); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index c82adfde..e6c7dd08 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -16,11 +16,11 @@ export interface AppLifecycleServiceDeps { startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; whenReady: (handler: () => Promise) => void; onWindowAllClosed: (handler: () => void) => void; - onWillQuit: (handler: () => void) => void; + onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void; onActivate: (handler: () => void) => void; isDarwinPlatform: () => boolean; onReady: () => Promise; - onWillQuitCleanup: () => void; + onWillQuitCleanup: () => void | Promise; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; shouldQuitOnWindowAllClosed: () => boolean; @@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions { logNoRunningInstance: () => void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; onReady: () => Promise; - onWillQuitCleanup: () => void; + onWillQuitCleanup: () => void | Promise; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; shouldQuitOnWindowAllClosed: () => boolean; @@ -189,10 +189,29 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic } }); - deps.onWillQuit(() => { + let quitCleanupPending = false; + let quitCleanupComplete = false; + deps.onWillQuit((event) => { + if (quitCleanupComplete) return; stopControlServer?.(); stopControlServer = null; - deps.onWillQuitCleanup(); + if (quitCleanupPending) { + event.preventDefault(); + return; + } + const cleanup = deps.onWillQuitCleanup(); + if (!(cleanup instanceof Promise)) return; + quitCleanupPending = true; + event.preventDefault(); + void cleanup + .catch((error) => { + logger.error('App quit cleanup failed:', error); + }) + .finally(() => { + quitCleanupPending = false; + quitCleanupComplete = true; + deps.quitApp(); + }); }); deps.onActivate(() => { diff --git a/src/core/services/stats-sync/shared.ts b/src/core/services/stats-sync/shared.ts index aefd5b3d..c929b1c8 100644 --- a/src/core/services/stats-sync/shared.ts +++ b/src/core/services/stats-sync/shared.ts @@ -8,8 +8,8 @@ import { selectOne, type OpenSyncDb, type SyncDb } from './driver'; export { SCHEMA_VERSION }; -export type { SyncMergeSummary } from '../../../shared/sync/sync-events'; import type { SyncMergeSummary } from '../../../shared/sync/sync-events'; +export type { SyncMergeSummary }; export function createEmptyMergeSummary(): SyncMergeSummary { return { diff --git a/src/core/services/stats-sync/ssh.test.ts b/src/core/services/stats-sync/ssh.test.ts index 17d0c4d1..e82a420c 100644 --- a/src/core/services/stats-sync/ssh.test.ts +++ b/src/core/services/stats-sync/ssh.test.ts @@ -64,10 +64,15 @@ test('resolveRemoteSubminerCommand verifies the launcher under the remote runtim test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => { const probed: string[] = []; - const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => { - probed.push(remoteCommand); - return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1); - }); + const command = resolveRemoteSubminerCommand( + 'media-box', + null, + 'posix', + (_host, remoteCommand) => { + probed.push(remoteCommand); + return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1); + }, + ); assert.match(command, / SubMiner --sync-cli$/); // Launcher candidates (PATH + ~/.local/bin) are tried before app binaries. @@ -77,13 +82,19 @@ test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mo }); test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => { - const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) => - remoteResult(cmd.includes('--sync-cli') ? 0 : 1), + const asApp = resolveRemoteSubminerCommand( + 'media-box', + '/opt/SubMiner.AppImage', + 'posix', + (_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1), ); assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/); - const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) => - remoteResult(cmd.includes('--sync-cli') ? 1 : 0), + const asLauncher = resolveRemoteSubminerCommand( + 'media-box', + '/opt/subminer', + 'posix', + (_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0), ); assert.match(asLauncher, /'\/opt\/subminer'$/); @@ -104,8 +115,11 @@ test('resolveRemoteSubminerCommand probes Windows install locations without a PA assert.equal(probed[0], 'subminer --help'); assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help'); - const powershell = resolveRemoteSubminerCommand('win-box', null, 'windows-powershell', (_host, cmd) => - remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1), + const powershell = resolveRemoteSubminerCommand( + 'win-box', + null, + 'windows-powershell', + (_host, cmd) => remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1), ); assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli'); }); @@ -158,6 +172,7 @@ test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values', '"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"', ); assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/); + assert.throws(() => quoteForRemoteShell('windows-cmd', 'C:/tmp/%TEMP%/db.sqlite'), /percent/); assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/); }); diff --git a/src/core/services/stats-sync/ssh.ts b/src/core/services/stats-sync/ssh.ts index c364b8d0..1216a9fa 100644 --- a/src/core/services/stats-sync/ssh.ts +++ b/src/core/services/stats-sync/ssh.ts @@ -35,6 +35,7 @@ export function runSsh(host: string, remoteCommand: string): RemoteRunResult { } function assertSafeScpEndpoint(endpoint: string): void { + if (/^[A-Za-z]:[\\/]/.test(endpoint)) return; const colon = endpoint.indexOf(':'); const slash = endpoint.indexOf('/'); if (colon <= 0 || (slash !== -1 && slash < colon)) { @@ -116,6 +117,9 @@ export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): s if (value.includes('"')) { throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`); } + if (value.includes('%')) { + throw new Error(`Refusing to quote a value with percent signs for cmd.exe: ${value}`); + } return `"${value}"`; } diff --git a/src/core/services/stats-sync/sync-flow.test.ts b/src/core/services/stats-sync/sync-flow.test.ts index e6e8f3fd..ac2b729a 100644 --- a/src/core/services/stats-sync/sync-flow.test.ts +++ b/src/core/services/stats-sync/sync-flow.test.ts @@ -136,6 +136,9 @@ test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', a true, ); assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite')); + assert.ok( + calls.indexOf('quiescent') < calls.indexOf('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'), + ); await runSyncFlow( makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }), @@ -200,7 +203,8 @@ test('runHostSync keeps tracker quiescent through both merges and cleans up afte }); await assert.rejects( - () => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps), + () => + runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps), /Remote merge failed on media-box[\s\S]*remote merge exploded/, ); assert.equal(calls.filter((call) => call === 'quiescent').length, 3); @@ -222,7 +226,8 @@ test('runHostSync includes remote snapshot stderr in failures', async () => { }); await assert.rejects( - () => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps), + () => + runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps), /Remote snapshot failed on media-box[\s\S]*snapshot permission denied/, ); }); @@ -357,7 +362,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => { }); await runSyncFlow( - makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }), + makeContext({ + syncDbPath: '/tmp/local.sqlite', + syncHost: 'media-box', + syncCheck: true, + syncJson: true, + }), deps, ); @@ -374,7 +384,12 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => { const failLines: string[] = []; await assert.rejects(() => runSyncFlow( - makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }), + makeContext({ + syncDbPath: '/tmp/local.sqlite', + syncHost: 'media-box', + syncCheck: true, + syncJson: true, + }), makeDeps({ consoleLog: (line) => { failLines.push(line); @@ -424,7 +439,9 @@ test('runHostSync speaks Windows shells: app command, double quotes, temp protoc assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`))); assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`))); // No POSIX shell-isms reach a Windows remote. - assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm '))); + assert.ok( + !sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')), + ); assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME'))); }); diff --git a/src/core/services/stats-sync/sync-flow.ts b/src/core/services/stats-sync/sync-flow.ts index cb69101a..c1b1a7e5 100644 --- a/src/core/services/stats-sync/sync-flow.ts +++ b/src/core/services/stats-sync/sync-flow.ts @@ -145,11 +145,12 @@ export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps { }; } -export function runSnapshotMode( +export async function runSnapshotMode( context: SyncFlowContext, dbPath: string, deps: SyncFlowDeps, -): void { +): Promise { + await deps.ensureTrackerQuiescent(context, dbPath); const outPath = deps.resolvePath(context.args.syncSnapshotPath); deps.emitEvent({ type: 'stage', @@ -409,7 +410,10 @@ export async function runHostSync( } } -export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowDeps): Promise { +export async function runSyncFlow( + context: SyncFlowContext, + inputDeps: SyncFlowDeps, +): Promise { let deps = inputDeps; const { args } = context; if (!args.sync) return false; @@ -430,7 +434,7 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD if (args.syncCheck) { await runCheckMode(context, deps); } else if (args.syncSnapshotPath) { - runSnapshotMode(context, dbPath, deps); + await runSnapshotMode(context, dbPath, deps); } else if (args.syncMergePath) { await runMergeMode(context, dbPath, deps); } else if (args.syncHost) { diff --git a/src/main.ts b/src/main.ts index 07451132..180ce1a0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3951,7 +3951,10 @@ const { annotationSubtitleWsService.stop(); }, stopTexthookerService: () => texthookerService.stop(), - stopSyncAutoScheduler: () => syncAutoScheduler.stop(), + stopSyncAutoScheduler: async () => { + syncAutoScheduler.stop(); + await syncUiRuntime.shutdown(); + }, clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop(), clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 8c72b2c7..e8f87b70 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput { logNoRunningInstance: () => void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; onReady: () => Promise; - onWillQuitCleanup: () => void; + onWillQuitCleanup: () => void | Promise; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; shouldQuitOnWindowAllClosed: () => boolean; diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 96fcda8f..11e75df0 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -16,7 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => { unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), - stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'), + stopSyncAutoScheduler: () => { + calls.push('stop-sync-auto-scheduler'); + }, clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'), clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 3159ed6b..16f4130a 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -6,7 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: { unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; - stopSyncAutoScheduler: () => void; + stopSyncAutoScheduler: () => void | Promise; clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; destroyMainOverlayWindow: () => void; @@ -34,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: { cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { - return (): void => { + return (): Promise => { deps.destroyTray(); deps.stopConfigHotReload(); deps.restorePreviousSecondarySubVisibility(); @@ -42,7 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.unregisterAllGlobalShortcuts(); deps.stopSubtitleWebsocket(); deps.stopTexthookerService(); - deps.stopSyncAutoScheduler(); + const stopSyncAutoScheduler = deps.stopSyncAutoScheduler(); deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(); deps.destroyMainOverlayWindow(); @@ -72,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeMediaCache(); deps.stopDiscordPresenceService(); + return Promise.resolve(stopSyncAutoScheduler); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 51212ef1..373df2f9 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -19,7 +19,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), - stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'), + stopSyncAutoScheduler: () => { + calls.push('stop-sync-auto-scheduler'); + }, clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-foreground-poll-loop'), clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index d57cbb1f..54b751f7 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -26,7 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; - stopSyncAutoScheduler: () => void; + stopSyncAutoScheduler: () => void | Promise; clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; getMainOverlayWindow: () => DestroyableWindow | null; diff --git a/src/main/runtime/startup-lifecycle-main-deps.test.ts b/src/main/runtime/startup-lifecycle-main-deps.test.ts index 29c2b71a..b2cf50f6 100644 --- a/src/main/runtime/startup-lifecycle-main-deps.test.ts +++ b/src/main/runtime/startup-lifecycle-main-deps.test.ts @@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks', onReady: async () => { calls.push('ready'); }, - onWillQuitCleanup: () => calls.push('cleanup'), + onWillQuitCleanup: () => { + calls.push('cleanup'); + }, shouldRestoreWindowsOnActivate: () => true, restoreWindowsOnActivate: () => calls.push('restore'), shouldQuitOnWindowAllClosed: () => false, diff --git a/src/main/runtime/startup-runtime-handlers.test.ts b/src/main/runtime/startup-runtime-handlers.test.ts index 5c9617a0..a38f58e8 100644 --- a/src/main/runtime/startup-runtime-handlers.test.ts +++ b/src/main/runtime/startup-runtime-handlers.test.ts @@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap onReady: async () => { calls.push('ready'); }, - onWillQuitCleanup: () => calls.push('cleanup'), + onWillQuitCleanup: () => { + calls.push('cleanup'); + }, shouldRestoreWindowsOnActivate: () => true, restoreWindowsOnActivate: () => calls.push('restore'), shouldQuitOnWindowAllClosed: () => false, diff --git a/src/main/runtime/sync-auto-scheduler.test.ts b/src/main/runtime/sync-auto-scheduler.test.ts index 60da10c4..be66bb30 100644 --- a/src/main/runtime/sync-auto-scheduler.test.ts +++ b/src/main/runtime/sync-auto-scheduler.test.ts @@ -91,6 +91,28 @@ test('tick triggers at most one host per tick', () => { assert.equal(triggered.length, 1); }); +test('tick logs a synchronous trigger failure and remains usable', () => { + const state = makeState(); + const logs: string[] = []; + let attempts = 0; + const scheduler = createSyncAutoScheduler({ + readState: () => state, + isRunning: () => false, + canAutoSync: () => true, + triggerHostSync: () => { + attempts += 1; + throw new Error('spawn exploded'); + }, + nowMs: () => 100 * 60_000, + log: (message) => logs.push(message), + }); + + assert.doesNotThrow(() => scheduler.tick()); + assert.doesNotThrow(() => scheduler.tick()); + assert.equal(attempts, 2); + assert.ok(logs.some((message) => message.includes('spawn exploded'))); +}); + test('start/stop manage the interval timer', () => { const state = makeState(); let setCalls = 0; diff --git a/src/main/runtime/sync-auto-scheduler.ts b/src/main/runtime/sync-auto-scheduler.ts index 844af953..8930992c 100644 --- a/src/main/runtime/sync-auto-scheduler.ts +++ b/src/main/runtime/sync-auto-scheduler.ts @@ -30,7 +30,13 @@ export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) { ); if (!due) return; deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`); - deps.triggerHostSync(due.host, due.direction); + try { + deps.triggerHostSync(due.host, due.direction); + } catch (error) { + deps.log?.( + `Auto-sync failed to start: ${error instanceof Error ? error.message : String(error)}`, + ); + } } function start(): void { diff --git a/src/main/runtime/sync-ui-runtime.test.ts b/src/main/runtime/sync-ui-runtime.test.ts index dcad24db..956100a9 100644 --- a/src/main/runtime/sync-ui-runtime.test.ts +++ b/src/main/runtime/sync-ui-runtime.test.ts @@ -14,7 +14,7 @@ interface LauncherCall { cancelled: boolean; } -function makeTestRig(root: string) { +function makeTestRig(root: string, overrides: Partial = {}) { const launcherCalls: LauncherCall[] = []; const sent: Array<{ channel: string; payload: unknown }> = []; const handlers = new Map unknown>(); @@ -59,6 +59,7 @@ function makeTestRig(root: string) { pickSnapshotFile: async () => null, revealPath: () => {}, nowMs: () => 1700000000000, + ...overrides, }; const runtime = createSyncUiRuntime(deps); @@ -230,6 +231,53 @@ test('check-host resolves with the check-result event', () => assert.equal(result.sshOk, true); })); +test('check-host participates in active-run coordination and can be cancelled', () => + withTempDir(async (root) => { + const { invoke, launcherCalls } = makeTestRig(root); + const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>; + await new Promise((resolve) => setImmediate(resolve)); + + const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as { + started: boolean; + reason: string | null; + }; + assert.equal(blocked.started, false); + assert.match(blocked.reason ?? '', /already running/i); + + assert.equal(await invoke('sync-ui:cancel-run'), true); + assert.equal(launcherCalls[0]!.cancelled, true); + assert.equal((await pending).ok, false); + })); + +test('shutdown cancels and awaits the active launcher run', () => + withTempDir(async (root) => { + const { runtime, invoke, launcherCalls } = makeTestRig(root); + await invoke('sync-ui:create-snapshot'); + await runtime.shutdown(); + assert.equal(launcherCalls[0]!.cancelled, true); + assert.equal(runtime.isRunning(), false); + })); + +test('post-run side-effect failures are logged without rejecting cleanup', () => + withTempDir(async (root) => { + const logs: string[] = []; + const { invoke, launcherCalls } = makeTestRig(root, { + getWindow: () => ({ + isDestroyed: () => false, + webContents: { + send: () => { + throw new Error('window gone'); + }, + }, + }), + log: (message) => logs.push(message), + }); + await invoke('sync-ui:create-snapshot'); + launcherCalls[0]!.finish({ ok: true, error: null }); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(logs.some((message) => message.includes('window gone'))); + })); + test('cancel-run cancels the active run', () => withTempDir(async (root) => { const { invoke, launcherCalls } = makeTestRig(root); diff --git a/src/main/runtime/sync-ui-runtime.ts b/src/main/runtime/sync-ui-runtime.ts index b266f454..7b72bc54 100644 --- a/src/main/runtime/sync-ui-runtime.ts +++ b/src/main/runtime/sync-ui-runtime.ts @@ -56,6 +56,7 @@ interface ActiveRun { host: string | null; handle: SyncLauncherRunHandle; resultSeen: boolean; + completion?: Promise; } // Milliseconds are part of the stamp so two snapshots taken in the same second @@ -169,32 +170,38 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { }), }; currentRun = run; - void run.handle.done.then((result: SyncLauncherRunResult) => { - if (currentRun?.id === runId) currentRun = null; - // If the launcher died without emitting a result event (spawn failure, - // kill), synthesize one so the renderer can settle its progress view. - if (!run.resultSeen) { - sendToWindow(IPC_CHANNELS.event.syncUiProgress, { - runId, - kind, - host, - event: { type: 'result', ok: result.ok, error: result.error }, - }); - } - broadcastStateChanged(); - if (options.notify && deps.notify) { - const target = host ?? 'local database'; - deps.notify( - result.ok - ? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' } - : { - title: 'Sync failed', - body: `${target}: ${result.error ?? 'unknown error'}`, - variant: 'error', - }, + run.completion = run.handle.done + .then((result: SyncLauncherRunResult) => { + if (currentRun?.id === runId) currentRun = null; + // If the launcher died without emitting a result event (spawn failure, + // kill), synthesize one so the renderer can settle its progress view. + if (!run.resultSeen) { + sendToWindow(IPC_CHANNELS.event.syncUiProgress, { + runId, + kind, + host, + event: { type: 'result', ok: result.ok, error: result.error }, + }); + } + broadcastStateChanged(); + if (options.notify && deps.notify) { + const target = host ?? 'local database'; + deps.notify( + result.ok + ? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' } + : { + title: 'Sync failed', + body: `${target}: ${result.error ?? 'unknown error'}`, + variant: 'error', + }, + ); + } + }) + .catch((error) => { + deps.log?.( + `[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`, ); - } - }); + }); return { started: true, runId, reason: null }; } @@ -231,12 +238,17 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { if (!isValidSyncHost(trimmed)) { return Promise.resolve(failed(`Invalid sync host: ${host}`)); } + if (currentRun) { + return Promise.resolve(failed('A sync operation is already running.')); + } const resolution = deps.resolveLauncherCommand(); if (!resolution.command) { return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.')); } return new Promise((resolve) => { let checkResult: SyncUiCheckResult | null = null; + runCounter += 1; + const runId = runCounter; const handle = deps.runLauncher({ command: resolution.command!, args: ['sync', trimmed, '--check', '--json'], @@ -254,9 +266,27 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { }, onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`), }); - void handle.done.then((result) => { - resolve(checkResult ?? failed(result.error ?? 'Connection check failed.')); - }); + const run: ActiveRun = { + id: runId, + kind: 'check', + host: trimmed, + handle, + resultSeen: false, + }; + currentRun = run; + run.completion = handle.done + .then((result) => { + if (currentRun?.id === runId) currentRun = null; + resolve(checkResult ?? failed(result.error ?? 'Connection check failed.')); + broadcastStateChanged(); + }) + .catch((error) => { + if (currentRun?.id === runId) currentRun = null; + resolve(failed(error instanceof Error ? error.message : String(error))); + deps.log?.( + `[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); }); } @@ -292,6 +322,13 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { return true; } + async function shutdown(): Promise { + const run = currentRun; + if (!run) return; + run.handle.cancel(); + await (run.completion ?? run.handle.done.then(() => undefined)); + } + function registerHandlers(): void { const channels = IPC_CHANNELS.request; deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot()); @@ -334,6 +371,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { runHostSync, checkHost, cancelRun, + shutdown, isRunning: () => currentRun !== null, }; } diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 840cacaf..4ab7c629 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => { handlers.openWindowsMpvLauncherSetup(); handlers.openYomitanSettings(); handlers.openConfigSettings(); + handlers.openSyncUi(); handlers.exportLogs(); handlers.openJellyfinSetup(); handlers.toggleJellyfinDiscovery(true); @@ -71,7 +72,7 @@ test('build tray template handler wires actions and init guards', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), - openSyncUiWindow: () => calls.push('configuration'), + openSyncUiWindow: () => calls.push('sync-ui'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, @@ -97,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => { 'setup-forced', 'yomitan', 'configuration', + 'sync-ui', 'export-logs', 'jellyfin', 'jellyfin-discovery:true', diff --git a/src/main/startup-lifecycle.ts b/src/main/startup-lifecycle.ts index df75eaa6..8bc41cb5 100644 --- a/src/main/startup-lifecycle.ts +++ b/src/main/startup-lifecycle.ts @@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams { logNoRunningInstance: () => void; startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void; onReady: () => Promise; - onWillQuitCleanup: () => void; + onWillQuitCleanup: () => void | Promise; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; shouldQuitOnWindowAllClosed: () => boolean; diff --git a/src/preload-syncui.ts b/src/preload-syncui.ts index c62c9258..308e02eb 100644 --- a/src/preload-syncui.ts +++ b/src/preload-syncui.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; import type { SyncHostsState, SyncUiAPI, @@ -11,55 +12,39 @@ import type { SyncUiStartResult, } from './types/sync-ui'; -const SYNC_UI_IPC_CHANNELS = { - getSnapshot: 'sync-ui:get-snapshot', - saveHost: 'sync-ui:save-host', - removeHost: 'sync-ui:remove-host', - setAutoSyncInterval: 'sync-ui:set-auto-sync-interval', - runSync: 'sync-ui:run-sync', - cancelRun: 'sync-ui:cancel-run', - checkHost: 'sync-ui:check-host', - createSnapshot: 'sync-ui:create-snapshot', - mergeSnapshotFile: 'sync-ui:merge-snapshot-file', - deleteSnapshot: 'sync-ui:delete-snapshot', - revealSnapshot: 'sync-ui:reveal-snapshot', - pickSnapshotFile: 'sync-ui:pick-snapshot-file', - progress: 'sync-ui:progress', - stateChanged: 'sync-ui:state-changed', -} as const; - const syncUiAPI: SyncUiAPI = { - getSnapshot: (): Promise => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot), + getSnapshot: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot), saveHost: (update: SyncUiHostUpdateRequest): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update), removeHost: (host: string): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.removeHost, host), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host), setAutoSyncInterval: (minutes: number): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.setAutoSyncInterval, minutes), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes), runSync: (request: SyncUiRunRequest): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.runSync, request), - cancelRun: (): Promise => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.cancelRun), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request), + cancelRun: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCancelRun), checkHost: (host: string): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCheckHost, host), createSnapshot: (): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot), mergeSnapshotFile: (path: string, force?: boolean): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path, force === true), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true), deleteSnapshot: (path: string): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path), revealSnapshot: (path: string): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.revealSnapshot, path), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path), pickSnapshotFile: (): Promise => - ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.pickSnapshotFile), + ipcRenderer.invoke(IPC_CHANNELS.request.syncUiPickSnapshotFile), onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => { const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload); - ipcRenderer.on(SYNC_UI_IPC_CHANNELS.progress, handler); - return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.progress, handler); + ipcRenderer.on(IPC_CHANNELS.event.syncUiProgress, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiProgress, handler); }, onStateChanged: (listener: () => void): (() => void) => { const handler = (): void => listener(); - ipcRenderer.on(SYNC_UI_IPC_CHANNELS.stateChanged, handler); - return () => ipcRenderer.removeListener(SYNC_UI_IPC_CHANNELS.stateChanged, handler); + ipcRenderer.on(IPC_CHANNELS.event.syncUiStateChanged, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiStateChanged, handler); }, }; diff --git a/src/syncui/index.html b/src/syncui/index.html index 01ff4328..4d7dcf04 100644 --- a/src/syncui/index.html +++ b/src/syncui/index.html @@ -5,7 +5,7 @@ SubMiner Sync @@ -69,15 +69,15 @@ ssh-copy-id user@hostname (no password prompts).
  • - The remote machine just needs SubMiner installed. It is found automatically in - its default install location (Windows), as SubMiner on the remote - PATH, a macOS /Applications install, or the optional + The remote machine just needs SubMiner installed. It is found automatically in its + default install location (Windows), as SubMiner on the remote PATH, a + macOS /Applications install, or the optional subminer command-line launcher. Windows remotes need the built-in OpenSSH Server enabled.
  • - Use Test connection to verify both. It checks SSH and finds SubMiner on - the remote in one go. + Use Test connection to verify both. It checks SSH and finds SubMiner on the + remote in one go.
  • diff --git a/src/syncui/syncui-format.test.ts b/src/syncui/syncui-format.test.ts index 48be02e0..b5ab19a4 100644 --- a/src/syncui/syncui-format.test.ts +++ b/src/syncui/syncui-format.test.ts @@ -29,20 +29,24 @@ test('summarizeMergeCounts lists only non-zero counts', () => { wordsAdded: 0, kanjiAdded: 0, subtitleLinesAdded: 450, - telemetryRowsAdded: 0, - eventsAdded: 0, + telemetryRowsAdded: 3, + eventsAdded: 4, excludedWordsAdded: 0, dailyRollupsCopied: 0, monthlyRollupsCopied: 0, rollupGroupsRecomputed: 4, }; const lines = summarizeMergeCounts(summary); + assert.ok(lines.some((line) => line.label === 'Telemetry rows' && line.value === 3)); + assert.ok(lines.some((line) => line.label === 'Events' && line.value === 4)); assert.deepEqual(lines, [ { label: 'Sessions merged', value: 3 }, { label: 'Already present', value: 12 }, { label: 'Series added', value: 1 }, { label: 'Videos added', value: 2 }, { label: 'Subtitle lines', value: 450 }, + { label: 'Telemetry rows', value: 3 }, + { label: 'Events', value: 4 }, { label: 'Rollups recomputed', value: 4 }, ]); @@ -53,6 +57,8 @@ test('summarizeMergeCounts lists only non-zero counts', () => { animeAdded: 0, videosAdded: 0, subtitleLinesAdded: 0, + telemetryRowsAdded: 0, + eventsAdded: 0, rollupGroupsRecomputed: 0, }); assert.deepEqual(empty, [{ label: 'Sessions merged', value: 0 }]); diff --git a/src/syncui/syncui-format.ts b/src/syncui/syncui-format.ts index 0f5694f7..4d35e313 100644 --- a/src/syncui/syncui-format.ts +++ b/src/syncui/syncui-format.ts @@ -35,6 +35,8 @@ const MERGE_COUNT_FIELDS: Array<{ key: keyof SyncMergeSummary; label: string }> { key: 'wordsAdded', label: 'Words added' }, { key: 'kanjiAdded', label: 'Kanji added' }, { key: 'subtitleLinesAdded', label: 'Subtitle lines' }, + { key: 'telemetryRowsAdded', label: 'Telemetry rows' }, + { key: 'eventsAdded', label: 'Events' }, { key: 'excludedWordsAdded', label: 'Excluded words' }, { key: 'dailyRollupsCopied', label: 'Daily rollups' }, { key: 'monthlyRollupsCopied', label: 'Monthly rollups' },