From ffa183b1a11b79a8c292193cfdca2aab58be5c7a Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 11 Jul 2026 20:23:22 -0700 Subject: [PATCH] feat(sync): headless --sync-cli mode so sync only needs the app installed The Electron app now answers launcher-style sync argv (--sync-cli sync [host|--snapshot|--merge] ... plus --help/--version) at entry, before any window or display initialization, backed by a libsql binding of the shared stats-sync engine. Works over SSH with no display server, so a remote machine no longer needs the bun launcher. The launcher accepts --sync-cli as a no-op so both invocation shapes stay equivalent. --- launcher/config/cli-parser-builder.ts | 6 +- launcher/sync/engine-drivers.test.ts | 76 ++++ .../test-support/immersion-db-fixture.d.ts | 28 ++ .../immersion-db-fixture.d.ts.map | 1 + launcher/test-support/immersion-db-fixture.js | 158 +++++++++ .../test-support/immersion-db-fixture.js.map | 1 + .../test-support/immersion-db-schema.d.ts | 2 + .../test-support/immersion-db-schema.d.ts.map | 1 + launcher/test-support/immersion-db-schema.js | 327 ++++++++++++++++++ .../test-support/immersion-db-schema.js.map | 1 + src/cli/help.ts | 4 + src/core/services/stats-sync/cli-args.ts | 145 ++++++++ src/core/services/stats-sync/libsql-driver.ts | 55 +++ src/main-entry-runtime.ts | 2 + src/main-entry.ts | 9 + src/main/sync-cli.ts | 188 ++++++++++ 16 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 launcher/sync/engine-drivers.test.ts create mode 100644 launcher/test-support/immersion-db-fixture.d.ts create mode 100644 launcher/test-support/immersion-db-fixture.d.ts.map create mode 100644 launcher/test-support/immersion-db-fixture.js create mode 100644 launcher/test-support/immersion-db-fixture.js.map create mode 100644 launcher/test-support/immersion-db-schema.d.ts create mode 100644 launcher/test-support/immersion-db-schema.d.ts.map create mode 100644 launcher/test-support/immersion-db-schema.js create mode 100644 launcher/test-support/immersion-db-schema.js.map create mode 100644 src/core/services/stats-sync/cli-args.ts create mode 100644 src/core/services/stats-sync/libsql-driver.ts create mode 100644 src/main/sync-cli.ts diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 47704fc7..0997b57c 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -79,7 +79,11 @@ function applyRootOptions(program: Command): void { .option('-R, --rofi', 'Use rofi picker') .option('-H, --history', 'Browse local watch history') .option('-S, --start-overlay', 'Auto-start overlay') - .option('-T, --no-texthooker', 'Disable texthooker-ui server'); + .option('-T, --no-texthooker', 'Disable texthooker-ui server') + // The SubMiner app answers sync commands when invoked with --sync-cli. + // Remote-command resolution may address the launcher the same way, so + // accept the flag as a no-op to keep both invocation shapes equivalent. + .option('--sync-cli', 'Compatibility no-op (sync commands work with or without it)'); } function buildSubcommandHelpText(program: Command): string { diff --git a/launcher/sync/engine-drivers.test.ts b/launcher/sync/engine-drivers.test.ts new file mode 100644 index 00000000..1903306f --- /dev/null +++ b/launcher/sync/engine-drivers.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + createImmersionDbFixture, + insertFixtureSession, +} from '../test-support/immersion-db-fixture.js'; +import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js'; +import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js'; +import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js'; + +const BASE_MS = Date.UTC(2026, 5, 1, 12, 0, 0); + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-libsql-sync-test-')); +} + +function countRows(dbPath: string, sql: string): number { + const db = openLibsqlSyncDb(dbPath, { readonly: true }); + try { + const row = db.query(sql).get() as { n: number } | undefined; + return Number(row?.n ?? 0); + } finally { + db.close(); + } +} + +// End-to-end pass of the shared engine over the libsql driver (the binding the +// Electron app's --sync-cli mode uses): snapshot a fixture DB, merge it into +// another, and re-merge to confirm idempotence. +test('libsql driver runs snapshot and merge through the shared engine', () => { + const dir = makeTmpDir(); + try { + const localPath = path.join(dir, 'local.sqlite'); + const remotePath = path.join(dir, 'remote.sqlite'); + createImmersionDbFixture(localPath); + createImmersionDbFixture(remotePath); + insertFixtureSession(localPath, { + uuid: 'local-1', + videoKey: 'showa-e1', + animeTitleKey: 'showa', + startedAtMs: BASE_MS, + applyLifetime: true, + words: [{ headword: '見る', word: '見た', reading: 'みた', count: 2 }], + }); + insertFixtureSession(remotePath, { + uuid: 'remote-1', + videoKey: 'showb-e1', + animeTitleKey: 'showb', + startedAtMs: BASE_MS + 86_400_000, + activeWatchedMs: 900_000, + cardsMined: 3, + applyLifetime: true, + words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }], + }); + + const snapshotPath = path.join(dir, 'remote-snapshot.sqlite'); + createDbSnapshot(openLibsqlSyncDb, remotePath, snapshotPath); + assert.ok(fs.existsSync(snapshotPath)); + assert.equal(countRows(snapshotPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 1); + + const summary = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath); + assert.equal(summary.sessionsMerged, 1); + assert.equal(summary.videosAdded, 1); + assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2); + + const again = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath); + assert.equal(again.sessionsMerged, 0); + assert.equal(again.sessionsAlreadyPresent, 1); + assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/launcher/test-support/immersion-db-fixture.d.ts b/launcher/test-support/immersion-db-fixture.d.ts new file mode 100644 index 00000000..21070d12 --- /dev/null +++ b/launcher/test-support/immersion-db-fixture.d.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000..7895ed70 --- /dev/null +++ b/launcher/test-support/immersion-db-fixture.d.ts.map @@ -0,0 +1 @@ +{"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 new file mode 100644 index 00000000..13da9407 --- /dev/null +++ b/launcher/test-support/immersion-db-fixture.js @@ -0,0 +1,158 @@ +"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 new file mode 100644 index 00000000..92d29790 --- /dev/null +++ b/launcher/test-support/immersion-db-fixture.js.map @@ -0,0 +1 @@ +{"version":3,"file":"immersion-db-fixture.js","sourceRoot":"","sources":["immersion-db-fixture.ts"],"names":[],"mappings":"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"} \ 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 new file mode 100644 index 00000000..4b5e69af --- /dev/null +++ b/launcher/test-support/immersion-db-schema.d.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..63927a8f --- /dev/null +++ b/launcher/test-support/immersion-db-schema.d.ts.map @@ -0,0 +1 @@ +{"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 new file mode 100644 index 00000000..c1512841 --- /dev/null +++ b/launcher/test-support/immersion-db-schema.js @@ -0,0 +1,327 @@ +"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 new file mode 100644 index 00000000..dc4e3f65 --- /dev/null +++ b/launcher/test-support/immersion-db-schema.js.map @@ -0,0 +1 @@ +{"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/cli/help.ts b/src/cli/help.ts index 46d8e69c..d46a4a59 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -79,6 +79,10 @@ ${B}Jellyfin${R} --jellyfin-audio-stream-index ${D}N${R} Audio stream override --jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override +${B}Stats sync${R} + --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} + ${B}Options${R} --socket ${D}PATH${R} mpv IPC socket path --backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R} diff --git a/src/core/services/stats-sync/cli-args.ts b/src/core/services/stats-sync/cli-args.ts new file mode 100644 index 00000000..f93f9588 --- /dev/null +++ b/src/core/services/stats-sync/cli-args.ts @@ -0,0 +1,145 @@ +import type { SyncFlowArgs } from './sync-flow'; + +export const SYNC_CLI_FLAG = '--sync-cli'; + +export type ParsedSyncCli = + | { kind: 'help' } + | { kind: 'version' } + | { kind: 'run'; args: SyncFlowArgs } + | { kind: 'error'; message: string }; + +export function extractSyncCliTokens(argv: readonly string[]): string[] | null { + const index = argv.indexOf(SYNC_CLI_FLAG); + if (index === -1) return null; + return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG); +} + +/** + * Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the + * app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so + * both entry points accept the same command lines and fail the same way. + */ +export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { + if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' }; + if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' }; + + const rest = [...tokens]; + if (rest[0] !== 'sync') { + return { + kind: 'error', + message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot ).`, + }; + } + rest.shift(); + + let host = ''; + let snapshot = ''; + let merge = ''; + let push = false; + let pull = false; + let check = false; + let force = false; + let json = false; + let remoteCmd = ''; + let dbPath = ''; + let logLevel = 'warn'; + + const valueFlags = new Map void>([ + ['--snapshot', (value) => (snapshot = value.trim())], + ['--merge', (value) => (merge = value.trim())], + ['--remote-cmd', (value) => (remoteCmd = value.trim())], + ['--db', (value) => (dbPath = value.trim())], + ['--log-level', (value) => (logLevel = value.trim() || 'warn')], + ]); + + for (let i = 0; i < rest.length; i += 1) { + const token = rest[i]!; + const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token); + if (assignValue) { + if (token.includes('=')) { + assignValue(token.slice(token.indexOf('=') + 1)); + continue; + } + const value = rest[i + 1]; + if (value === undefined) { + return { kind: 'error', message: `Missing value for ${token}.` }; + } + assignValue(value); + i += 1; + continue; + } + if (token === '--push') push = true; + else if (token === '--pull') pull = true; + else if (token === '--check') check = true; + else if (token === '--force' || token === '-f') force = true; + else if (token === '--json') json = true; + else if (token.startsWith('-')) { + return { kind: 'error', message: `Unknown sync option: ${token}` }; + } else if (host) { + return { kind: 'error', message: `Unexpected extra argument: ${token}` }; + } else { + host = token.trim(); + } + } + + if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' }; + if ((push || pull) && !host) { + return { kind: 'error', message: 'Sync --push and --pull require a host.' }; + } + if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' }; + if (check && (push || pull || snapshot || merge)) { + return { + kind: 'error', + message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.', + }; + } + const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length; + if (modes === 0) { + return { kind: 'error', message: 'Sync requires a host, --snapshot , or --merge .' }; + } + if (modes > 1) { + return { kind: 'error', message: 'Sync host, --snapshot, and --merge cannot be combined.' }; + } + + return { + kind: 'run', + args: { + sync: true, + syncHost: host, + syncSnapshotPath: snapshot, + syncMergePath: merge, + syncDirection: push ? 'push' : pull ? 'pull' : 'both', + syncRemoteCmd: remoteCmd, + syncDbPath: dbPath, + syncForce: force, + syncJson: json, + syncCheck: check, + logLevel, + }, + }; +} + +export function syncCliUsage(): string { + return [ + 'SubMiner sync CLI', + '', + `Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`, + '', + 'Modes (exactly one):', + ' Sync stats with an SSH destination (user@host or ssh alias)', + ' --snapshot Write a consistent snapshot of the local stats database', + ' --merge Merge a snapshot database file into the local stats database', + '', + 'Options:', + ' --push Only merge local stats into the SSH host', + ' --pull Only merge stats from the SSH host into the local database', + ' --check Test the SSH connection and remote SubMiner availability', + ' --db Override the local stats database path', + ' --remote-cmd SubMiner app or launcher command to run on the remote host', + ' -f, --force Skip the running-app safety check', + ' --json Emit machine-readable NDJSON progress output', + ' --log-level Log level', + ' --help Show this help', + ' --version Show the SubMiner version', + ].join('\n'); +} diff --git a/src/core/services/stats-sync/libsql-driver.ts b/src/core/services/stats-sync/libsql-driver.ts new file mode 100644 index 00000000..1938d557 --- /dev/null +++ b/src/core/services/stats-sync/libsql-driver.ts @@ -0,0 +1,55 @@ +import { Database } from '../immersion-tracker/sqlite'; +import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver'; + +interface LibsqlStatement { + run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; +} + +interface LibsqlDatabase { + prepare(sql: string): LibsqlStatement; + exec(sql: string): unknown; + close(): unknown; +} + +/** + * libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron + * app. prepare() is not cached by libsql, so query() keeps a per-connection + * statement cache — the merge prepares a handful of statements and runs them + * once per copied row. + */ +export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => { + const db = new Database(dbPath, { + readonly: options.readonly === true, + fileMustExist: options.create !== true, + }) as unknown as LibsqlDatabase; + const statements = new Map(); + return { + query(sql: string): SyncDbStatement { + const cached = statements.get(sql); + if (cached) return cached; + const statement = db.prepare(sql); + const wrapped: SyncDbStatement = { + run(...params: unknown[]): SyncDbRunResult { + return statement.run(...params); + }, + get(...params: unknown[]): unknown { + return statement.get(...params); + }, + all(...params: unknown[]): unknown[] { + return statement.all(...params); + }, + }; + statements.set(sql, wrapped); + return wrapped; + }, + exec(sql: string): void { + db.exec(sql); + }, + close(): void { + statements.clear(); + db.close(); + }, + }; +}; diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 56264230..3af5cc33 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -158,6 +158,8 @@ export function shouldForwardStartupArgvViaAppControl( const args = parseCliArgs(argv); if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false; if (resolveStatsDaemonCommandAction(argv) !== null) return false; + // Sync CLI argv is handled headless at entry, never forwarded to a running app. + if (argv.includes('--sync-cli')) return false; return hasExplicitCommand(args); } diff --git a/src/main-entry.ts b/src/main-entry.ts index e4c84563..ad5979a2 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -30,6 +30,7 @@ import { } from './main/runtime/first-run-setup-plugin'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; +import { runSyncCliFromProcess, shouldHandleSyncCliAtEntry } from './main/sync-cli'; import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; import { @@ -221,6 +222,14 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise { } async function runEntryProcess(): Promise { + // Headless sync CLI: must run first (its own --help/--version handling) and + // exit before app.whenReady() so it works over SSH with no display server. + if (shouldHandleSyncCliAtEntry(process.argv, process.env)) { + const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion()); + app.exit(exitCode); + return; + } + if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) { const sanitizedEnv = sanitizeHelpEnv(process.env); process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS; diff --git a/src/main/sync-cli.ts b/src/main/sync-cli.ts new file mode 100644 index 00000000..cdcbc08d --- /dev/null +++ b/src/main/sync-cli.ts @@ -0,0 +1,188 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution'; +import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path'; +import { + extractSyncCliTokens, + parseSyncCliTokens, + syncCliUsage, +} from '../core/services/stats-sync/cli-args'; +import { + createDbSnapshot, + findLiveStatsDaemonPid, +} from '../core/services/stats-sync/shared'; +import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; +import { + assertSafeSshHost, + resolveRemoteSubminerCommand, + runScp, + runSsh, +} from '../core/services/stats-sync/ssh'; +import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver'; +import { + ensureTrackerQuiescentFlow, + runSyncFlow, + type SyncFlowDeps, +} from '../core/services/stats-sync/sync-flow'; +import { recordSyncResult, readSyncHostsState, writeSyncHostsState, getSyncHostsPath } from '../shared/sync/sync-hosts-store'; + +export function shouldHandleSyncCliAtEntry(argv: readonly string[], env: NodeJS.ProcessEnv): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + return extractSyncCliTokens(argv) !== null; +} + +function resolveAppConfigDir(): string { + return resolveConfigDir({ + platform: process.platform, + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); +} + +function resolveTildePath(input: string): string { + return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input); +} + +// Same resolution as the launcher: honor a configured immersionTracking.dbPath +// (raw config read, tolerant of comments), else /immersion.sqlite. +function resolveDefaultDbPath(): string { + const configPath = resolveConfigFilePath({ + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); + let configured = ''; + try { + const data = fs.readFileSync(configPath, 'utf8'); + const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); + const tracking = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record).immersionTracking + : null; + if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) { + const dbPath = (tracking as Record).dbPath; + if (typeof dbPath === 'string') configured = dbPath.trim(); + } + } catch { + // no config or unreadable config → default location + } + if (configured) return resolveTildePath(configured); + return path.join(resolveAppConfigDir(), 'immersion.sqlite'); +} + +async function canConnectUnixSocket(socketPath: string): Promise { + return await new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + try { + socket.destroy(); + } catch { + // ignore + } + resolve(value); + }; + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.setTimeout(400, () => finish(false)); + }); +} + +function recordHostSyncResultToDisk( + host: string, + status: 'success' | 'error', + detail: string | null, +): void { + try { + const filePath = getSyncHostsPath(resolveAppConfigDir()); + const state = recordSyncResult(readSyncHostsState(filePath), host, { + atMs: Date.now(), + status, + detail, + }); + writeSyncHostsState(filePath, state); + } catch { + // best effort: bookkeeping must never fail the sync itself + } +} + +function buildSyncCliDeps(): SyncFlowDeps { + const deps: SyncFlowDeps = { + createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath), + mergeSnapshotIntoDb: (localDbPath, snapshotPath) => + mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath), + formatMergeSummary, + findLiveStatsDaemonPid, + assertSafeSshHost, + resolveRemoteSubminerCommand, + runScp, + runSsh, + fail: (message: string): never => { + throw new Error(message); + }, + log: (level, configured, message) => { + if (level === 'debug' && configured !== 'debug') return; + console.error(message); + }, + canConnectUnixSocket, + realpathSync: (candidate) => fs.realpathSync(candidate), + mkdtempSync: (prefix) => fs.mkdtempSync(prefix), + rmSync: (target, options) => fs.rmSync(target, options), + consoleLog: (message) => console.log(message), + writeStdout: (text) => process.stdout.write(text), + ensureTrackerQuiescent: async (context, dbPath) => + ensureTrackerQuiescentFlow(context, dbPath, deps), + emitEvent: () => {}, + recordHostSyncResult: recordHostSyncResultToDisk, + resolveDefaultDbPath, + resolvePath: resolveTildePath, + }; + return deps; +} + +/** + * Headless sync entry for the Electron app: answers the same launcher-style + * `sync ...` argv as `subminer sync`, so a machine only needs the app + * installed to participate in stats sync (locally or as an SSH remote). Runs + * before any window/display initialization and never touches Electron APIs. + */ +export async function runSyncCliFromProcess( + argv: readonly string[], + appVersion: string, +): Promise { + const tokens = extractSyncCliTokens(argv); + const parsed = parseSyncCliTokens(tokens ?? []); + if (parsed.kind === 'help') { + console.log(syncCliUsage()); + return 0; + } + if (parsed.kind === 'version') { + console.log(`SubMiner ${appVersion}`); + return 0; + } + if (parsed.kind === 'error') { + console.error(parsed.message); + console.error(`\n${syncCliUsage()}`); + return 2; + } + + const context = { + args: parsed.args, + mpvSocketPath: getDefaultMpvSocketPath(process.platform), + }; + try { + await runSyncFlow(context, buildSyncCliDeps()); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +}