diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf5e2d4..1716563d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Added +- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically. + ## v0.18.0 (2026-07-10) ### Added diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 0a52b8fa..3c40a124 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +**Added** +- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically. + ## v0.18.0 (2026-07-10) **Added** diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index c40423f4..c29dbbae 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -81,15 +81,15 @@ Series whose directories are not currently accessible (e.g. an unmounted network ## Sync Between Machines -`subminer sync ` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. +`subminer sync ` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The command-line launcher is optional on both sides: the app itself answers sync commands (`SubMiner --sync-cli sync ...`), the sync window runs the engine in-app, and the remote side is found automatically whether it has the launcher or just the app. ```bash subminer sync macbook # two-way sync with the host "macbook" subminer sync macbook --push # merge local data into macbook only subminer sync macbook --pull # merge macbook data into local only subminer sync user@192.168.1.20 # explicit user@host -subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path -subminer sync macbook --check # test SSH + remote launcher without syncing +subminer sync macbook --remote-cmd ~/bin/subminer # custom remote SubMiner/launcher path +subminer sync macbook --check # test SSH + remote SubMiner without syncing subminer sync --ui # open the sync window (also in the tray menu) ``` @@ -97,7 +97,9 @@ How it works: each side takes a consistent snapshot of its database (`VACUUM INT For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror. -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`. +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. + +On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking 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`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH). Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive): @@ -108,14 +110,14 @@ subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the lo Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection. -`subminer sync --check` verifies a host without touching any data: it probes the SSH connection, locates the remote `subminer` launcher, and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes). +`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 window `subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine: - **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically. -- **Add a device** — test SSH + remote launcher availability before saving, with a setup checklist for first-time SSH configuration. +- **Add a device** — test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration. - **Activity** — live stage-by-stage progress, remote output, and a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry. - **Snapshots** — create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots. diff --git a/launcher/sync/ssh.test.ts b/launcher/sync/ssh.test.ts index af2355eb..208fd49c 100644 --- a/launcher/sync/ssh.test.ts +++ b/launcher/sync/ssh.test.ts @@ -49,3 +49,41 @@ 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, (_host, remoteCommand) => { + probed.push(remoteCommand); + return { status: remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1, stdout: '', stderr: '' }; + }); + + assert.match(command, / SubMiner --sync-cli$/); + // Launcher candidates (PATH + ~/.local/bin) are tried before app binaries. + assert.equal(probed.length, 3); + assert.match(probed[0]!, / subminer --help /); + assert.match(probed[1]!, / ~\/\.local\/bin\/subminer --help /); +}); + +test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => { + const probed: string[] = []; + const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', (_host, cmd) => { + probed.push(cmd); + return { status: cmd.includes('--sync-cli') ? 0 : 1, stdout: '', stderr: '' }; + }); + assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/); + + const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', (_host, cmd) => { + return { status: cmd.includes('--sync-cli') ? 1 : 0, stdout: '', stderr: '' }; + }); + assert.match(asLauncher, /'\/opt\/subminer'$/); + + assert.throws( + () => + resolveRemoteSubminerCommand('media-box', '/missing', () => ({ + status: 127, + stdout: '', + stderr: '', + })), + /Remote command not found on media-box: \/missing/, + ); +}); diff --git a/launcher/test-support/immersion-db-fixture.d.ts b/launcher/test-support/immersion-db-fixture.d.ts deleted file mode 100644 index 21070d12..00000000 --- a/launcher/test-support/immersion-db-fixture.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export declare function createImmersionDbFixture(dbPath: string): void; -export interface FixtureSessionInput { - uuid: string; - videoKey: string; - animeTitleKey?: string | null; - animeEpisodesTotal?: number | null; - startedAtMs: number; - endedAtMs?: number | null; - activeWatchedMs?: number; - cardsMined?: number; - linesSeen?: number; - tokensSeen?: number; - watched?: boolean; - words?: Array<{ - headword: string; - word: string; - reading: string; - count: number; - }>; - applyLifetime?: boolean; -} -/** - * Insert a session (with video/anime/subtitle-line/occurrence rows) the way - * the app would have recorded it, optionally crediting lifetime aggregates - * the way applySessionLifetimeSummary does for locally-recorded sessions. - */ -export declare function insertFixtureSession(dbPath: string, input: FixtureSessionInput): void; -//# sourceMappingURL=immersion-db-fixture.d.ts.map \ No newline at end of file diff --git a/launcher/test-support/immersion-db-fixture.d.ts.map b/launcher/test-support/immersion-db-fixture.d.ts.map deleted file mode 100644 index 7895ed70..00000000 --- a/launcher/test-support/immersion-db-fixture.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"immersion-db-fixture.d.ts","sourceRoot":"","sources":["immersion-db-fixture.ts"],"names":[],"mappings":"AAIA,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAsB7D;AAID,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClF,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,GAAG,IAAI,CAsKrF"} \ No newline at end of file diff --git a/launcher/test-support/immersion-db-fixture.js b/launcher/test-support/immersion-db-fixture.js deleted file mode 100644 index 13da9407..00000000 --- a/launcher/test-support/immersion-db-fixture.js +++ /dev/null @@ -1,158 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.createImmersionDbFixture = createImmersionDbFixture; -exports.insertFixtureSession = insertFixtureSession; -const bun_sqlite_1 = require("bun:sqlite"); -const types_js_1 = require("../../src/core/services/immersion-tracker/types.js"); -const immersion_db_schema_js_1 = require("./immersion-db-schema.js"); -function createImmersionDbFixture(dbPath) { - const db = new bun_sqlite_1.Database(dbPath, { create: true }); - try { - db.run('PRAGMA foreign_keys = ON'); - db.run('PRAGMA journal_mode = WAL'); - for (const statement of immersion_db_schema_js_1.IMMERSION_DB_FIXTURE_DDL.split(';')) { - const sql = statement.trim(); - if (sql) - db.run(sql); - } - db.prepare('INSERT INTO imm_schema_version (schema_version, applied_at_ms) VALUES (?, ?)').run(types_js_1.SCHEMA_VERSION, String(Date.now())); - db.prepare(`INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0)`).run(); - db.prepare(`INSERT INTO imm_lifetime_global(global_id, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, ?, ?)`).run(String(Date.now()), String(Date.now())); - } - finally { - db.close(); - } -} -let uniqueCounter = 0; -/** - * Insert a session (with video/anime/subtitle-line/occurrence rows) the way - * the app would have recorded it, optionally crediting lifetime aggregates - * the way applySessionLifetimeSummary does for locally-recorded sessions. - */ -function insertFixtureSession(dbPath, input) { - const db = new bun_sqlite_1.Database(dbPath, { readwrite: true }); - const stamp = String(input.startedAtMs); - try { - db.run('PRAGMA foreign_keys = ON'); - let animeId = null; - if (input.animeTitleKey) { - const existing = db - .query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?') - .get(input.animeTitleKey); - animeId = existing - ? existing.anime_id - : Number(db - .prepare(`INSERT INTO imm_anime (normalized_title_key, canonical_title, episodes_total, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, ?, ?, ?, ?)`) - .run(input.animeTitleKey, input.animeTitleKey, input.animeEpisodesTotal ?? null, stamp, stamp).lastInsertRowid); - } - const existingVideo = db - .query('SELECT video_id FROM imm_videos WHERE video_key = ?') - .get(input.videoKey); - const videoId = existingVideo - ? existingVideo.video_id - : Number(db - .prepare(`INSERT INTO imm_videos (video_key, anime_id, canonical_title, source_type, source_path, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, ?, ?, 1, ?, ?, 1440000, ?, ?)`) - .run(input.videoKey, animeId, input.videoKey, `/videos/${input.videoKey}.mkv`, input.watched ? 1 : 0, stamp, stamp).lastInsertRowid); - if (input.watched) { - db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId); - } - const endedAtMs = input.endedAtMs === undefined ? input.startedAtMs + 1_500_000 : input.endedAtMs; - const activeWatchedMs = input.activeWatchedMs ?? 1_200_000; - const cardsMined = input.cardsMined ?? 2; - const linesSeen = input.linesSeen ?? 100; - const tokensSeen = input.tokensSeen ?? 800; - const sessionId = Number(db - .prepare(`INSERT INTO imm_sessions ( - session_uuid, video_id, started_at_ms, ended_at_ms, status, - total_watched_ms, active_watched_ms, lines_seen, tokens_seen, cards_mined, - CREATED_DATE, LAST_UPDATE_DATE - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(input.uuid, videoId, String(input.startedAtMs), endedAtMs === null ? null : String(endedAtMs), endedAtMs === null ? 1 : 2, activeWatchedMs, activeWatchedMs, linesSeen, tokensSeen, cardsMined, stamp, stamp).lastInsertRowid); - db.prepare(`INSERT INTO imm_session_telemetry ( - session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen, tokens_seen, - cards_mined, CREATED_DATE, LAST_UPDATE_DATE - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, String(endedAtMs ?? input.startedAtMs), activeWatchedMs, activeWatchedMs, linesSeen, tokensSeen, cardsMined, stamp, stamp); - for (const word of input.words ?? []) { - const existing = db - .query('SELECT id FROM imm_words WHERE headword = ? AND word = ? AND reading = ?') - .get(word.headword, word.word, word.reading); - const wordId = existing - ? existing.id - : Number(db - .prepare(`INSERT INTO imm_words (headword, word, reading, first_seen, last_seen, frequency) - VALUES (?, ?, ?, ?, ?, 0)`) - .run(word.headword, word.word, word.reading, Math.floor(input.startedAtMs / 1000), Math.floor(input.startedAtMs / 1000)).lastInsertRowid); - uniqueCounter += 1; - const lineId = Number(db - .prepare(`INSERT INTO imm_subtitle_lines (session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, ?, ?, ?, ?, ?, ?)`) - .run(sessionId, videoId, animeId, uniqueCounter, `line ${word.word}`, input.startedAtMs, input.startedAtMs).lastInsertRowid); - db.prepare('INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)').run(lineId, wordId, word.count); - db.prepare('UPDATE imm_words SET frequency = frequency + ? WHERE id = ?').run(word.count, wordId); - } - if (input.applyLifetime && endedAtMs !== null) { - applyFixtureLifetime(db, sessionId, videoId, animeId, { - endedAtMs, - startedAtMs: input.startedAtMs, - activeWatchedMs, - cardsMined, - linesSeen, - tokensSeen, - watched: Boolean(input.watched), - episodesTotal: input.animeEpisodesTotal ?? null, - }); - } - } - finally { - db.close(); - } -} -function applyFixtureLifetime(db, sessionId, videoId, animeId, data) { - const stamp = String(data.endedAtMs); - db.prepare(`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, ?, ?, ?)`).run(sessionId, String(data.endedAtMs), stamp, stamp); - const mediaLifetime = db - .query('SELECT completed FROM imm_lifetime_media WHERE video_id = ?') - .get(videoId); - const isFirstSessionForVideo = !mediaLifetime; - const isFirstCompleted = data.watched && Number(mediaLifetime?.completed ?? 0) <= 0; - const dayExpr = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`; - const otherOnDay = db - .query(`SELECT 1 FROM imm_sessions WHERE session_id != ? AND ${dayExpr} = - CAST(julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) LIMIT 1`) - .get(sessionId, String(data.startedAtMs)); - db.prepare(`UPDATE imm_lifetime_global SET - total_sessions = total_sessions + 1, - total_active_ms = total_active_ms + ?, - total_cards = total_cards + ?, - active_days = active_days + ?, - episodes_started = episodes_started + ?, - episodes_completed = episodes_completed + ? - WHERE global_id = 1`).run(data.activeWatchedMs, data.cardsMined, otherOnDay ? 0 : 1, isFirstSessionForVideo ? 1 : 0, isFirstCompleted ? 1 : 0); - db.prepare(`INSERT INTO imm_lifetime_media (video_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(video_id) DO UPDATE SET - total_sessions = total_sessions + 1, - total_active_ms = total_active_ms + excluded.total_active_ms, - total_cards = total_cards + excluded.total_cards, - total_lines_seen = total_lines_seen + excluded.total_lines_seen, - total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen, - completed = MAX(completed, excluded.completed), - last_watched_ms = excluded.last_watched_ms`).run(videoId, data.activeWatchedMs, data.cardsMined, data.linesSeen, data.tokensSeen, data.watched ? 1 : 0, String(data.startedAtMs), String(data.endedAtMs), stamp, stamp); - if (animeId !== null) { - db.prepare(`INSERT INTO imm_lifetime_anime (anime_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, episodes_started, episodes_completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(anime_id) DO UPDATE SET - total_sessions = total_sessions + 1, - total_active_ms = total_active_ms + excluded.total_active_ms, - total_cards = total_cards + excluded.total_cards, - total_lines_seen = total_lines_seen + excluded.total_lines_seen, - total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen, - episodes_started = episodes_started + excluded.episodes_started, - episodes_completed = episodes_completed + excluded.episodes_completed, - last_watched_ms = excluded.last_watched_ms`).run(animeId, data.activeWatchedMs, data.cardsMined, data.linesSeen, data.tokensSeen, isFirstSessionForVideo ? 1 : 0, isFirstCompleted ? 1 : 0, String(data.startedAtMs), String(data.endedAtMs), stamp, stamp); - } -} -//# sourceMappingURL=immersion-db-fixture.js.map \ No newline at end of file diff --git a/launcher/test-support/immersion-db-fixture.js.map b/launcher/test-support/immersion-db-fixture.js.map deleted file mode 100644 index 92d29790..00000000 --- a/launcher/test-support/immersion-db-fixture.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"immersion-db-fixture.js","sourceRoot":"","sources":["immersion-db-fixture.ts"],"names":[],"mappings":";;AAIA,4DAsBC;AAyBD,oDAsKC;AAzND,2CAAsC;AACtC,iFAAoF;AACpF,qEAAoE;AAEpE,SAAgB,wBAAwB,CAAC,MAAc;IACrD,MAAM,EAAE,GAAG,IAAI,qBAAQ,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC;QACH,EAAE,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC;QACnC,EAAE,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;QACpC,KAAK,MAAM,SAAS,IAAI,iDAAwB,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5D,MAAM,GAAG,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC;YAC7B,IAAI,GAAG;gBAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACvB,CAAC;QACD,EAAE,CAAC,OAAO,CAAC,8EAA8E,CAAC,CAAC,GAAG,CAC5F,yBAAc,EACd,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CACnB,CAAC;QACF,EAAE,CAAC,OAAO,CACR,0FAA0F,CAC3F,CAAC,GAAG,EAAE,CAAC;QACR,EAAE,CAAC,OAAO,CACR,6FAA6F,CAC9F,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IAChD,CAAC;YAAS,CAAC;QACT,EAAE,CAAC,KAAK,EAAE,CAAC;IACb,CAAC;AACH,CAAC;AAED,IAAI,aAAa,GAAG,CAAC,CAAC;AAkBtB;;;;GAIG;AACH,SAAgB,oBAAoB,CAAC,MAAc,EAAE,KAA0B;IAC7E,MAAM,EAAE,GAAG,IAAI,qBAAQ,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IACxC,IAAI,CAAC;QACH,EAAE,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC;QACnC,IAAI,OAAO,GAAkB,IAAI,CAAC;QAClC,IAAI,KAAK,CAAC,aAAa,EAAE,CAAC;YACxB,MAAM,QAAQ,GAAG,EAAE;iBAChB,KAAK,CAEH,+DAA+D,CAAC;iBAClE,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;YAC5B,OAAO,GAAG,QAAQ;gBAChB,CAAC,CAAC,QAAQ,CAAC,QAAQ;gBACnB,CAAC,CAAC,MAAM,CACJ,EAAE;qBACC,OAAO,CACN;wCACwB,CACzB;qBACA,GAAG,CACF,KAAK,CAAC,aAAa,EACnB,KAAK,CAAC,aAAa,EACnB,KAAK,CAAC,kBAAkB,IAAI,IAAI,EAChC,KAAK,EACL,KAAK,CACN,CAAC,eAAe,CACpB,CAAC;QACR,CAAC;QAED,MAAM,aAAa,GAAG,EAAE;aACrB,KAAK,CAAuB,qDAAqD,CAAC;aAClF,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACvB,MAAM,OAAO,GAAG,aAAa;YAC3B,CAAC,CAAC,aAAa,CAAC,QAAQ;YACxB,CAAC,CAAC,MAAM,CACJ,EAAE;iBACC,OAAO,CACN;wDAC0C,CAC3C;iBACA,GAAG,CACF,KAAK,CAAC,QAAQ,EACd,OAAO,EACP,KAAK,CAAC,QAAQ,EACd,WAAW,KAAK,CAAC,QAAQ,MAAM,EAC/B,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACrB,KAAK,EACL,KAAK,CACN,CAAC,eAAe,CACpB,CAAC;QACN,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YAClB,EAAE,CAAC,OAAO,CAAC,sDAAsD,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAClF,CAAC;QAED,MAAM,SAAS,GACb,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,GAAG,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC;QAClF,MAAM,eAAe,GAAG,KAAK,CAAC,eAAe,IAAI,SAAS,CAAC;QAC3D,MAAM,UAAU,GAAG,KAAK,CAAC,UAAU,IAAI,CAAC,CAAC;QACzC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,GAAG,CAAC;QACzC,MAAM,UAAU,GAAG,KAAK,CAAC,UAAU,IAAI,GAAG,CAAC;QAC3C,MAAM,SAAS,GAAG,MAAM,CACtB,EAAE;aACC,OAAO,CACN;;;;yDAI+C,CAChD;aACA,GAAG,CACF,KAAK,CAAC,IAAI,EACV,OAAO,EACP,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,EACzB,SAAS,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,EAC7C,SAAS,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAC1B,eAAe,EACf,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,KAAK,EACL,KAAK,CACN,CAAC,eAAe,CACpB,CAAC;QAEF,EAAE,CAAC,OAAO,CACR;;;4CAGsC,CACvC,CAAC,GAAG,CACH,SAAS,EACT,MAAM,CAAC,SAAS,IAAI,KAAK,CAAC,WAAW,CAAC,EACtC,eAAe,EACf,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,KAAK,EACL,KAAK,CACN,CAAC;QAEF,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;YACrC,MAAM,QAAQ,GAAG,EAAE;iBAChB,KAAK,CAEH,0EAA0E,CAAC;iBAC7E,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;YAC/C,MAAM,MAAM,GAAG,QAAQ;gBACrB,CAAC,CAAC,QAAQ,CAAC,EAAE;gBACb,CAAC,CAAC,MAAM,CACJ,EAAE;qBACC,OAAO,CACN;2CAC2B,CAC5B;qBACA,GAAG,CACF,IAAI,CAAC,QAAQ,EACb,IAAI,CAAC,IAAI,EACT,IAAI,CAAC,OAAO,EACZ,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,EACpC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,CACrC,CAAC,eAAe,CACpB,CAAC;YACN,aAAa,IAAI,CAAC,CAAC;YACnB,MAAM,MAAM,GAAG,MAAM,CACnB,EAAE;iBACC,OAAO,CACN;0CAC8B,CAC/B;iBACA,GAAG,CACF,SAAS,EACT,OAAO,EACP,OAAO,EACP,aAAa,EACb,QAAQ,IAAI,CAAC,IAAI,EAAE,EACnB,KAAK,CAAC,WAAW,EACjB,KAAK,CAAC,WAAW,CAClB,CAAC,eAAe,CACpB,CAAC;YACF,EAAE,CAAC,OAAO,CACR,6FAA6F,CAC9F,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;YAClC,EAAE,CAAC,OAAO,CAAC,6DAA6D,CAAC,CAAC,GAAG,CAC3E,IAAI,CAAC,KAAK,EACV,MAAM,CACP,CAAC;QACJ,CAAC;QAED,IAAI,KAAK,CAAC,aAAa,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;YAC9C,oBAAoB,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE;gBACpD,SAAS;gBACT,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,eAAe;gBACf,UAAU;gBACV,SAAS;gBACT,UAAU;gBACV,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;gBAC/B,aAAa,EAAE,KAAK,CAAC,kBAAkB,IAAI,IAAI;aAChD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;YAAS,CAAC;QACT,EAAE,CAAC,KAAK,EAAE,CAAC;IACb,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAC3B,EAAY,EACZ,SAAiB,EACjB,OAAe,EACf,OAAsB,EACtB,IASC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACrC,EAAE,CAAC,OAAO,CACR;yBACqB,CACtB,CAAC,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IAEvD,MAAM,aAAa,GAAG,EAAE;SACrB,KAAK,CAAwB,6DAA6D,CAAC;SAC3F,GAAG,CAAC,OAAO,CAAC,CAAC;IAChB,MAAM,sBAAsB,GAAG,CAAC,aAAa,CAAC;IAC9C,MAAM,gBAAgB,GAAG,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC,aAAa,EAAE,SAAS,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;IACpF,MAAM,OAAO,GAAG,sGAAsG,CAAC;IACvH,MAAM,UAAU,GAAG,EAAE;SAClB,KAAK,CACJ,wDAAwD,OAAO;0GACqC,CACrG;SACA,GAAG,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;IAE5C,EAAE,CAAC,OAAO,CACR;;;;;;;yBAOqB,CACtB,CAAC,GAAG,CACH,IAAI,CAAC,eAAe,EACpB,IAAI,CAAC,UAAU,EACf,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAClB,sBAAsB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAC9B,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CACzB,CAAC;IAEF,EAAE,CAAC,OAAO,CACR;;;;;;;;;kDAS8C,CAC/C,CAAC,GAAG,CACH,OAAO,EACP,IAAI,CAAC,eAAe,EACpB,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,SAAS,EACd,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACpB,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,EACxB,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,CACN,CAAC;IAEF,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,EAAE,CAAC,OAAO,CACR;;;;;;;;;;oDAU8C,CAC/C,CAAC,GAAG,CACH,OAAO,EACP,IAAI,CAAC,eAAe,EACpB,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,SAAS,EACd,IAAI,CAAC,UAAU,EACf,sBAAsB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAC9B,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACxB,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,EACxB,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,CACN,CAAC;IACJ,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/launcher/test-support/immersion-db-schema.d.ts b/launcher/test-support/immersion-db-schema.d.ts deleted file mode 100644 index 4b5e69af..00000000 --- a/launcher/test-support/immersion-db-schema.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export declare const IMMERSION_DB_FIXTURE_DDL = "\n CREATE TABLE imm_schema_version (\n schema_version INTEGER PRIMARY KEY,\n applied_at_ms TEXT NOT NULL\n );\n CREATE TABLE imm_rollup_state(\n state_key TEXT PRIMARY KEY,\n state_value TEXT NOT NULL\n );\n CREATE TABLE imm_anime(\n anime_id INTEGER PRIMARY KEY AUTOINCREMENT,\n normalized_title_key TEXT NOT NULL UNIQUE,\n canonical_title TEXT NOT NULL,\n anilist_id INTEGER UNIQUE,\n title_romaji TEXT,\n title_english TEXT,\n title_native TEXT,\n episodes_total INTEGER,\n description TEXT,\n metadata_json TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT\n );\n CREATE TABLE imm_videos(\n video_id INTEGER PRIMARY KEY AUTOINCREMENT,\n video_key TEXT NOT NULL UNIQUE,\n anime_id INTEGER,\n canonical_title TEXT NOT NULL,\n source_type INTEGER NOT NULL,\n source_path TEXT,\n source_url TEXT,\n parsed_basename TEXT,\n parsed_title TEXT,\n parsed_season INTEGER,\n parsed_episode INTEGER,\n parser_source TEXT,\n parser_confidence REAL,\n parse_metadata_json TEXT,\n watched INTEGER NOT NULL DEFAULT 0,\n duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),\n file_size_bytes INTEGER CHECK(file_size_bytes>=0),\n codec_id INTEGER, container_id INTEGER,\n width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,\n bitrate_kbps INTEGER, audio_codec_id INTEGER,\n hash_sha256 TEXT, screenshot_path TEXT,\n metadata_json TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL\n );\n CREATE TABLE imm_sessions(\n session_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_uuid TEXT NOT NULL UNIQUE,\n video_id INTEGER NOT NULL,\n started_at_ms TEXT NOT NULL, ended_at_ms TEXT,\n status INTEGER NOT NULL,\n locale_id INTEGER, target_lang_id INTEGER,\n difficulty_tier INTEGER, subtitle_mode INTEGER,\n ended_media_ms INTEGER,\n total_watched_ms INTEGER NOT NULL DEFAULT 0,\n active_watched_ms INTEGER NOT NULL DEFAULT 0,\n lines_seen INTEGER NOT NULL DEFAULT 0,\n tokens_seen INTEGER NOT NULL DEFAULT 0,\n cards_mined INTEGER NOT NULL DEFAULT 0,\n lookup_count INTEGER NOT NULL DEFAULT 0,\n lookup_hits INTEGER NOT NULL DEFAULT 0,\n yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,\n pause_count INTEGER NOT NULL DEFAULT 0,\n pause_ms INTEGER NOT NULL DEFAULT 0,\n seek_forward_count INTEGER NOT NULL DEFAULT 0,\n seek_backward_count INTEGER NOT NULL DEFAULT 0,\n media_buffer_events INTEGER NOT NULL DEFAULT 0,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)\n );\n CREATE TABLE imm_session_telemetry(\n telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id INTEGER NOT NULL,\n sample_ms TEXT NOT NULL,\n total_watched_ms INTEGER NOT NULL DEFAULT 0,\n active_watched_ms INTEGER NOT NULL DEFAULT 0,\n lines_seen INTEGER NOT NULL DEFAULT 0,\n tokens_seen INTEGER NOT NULL DEFAULT 0,\n cards_mined INTEGER NOT NULL DEFAULT 0,\n lookup_count INTEGER NOT NULL DEFAULT 0,\n lookup_hits INTEGER NOT NULL DEFAULT 0,\n yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,\n pause_count INTEGER NOT NULL DEFAULT 0,\n pause_ms INTEGER NOT NULL DEFAULT 0,\n seek_forward_count INTEGER NOT NULL DEFAULT 0,\n seek_backward_count INTEGER NOT NULL DEFAULT 0,\n media_buffer_events INTEGER NOT NULL DEFAULT 0,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_session_events(\n event_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id INTEGER NOT NULL,\n ts_ms TEXT NOT NULL,\n event_type INTEGER NOT NULL,\n line_index INTEGER,\n segment_start_ms INTEGER,\n segment_end_ms INTEGER,\n tokens_delta INTEGER NOT NULL DEFAULT 0,\n cards_delta INTEGER NOT NULL DEFAULT 0,\n payload_json TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_daily_rollups(\n rollup_day INTEGER NOT NULL,\n video_id INTEGER,\n total_sessions INTEGER NOT NULL DEFAULT 0,\n total_active_min REAL NOT NULL DEFAULT 0,\n total_lines_seen INTEGER NOT NULL DEFAULT 0,\n total_tokens_seen INTEGER NOT NULL DEFAULT 0,\n total_cards INTEGER NOT NULL DEFAULT 0,\n cards_per_hour REAL,\n tokens_per_min REAL,\n lookup_hit_rate REAL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n PRIMARY KEY (rollup_day, video_id)\n );\n CREATE TABLE imm_monthly_rollups(\n rollup_month INTEGER NOT NULL,\n video_id INTEGER,\n total_sessions INTEGER NOT NULL DEFAULT 0,\n total_active_min REAL NOT NULL DEFAULT 0,\n total_lines_seen INTEGER NOT NULL DEFAULT 0,\n total_tokens_seen INTEGER NOT NULL DEFAULT 0,\n total_cards INTEGER NOT NULL DEFAULT 0,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n PRIMARY KEY (rollup_month, video_id)\n );\n CREATE TABLE imm_words(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n headword TEXT,\n word TEXT,\n reading TEXT,\n part_of_speech TEXT,\n pos1 TEXT,\n pos2 TEXT,\n pos3 TEXT,\n first_seen REAL,\n last_seen REAL,\n frequency INTEGER,\n frequency_rank INTEGER,\n UNIQUE(headword, word, reading)\n );\n CREATE TABLE imm_kanji(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n kanji TEXT,\n first_seen REAL,\n last_seen REAL,\n frequency INTEGER,\n UNIQUE(kanji)\n );\n CREATE TABLE imm_subtitle_lines(\n line_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id INTEGER NOT NULL,\n event_id INTEGER,\n video_id INTEGER NOT NULL,\n anime_id INTEGER,\n line_index INTEGER NOT NULL,\n segment_start_ms INTEGER,\n segment_end_ms INTEGER,\n text TEXT NOT NULL,\n secondary_text TEXT,\n CREATED_DATE INTEGER,\n LAST_UPDATE_DATE INTEGER,\n FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,\n FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,\n FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,\n FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL\n );\n CREATE TABLE imm_word_line_occurrences(\n line_id INTEGER NOT NULL,\n word_id INTEGER NOT NULL,\n occurrence_count INTEGER NOT NULL,\n PRIMARY KEY(line_id, word_id),\n FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,\n FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE\n );\n CREATE TABLE imm_kanji_line_occurrences(\n line_id INTEGER NOT NULL,\n kanji_id INTEGER NOT NULL,\n occurrence_count INTEGER NOT NULL,\n PRIMARY KEY(line_id, kanji_id),\n FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,\n FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE\n );\n CREATE TABLE imm_media_art(\n video_id INTEGER PRIMARY KEY,\n anilist_id INTEGER,\n cover_url TEXT,\n cover_blob BLOB,\n cover_blob_hash TEXT,\n title_romaji TEXT,\n title_english TEXT,\n episodes_total INTEGER,\n fetched_at_ms TEXT NOT NULL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_youtube_videos(\n video_id INTEGER PRIMARY KEY,\n youtube_video_id TEXT NOT NULL,\n video_url TEXT NOT NULL,\n video_title TEXT,\n video_thumbnail_url TEXT,\n channel_id TEXT,\n channel_name TEXT,\n channel_url TEXT,\n channel_thumbnail_url TEXT,\n uploader_id TEXT,\n uploader_url TEXT,\n description TEXT,\n metadata_json TEXT,\n fetched_at_ms TEXT NOT NULL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_cover_art_blobs(\n blob_hash TEXT PRIMARY KEY,\n cover_blob BLOB NOT NULL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT\n );\n CREATE TABLE imm_lifetime_global(\n global_id INTEGER PRIMARY KEY CHECK(global_id = 1),\n total_sessions INTEGER NOT NULL DEFAULT 0,\n total_active_ms INTEGER NOT NULL DEFAULT 0,\n total_cards INTEGER NOT NULL DEFAULT 0,\n active_days INTEGER NOT NULL DEFAULT 0,\n episodes_started INTEGER NOT NULL DEFAULT 0,\n episodes_completed INTEGER NOT NULL DEFAULT 0,\n anime_completed INTEGER NOT NULL DEFAULT 0,\n last_rebuilt_ms TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT\n );\n CREATE TABLE imm_lifetime_anime(\n anime_id INTEGER PRIMARY KEY,\n total_sessions INTEGER NOT NULL DEFAULT 0,\n total_active_ms INTEGER NOT NULL DEFAULT 0,\n total_cards INTEGER NOT NULL DEFAULT 0,\n total_lines_seen INTEGER NOT NULL DEFAULT 0,\n total_tokens_seen INTEGER NOT NULL DEFAULT 0,\n episodes_started INTEGER NOT NULL DEFAULT 0,\n episodes_completed INTEGER NOT NULL DEFAULT 0,\n first_watched_ms TEXT,\n last_watched_ms TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_lifetime_media(\n video_id INTEGER PRIMARY KEY,\n total_sessions INTEGER NOT NULL DEFAULT 0,\n total_active_ms INTEGER NOT NULL DEFAULT 0,\n total_cards INTEGER NOT NULL DEFAULT 0,\n total_lines_seen INTEGER NOT NULL DEFAULT 0,\n total_tokens_seen INTEGER NOT NULL DEFAULT 0,\n completed INTEGER NOT NULL DEFAULT 0,\n first_watched_ms TEXT,\n last_watched_ms TEXT,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_lifetime_applied_sessions(\n session_id INTEGER PRIMARY KEY,\n applied_at_ms TEXT NOT NULL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE\n );\n CREATE TABLE imm_stats_excluded_words(\n headword TEXT NOT NULL,\n word TEXT NOT NULL,\n reading TEXT NOT NULL,\n CREATED_DATE TEXT,\n LAST_UPDATE_DATE TEXT,\n PRIMARY KEY(headword, word, reading)\n );\n CREATE INDEX idx_anime_normalized_title ON imm_anime(normalized_title_key);\n CREATE INDEX idx_anime_anilist_id ON imm_anime(anilist_id);\n CREATE INDEX idx_videos_anime_id ON imm_videos(anime_id);\n CREATE INDEX idx_sessions_video_started ON imm_sessions(video_id, started_at_ms DESC);\n CREATE INDEX idx_sessions_status_started ON imm_sessions(status, started_at_ms DESC);\n CREATE INDEX idx_sessions_started_at ON imm_sessions(started_at_ms DESC);\n CREATE INDEX idx_sessions_ended_at ON imm_sessions(ended_at_ms DESC);\n CREATE INDEX idx_telemetry_session_sample ON imm_session_telemetry(session_id, sample_ms DESC);\n CREATE INDEX idx_telemetry_sample_ms ON imm_session_telemetry(sample_ms DESC);\n CREATE INDEX idx_events_session_ts ON imm_session_events(session_id, ts_ms DESC);\n CREATE INDEX idx_events_type_ts ON imm_session_events(event_type, ts_ms DESC);\n CREATE INDEX idx_rollups_day_video ON imm_daily_rollups(rollup_day, video_id);\n CREATE INDEX idx_rollups_month_video ON imm_monthly_rollups(rollup_month, video_id);\n CREATE INDEX idx_words_headword_word_reading ON imm_words(headword, word, reading);\n CREATE INDEX idx_words_frequency ON imm_words(frequency DESC);\n CREATE INDEX idx_kanji_kanji ON imm_kanji(kanji);\n CREATE INDEX idx_kanji_frequency ON imm_kanji(frequency DESC);\n CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);\n CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);\n CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);\n CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);\n CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);\n CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);\n CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);\n CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);\n CREATE INDEX idx_youtube_videos_channel_id ON imm_youtube_videos(channel_id);\n CREATE INDEX idx_youtube_videos_youtube_video_id ON imm_youtube_videos(youtube_video_id);\n"; -//# sourceMappingURL=immersion-db-schema.d.ts.map \ No newline at end of file diff --git a/launcher/test-support/immersion-db-schema.d.ts.map b/launcher/test-support/immersion-db-schema.d.ts.map deleted file mode 100644 index 63927a8f..00000000 --- a/launcher/test-support/immersion-db-schema.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"immersion-db-schema.d.ts","sourceRoot":"","sources":["immersion-db-schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,+rYA+TpC,CAAC"} \ No newline at end of file diff --git a/launcher/test-support/immersion-db-schema.js b/launcher/test-support/immersion-db-schema.js deleted file mode 100644 index c1512841..00000000 --- a/launcher/test-support/immersion-db-schema.js +++ /dev/null @@ -1,327 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.IMMERSION_DB_FIXTURE_DDL = void 0; -// Schema-version-18 shape of the tables the sync merge touches (plus the -// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables / -// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts. -exports.IMMERSION_DB_FIXTURE_DDL = ` - CREATE TABLE imm_schema_version ( - schema_version INTEGER PRIMARY KEY, - applied_at_ms TEXT NOT NULL - ); - CREATE TABLE imm_rollup_state( - state_key TEXT PRIMARY KEY, - state_value TEXT NOT NULL - ); - CREATE TABLE imm_anime( - anime_id INTEGER PRIMARY KEY AUTOINCREMENT, - normalized_title_key TEXT NOT NULL UNIQUE, - canonical_title TEXT NOT NULL, - anilist_id INTEGER UNIQUE, - title_romaji TEXT, - title_english TEXT, - title_native TEXT, - episodes_total INTEGER, - description TEXT, - metadata_json TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT - ); - CREATE TABLE imm_videos( - video_id INTEGER PRIMARY KEY AUTOINCREMENT, - video_key TEXT NOT NULL UNIQUE, - anime_id INTEGER, - canonical_title TEXT NOT NULL, - source_type INTEGER NOT NULL, - source_path TEXT, - source_url TEXT, - parsed_basename TEXT, - parsed_title TEXT, - parsed_season INTEGER, - parsed_episode INTEGER, - parser_source TEXT, - parser_confidence REAL, - parse_metadata_json TEXT, - watched INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER NOT NULL CHECK(duration_ms>=0), - file_size_bytes INTEGER CHECK(file_size_bytes>=0), - codec_id INTEGER, container_id INTEGER, - width_px INTEGER, height_px INTEGER, fps_x100 INTEGER, - bitrate_kbps INTEGER, audio_codec_id INTEGER, - hash_sha256 TEXT, screenshot_path TEXT, - metadata_json TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL - ); - CREATE TABLE imm_sessions( - session_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_uuid TEXT NOT NULL UNIQUE, - video_id INTEGER NOT NULL, - started_at_ms TEXT NOT NULL, ended_at_ms TEXT, - status INTEGER NOT NULL, - locale_id INTEGER, target_lang_id INTEGER, - difficulty_tier INTEGER, subtitle_mode INTEGER, - ended_media_ms INTEGER, - total_watched_ms INTEGER NOT NULL DEFAULT 0, - active_watched_ms INTEGER NOT NULL DEFAULT 0, - lines_seen INTEGER NOT NULL DEFAULT 0, - tokens_seen INTEGER NOT NULL DEFAULT 0, - cards_mined INTEGER NOT NULL DEFAULT 0, - lookup_count INTEGER NOT NULL DEFAULT 0, - lookup_hits INTEGER NOT NULL DEFAULT 0, - yomitan_lookup_count INTEGER NOT NULL DEFAULT 0, - pause_count INTEGER NOT NULL DEFAULT 0, - pause_ms INTEGER NOT NULL DEFAULT 0, - seek_forward_count INTEGER NOT NULL DEFAULT 0, - seek_backward_count INTEGER NOT NULL DEFAULT 0, - media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) - ); - CREATE TABLE imm_session_telemetry( - telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - sample_ms TEXT NOT NULL, - total_watched_ms INTEGER NOT NULL DEFAULT 0, - active_watched_ms INTEGER NOT NULL DEFAULT 0, - lines_seen INTEGER NOT NULL DEFAULT 0, - tokens_seen INTEGER NOT NULL DEFAULT 0, - cards_mined INTEGER NOT NULL DEFAULT 0, - lookup_count INTEGER NOT NULL DEFAULT 0, - lookup_hits INTEGER NOT NULL DEFAULT 0, - yomitan_lookup_count INTEGER NOT NULL DEFAULT 0, - pause_count INTEGER NOT NULL DEFAULT 0, - pause_ms INTEGER NOT NULL DEFAULT 0, - seek_forward_count INTEGER NOT NULL DEFAULT 0, - seek_backward_count INTEGER NOT NULL DEFAULT 0, - media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE - ); - CREATE TABLE imm_session_events( - event_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - ts_ms TEXT NOT NULL, - event_type INTEGER NOT NULL, - line_index INTEGER, - segment_start_ms INTEGER, - segment_end_ms INTEGER, - tokens_delta INTEGER NOT NULL DEFAULT 0, - cards_delta INTEGER NOT NULL DEFAULT 0, - payload_json TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE - ); - CREATE TABLE imm_daily_rollups( - rollup_day INTEGER NOT NULL, - video_id INTEGER, - total_sessions INTEGER NOT NULL DEFAULT 0, - total_active_min REAL NOT NULL DEFAULT 0, - total_lines_seen INTEGER NOT NULL DEFAULT 0, - total_tokens_seen INTEGER NOT NULL DEFAULT 0, - total_cards INTEGER NOT NULL DEFAULT 0, - cards_per_hour REAL, - tokens_per_min REAL, - lookup_hit_rate REAL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - PRIMARY KEY (rollup_day, video_id) - ); - CREATE TABLE imm_monthly_rollups( - rollup_month INTEGER NOT NULL, - video_id INTEGER, - total_sessions INTEGER NOT NULL DEFAULT 0, - total_active_min REAL NOT NULL DEFAULT 0, - total_lines_seen INTEGER NOT NULL DEFAULT 0, - total_tokens_seen INTEGER NOT NULL DEFAULT 0, - total_cards INTEGER NOT NULL DEFAULT 0, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - PRIMARY KEY (rollup_month, video_id) - ); - CREATE TABLE imm_words( - id INTEGER PRIMARY KEY AUTOINCREMENT, - headword TEXT, - word TEXT, - reading TEXT, - part_of_speech TEXT, - pos1 TEXT, - pos2 TEXT, - pos3 TEXT, - first_seen REAL, - last_seen REAL, - frequency INTEGER, - frequency_rank INTEGER, - UNIQUE(headword, word, reading) - ); - CREATE TABLE imm_kanji( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kanji TEXT, - first_seen REAL, - last_seen REAL, - frequency INTEGER, - UNIQUE(kanji) - ); - CREATE TABLE imm_subtitle_lines( - line_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - event_id INTEGER, - video_id INTEGER NOT NULL, - anime_id INTEGER, - line_index INTEGER NOT NULL, - segment_start_ms INTEGER, - segment_end_ms INTEGER, - text TEXT NOT NULL, - secondary_text TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, - FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, - FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL, - FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE, - FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL - ); - CREATE TABLE imm_word_line_occurrences( - line_id INTEGER NOT NULL, - word_id INTEGER NOT NULL, - occurrence_count INTEGER NOT NULL, - PRIMARY KEY(line_id, word_id), - FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE, - FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE - ); - CREATE TABLE imm_kanji_line_occurrences( - line_id INTEGER NOT NULL, - kanji_id INTEGER NOT NULL, - occurrence_count INTEGER NOT NULL, - PRIMARY KEY(line_id, kanji_id), - FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE, - FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE - ); - CREATE TABLE imm_media_art( - video_id INTEGER PRIMARY KEY, - anilist_id INTEGER, - cover_url TEXT, - cover_blob BLOB, - cover_blob_hash TEXT, - title_romaji TEXT, - title_english TEXT, - episodes_total INTEGER, - fetched_at_ms TEXT NOT NULL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE - ); - CREATE TABLE imm_youtube_videos( - video_id INTEGER PRIMARY KEY, - youtube_video_id TEXT NOT NULL, - video_url TEXT NOT NULL, - video_title TEXT, - video_thumbnail_url TEXT, - channel_id TEXT, - channel_name TEXT, - channel_url TEXT, - channel_thumbnail_url TEXT, - uploader_id TEXT, - uploader_url TEXT, - description TEXT, - metadata_json TEXT, - fetched_at_ms TEXT NOT NULL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE - ); - CREATE TABLE imm_cover_art_blobs( - blob_hash TEXT PRIMARY KEY, - cover_blob BLOB NOT NULL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT - ); - CREATE TABLE imm_lifetime_global( - global_id INTEGER PRIMARY KEY CHECK(global_id = 1), - total_sessions INTEGER NOT NULL DEFAULT 0, - total_active_ms INTEGER NOT NULL DEFAULT 0, - total_cards INTEGER NOT NULL DEFAULT 0, - active_days INTEGER NOT NULL DEFAULT 0, - episodes_started INTEGER NOT NULL DEFAULT 0, - episodes_completed INTEGER NOT NULL DEFAULT 0, - anime_completed INTEGER NOT NULL DEFAULT 0, - last_rebuilt_ms TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT - ); - CREATE TABLE imm_lifetime_anime( - anime_id INTEGER PRIMARY KEY, - total_sessions INTEGER NOT NULL DEFAULT 0, - total_active_ms INTEGER NOT NULL DEFAULT 0, - total_cards INTEGER NOT NULL DEFAULT 0, - total_lines_seen INTEGER NOT NULL DEFAULT 0, - total_tokens_seen INTEGER NOT NULL DEFAULT 0, - episodes_started INTEGER NOT NULL DEFAULT 0, - episodes_completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms TEXT, - last_watched_ms TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE - ); - CREATE TABLE imm_lifetime_media( - video_id INTEGER PRIMARY KEY, - total_sessions INTEGER NOT NULL DEFAULT 0, - total_active_ms INTEGER NOT NULL DEFAULT 0, - total_cards INTEGER NOT NULL DEFAULT 0, - total_lines_seen INTEGER NOT NULL DEFAULT 0, - total_tokens_seen INTEGER NOT NULL DEFAULT 0, - completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms TEXT, - last_watched_ms TEXT, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE - ); - CREATE TABLE imm_lifetime_applied_sessions( - session_id INTEGER PRIMARY KEY, - applied_at_ms TEXT NOT NULL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE - ); - CREATE TABLE imm_stats_excluded_words( - headword TEXT NOT NULL, - word TEXT NOT NULL, - reading TEXT NOT NULL, - CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - PRIMARY KEY(headword, word, reading) - ); - CREATE INDEX idx_anime_normalized_title ON imm_anime(normalized_title_key); - CREATE INDEX idx_anime_anilist_id ON imm_anime(anilist_id); - CREATE INDEX idx_videos_anime_id ON imm_videos(anime_id); - CREATE INDEX idx_sessions_video_started ON imm_sessions(video_id, started_at_ms DESC); - CREATE INDEX idx_sessions_status_started ON imm_sessions(status, started_at_ms DESC); - CREATE INDEX idx_sessions_started_at ON imm_sessions(started_at_ms DESC); - CREATE INDEX idx_sessions_ended_at ON imm_sessions(ended_at_ms DESC); - CREATE INDEX idx_telemetry_session_sample ON imm_session_telemetry(session_id, sample_ms DESC); - CREATE INDEX idx_telemetry_sample_ms ON imm_session_telemetry(sample_ms DESC); - CREATE INDEX idx_events_session_ts ON imm_session_events(session_id, ts_ms DESC); - CREATE INDEX idx_events_type_ts ON imm_session_events(event_type, ts_ms DESC); - CREATE INDEX idx_rollups_day_video ON imm_daily_rollups(rollup_day, video_id); - CREATE INDEX idx_rollups_month_video ON imm_monthly_rollups(rollup_month, video_id); - CREATE INDEX idx_words_headword_word_reading ON imm_words(headword, word, reading); - CREATE INDEX idx_words_frequency ON imm_words(frequency DESC); - CREATE INDEX idx_kanji_kanji ON imm_kanji(kanji); - CREATE INDEX idx_kanji_frequency ON imm_kanji(frequency DESC); - CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index); - CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index); - CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index); - CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id); - CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id); - CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash); - CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id); - CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url); - CREATE INDEX idx_youtube_videos_channel_id ON imm_youtube_videos(channel_id); - CREATE INDEX idx_youtube_videos_youtube_video_id ON imm_youtube_videos(youtube_video_id); -`; -//# sourceMappingURL=immersion-db-schema.js.map \ No newline at end of file diff --git a/launcher/test-support/immersion-db-schema.js.map b/launcher/test-support/immersion-db-schema.js.map deleted file mode 100644 index dc4e3f65..00000000 --- a/launcher/test-support/immersion-db-schema.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"immersion-db-schema.js","sourceRoot":"","sources":["immersion-db-schema.ts"],"names":[],"mappings":";;;AAAA,yEAAyE;AACzE,yEAAyE;AACzE,mFAAmF;AACtE,QAAA,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+TvC,CAAC"} \ No newline at end of file diff --git a/src/core/services/stats-sync/cli-args.test.ts b/src/core/services/stats-sync/cli-args.test.ts new file mode 100644 index 00000000..e2ee8f02 --- /dev/null +++ b/src/core/services/stats-sync/cli-args.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { extractSyncCliTokens, parseSyncCliTokens } from './cli-args'; + +test('extractSyncCliTokens returns tokens after --sync-cli', () => { + assert.equal(extractSyncCliTokens(['/bin/electron', '/app']), null); + assert.deepEqual(extractSyncCliTokens(['/bin/electron', '/app', '--sync-cli', 'sync', 'host']), [ + 'sync', + 'host', + ]); + // A repeated flag (e.g. resolved remote command + forwarded argv) is ignored. + assert.deepEqual(extractSyncCliTokens(['/app', '--sync-cli', '--sync-cli', 'sync', 'h']), [ + 'sync', + 'h', + ]); +}); + +test('parseSyncCliTokens handles help, version, and run modes', () => { + assert.deepEqual(parseSyncCliTokens(['--help']), { kind: 'help' }); + assert.deepEqual(parseSyncCliTokens(['--version']), { kind: 'version' }); + + const run = parseSyncCliTokens(['sync', 'media-box', '--pull', '--force', '--json']); + assert.equal(run.kind, 'run'); + if (run.kind === 'run') { + assert.equal(run.args.syncHost, 'media-box'); + assert.equal(run.args.syncDirection, 'pull'); + assert.equal(run.args.syncForce, true); + assert.equal(run.args.syncJson, true); + } + + const snapshot = parseSyncCliTokens(['sync', '--snapshot', '/tmp/x.sqlite', '--db', '/tmp/db']); + assert.equal(snapshot.kind, 'run'); + if (snapshot.kind === 'run') { + assert.equal(snapshot.args.syncSnapshotPath, '/tmp/x.sqlite'); + assert.equal(snapshot.args.syncDbPath, '/tmp/db'); + } +}); + +test('parseSyncCliTokens mirrors launcher sync validation', () => { + assert.equal(parseSyncCliTokens([]).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', 'h', '--bogus']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error'); +}); diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index f876c850..bff95253 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -180,6 +180,13 @@ test('direct runtime commands forward through app control', () => { test('entry-only and internal commands do not forward through app control', () => { assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false); assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false); + assert.equal( + shouldForwardStartupArgvViaAppControl( + ['SubMiner.exe', '--sync-cli', 'sync', 'media-box', '--json'], + {}, + ), + false, + ); assert.equal( shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}), false, diff --git a/src/main/runtime/sync-ui-runtime.ts b/src/main/runtime/sync-ui-runtime.ts index 7c02c0d9..37970071 100644 --- a/src/main/runtime/sync-ui-runtime.ts +++ b/src/main/runtime/sync-ui-runtime.ts @@ -123,14 +123,12 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) { } function getSnapshot(): SyncUiSnapshot { - const resolution = deps.resolveLauncherCommand(); return { dbPath: deps.getDbPath(), hosts: readState(), snapshotsDir: deps.snapshotsDir, snapshots: listSnapshots(), run: runState(), - launcherPath: resolution.command ? resolution.command.join(' ') : null, }; } diff --git a/src/syncui/index.html b/src/syncui/index.html index 914b692e..aa77551e 100644 --- a/src/syncui/index.html +++ b/src/syncui/index.html @@ -24,8 +24,6 @@
- -

Devices

@@ -71,14 +69,13 @@ ssh-copy-id user@hostname (no password prompts).
  • - The remote machine needs SubMiner's command-line launcher installed (subminer - on its PATH, or in ~/.local/bin). + The remote machine just needs SubMiner installed. It is found automatically as + SubMiner on the remote PATH, a macOS /Applications + install, or the optional subminer command-line launcher.
  • - Use Test connection to verify both. It checks SSH and the remote launcher - 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.ts b/src/syncui/syncui.ts index 69f499e6..a28a5946 100644 --- a/src/syncui/syncui.ts +++ b/src/syncui/syncui.ts @@ -25,7 +25,6 @@ function el(id: string): T { const runPill = el('runPill'); const dbPathEl = el('dbPath'); -const launcherWarning = el('launcherWarning'); const hostList = el('hostList'); const hostListEmpty = el('hostListEmpty'); const autoIntervalInput = el('autoIntervalInput'); @@ -385,12 +384,6 @@ async function refresh(): Promise { if (document.activeElement !== autoIntervalInput) { autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes); } - launcherWarning.classList.toggle('hidden', snapshot.launcherPath !== null); - if (snapshot.launcherPath === null) { - launcherWarning.textContent = - 'The subminer command-line launcher was not found. Install it from SubMiner setup ' + - '(or install bun). Syncing runs through the launcher.'; - } setRunPill(); renderHosts(); renderSnapshots(); diff --git a/src/types/sync-ui.ts b/src/types/sync-ui.ts index 6d38d27d..24e33528 100644 --- a/src/types/sync-ui.ts +++ b/src/types/sync-ui.ts @@ -26,7 +26,6 @@ export interface SyncUiSnapshot { snapshotsDir: string; snapshots: SyncUiSnapshotFile[]; run: SyncUiRunState; - launcherPath: string | null; } export interface SyncUiRunRequest {