diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66e9184..a8b4147 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies diff --git a/config.example.jsonc b/config.example.jsonc index e367ffa..e3ecabc 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -260,5 +260,15 @@ "anilist": { "enabled": false, "accessToken": "" + }, + + // ========================================== + // Immersion Tracking + // Enable/disable immersion tracking. + // Set dbPath to override the default app data path. + // ========================================== + "immersionTracking": { + "enabled": true, + "dbPath": "" } } diff --git a/docs/configuration.md b/docs/configuration.md index 1ff9ae0..ec384c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,6 +61,7 @@ The configuration file includes several main sections: - [**Subtitle Style**](#subtitle-style) - Appearance customization - [**Texthooker**](#texthooker) - Control browser opening behavior - [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server +- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback ### AnkiConnect @@ -693,6 +694,32 @@ See `config.example.jsonc` for detailed configuration options. | `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected | | `port` | number | WebSocket server port (default: 6677) | +### Immersion Tracking + +Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions: + +```json +{ + "immersionTracking": { + "enabled": true, + "dbPath": "" + } +} +``` + +| Option | Values | Description | +| ---------- | -------------------------- | ----------- | +| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | +| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `/immersion.sqlite`. | + +When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location: + +```text +/immersion.sqlite +``` + +Set `dbPath` only if you want to relocate the database (for backup, syncing, or inspection workflows). The database is created when tracking starts for the first time. + ### YouTube Subtitle Generation Set defaults used by the `subminer` launcher for YouTube subtitle extraction/transcription: diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md index 768685b..e51ec9e 100644 --- a/docs/mining-workflow.md +++ b/docs/mining-workflow.md @@ -192,3 +192,19 @@ When enabled, SubMiner highlights words you already know in your Anki deck, maki - **Immersion tracking**: Quickly identify which sentences contain only known words vs. those with new vocabulary - **Mining focus**: Target sentences with exactly one unknown word (true N+1) - **Progress visualization**: See your growing vocabulary visually represented in real content + +### Immersion Tracking Storage + +Immersion data is persisted to SQLite when enabled in `immersionTracking`: + +```json +{ + "immersionTracking": { + "enabled": true, + "dbPath": "" + } +} +``` + +- `dbPath` can be empty (default) to use SubMiner’s app-data `immersion.sqlite`. +- Set an explicit path to move the database (for backups, cloud syncing, or easier inspection). diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index e367ffa..df75763 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -46,6 +46,14 @@ "level": "info" }, + // Immersion Tracking + // Persist mined subtitle/session telemetry for analytics. + // ========================================== + "immersionTracking": { + "enabled": true, + "dbPath": "" + }, + // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. diff --git a/package.json b/package.json index 245a9bb..6a9baac 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config:dist": "node --test dist/config/config.test.js", - "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", + "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test": "pnpm run test:config && pnpm run test:core", "test:config": "pnpm run build && pnpm run test:config:dist", diff --git a/scripts/build-macos-helper.sh b/scripts/build-macos-helper.sh index df4c786..4f66a4b 100755 --- a/scripts/build-macos-helper.sh +++ b/scripts/build-macos-helper.sh @@ -7,21 +7,48 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift" OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts" OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos" +OUTPUT_SOURCE_COPY="$OUTPUT_DIR/get-mpv-window-macos.swift" + +fallback_to_source() { + echo "Falling back to source fallback: $OUTPUT_SOURCE_COPY" + mkdir -p "$OUTPUT_DIR" + cp "$SWIFT_SOURCE" "$OUTPUT_SOURCE_COPY" +} + +build_swift_helper() { + echo "Compiling macOS window tracking helper..." + if ! command -v swiftc >/dev/null 2>&1; then + echo "swiftc not found in PATH; skipping compilation." + return 1 + fi + + if ! swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY"; then + return 1 + fi + + chmod +x "$OUTPUT_BINARY" + echo "✓ Built $OUTPUT_BINARY" + return 0 +} + +# Optional skip flag for non-macOS CI/dev environments +if [[ "${SUBMINER_SKIP_MACOS_HELPER_BUILD:-}" == "1" ]]; then + echo "Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)" + fallback_to_source + exit 0 +fi # Only build on macOS if [[ "$(uname)" != "Darwin" ]]; then echo "Skipping macOS helper build (not on macOS)" + fallback_to_source exit 0 fi # Create output directory mkdir -p "$OUTPUT_DIR" -# Compile Swift script to binary -echo "Compiling macOS window tracking helper..." -swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY" - -# Make executable -chmod +x "$OUTPUT_BINARY" - -echo "✓ Built $OUTPUT_BINARY" +# Compile Swift script to binary, fallback to source if unavailable or compilation fails +if ! build_swift_helper; then + fallback_to_source +fi diff --git a/src/anki-integration.ts b/src/anki-integration.ts index f4186fe..45d751e 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -1054,7 +1054,7 @@ export class AnkiIntegration { startTime: number, endTime: number, secondarySubText?: string, - ): Promise { + ): Promise { return this.cardCreationService.createSentenceCard( sentence, startTime, diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 73c30b3..a496f62 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -460,23 +460,23 @@ export class CardCreationService { startTime: number, endTime: number, secondarySubText?: string, - ): Promise { + ): Promise { if (this.deps.isUpdateInProgress()) { this.deps.showOsdNotification("Anki update already in progress"); - return; + return false; } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardModel = sentenceCardConfig.model; if (!sentenceCardModel) { this.deps.showOsdNotification("sentenceCardModel not configured"); - return; + return false; } const mpvClient = this.deps.getMpvClient(); if (!mpvClient || !mpvClient.currentVideoPath) { this.deps.showOsdNotification("No video loaded"); - return; + return false; } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; @@ -488,7 +488,8 @@ export class CardCreationService { } this.deps.showOsdNotification("Creating sentence card..."); - await this.deps.withUpdateProgress("Creating sentence card", async () => { + try { + return await this.deps.withUpdateProgress("Creating sentence card", async () => { const videoPath = mpvClient.currentVideoPath; const fields: Record = {}; const errors: string[] = []; @@ -533,7 +534,7 @@ export class CardCreationService { this.deps.showOsdNotification( `Sentence card failed: ${(error as Error).message}`, ); - return; + return false; } try { @@ -637,7 +638,18 @@ export class CardCreationService { const errorSuffix = errors.length > 0 ? `${errors.join(", ")} failed` : undefined; await this.deps.showNotification(noteId, label, errorSuffix); + return true; }); + } catch (error) { + log.error( + "Error creating sentence card:", + (error as Error).message, + ); + this.deps.showOsdNotification( + `Sentence card failed: ${(error as Error).message}`, + ); + return false; + } } private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8d9ef14..9a2c1f5 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -18,6 +18,8 @@ test("loads defaults when config is missing", () => { assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); assert.equal(config.anilist.enabled, false); + assert.equal(config.immersionTracking.enabled, true); + assert.equal(config.immersionTracking.dbPath, undefined); }); test("parses anilist.enabled and warns for invalid value", () => { @@ -43,6 +45,26 @@ test("parses anilist.enabled and warns for invalid value", () => { assert.equal(service.getConfig().anilist.enabled, true); }); +test("accepts immersion tracking config values", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "config.jsonc"), + `{ + "immersionTracking": { + "enabled": false, + "dbPath": "/tmp/immersions/custom.sqlite" + } + }`, + "utf-8", + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.immersionTracking.enabled, false); + assert.equal(config.immersionTracking.dbPath, "/tmp/immersions/custom.sqlite"); +}); + test("parses jsonc and warns/falls back on invalid value", () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions.ts b/src/config/definitions.ts index d01f82d..07cd5cf 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -239,6 +239,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { invisibleOverlay: { startupVisibility: "platform-default", }, + immersionTracking: { + enabled: true, + }, }; export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect; @@ -509,6 +512,19 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [ description: "Comma-separated primary subtitle language priority used by the launcher.", }, + { + path: "immersionTracking.enabled", + kind: "boolean", + defaultValue: DEFAULT_CONFIG.immersionTracking.enabled, + description: "Enable immersion tracking for mined subtitle metadata.", + }, + { + path: "immersionTracking.dbPath", + kind: "string", + defaultValue: DEFAULT_CONFIG.immersionTracking.dbPath, + description: + "Optional SQLite database path for immersion tracking. Empty value uses the default app data path.", + }, ]; export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ @@ -621,6 +637,14 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ description: ["Anilist API credentials and update behavior."], key: "anilist", }, + { + title: "Immersion Tracking", + description: [ + "Enable/disable immersion tracking.", + "Set dbPath to override the default sqlite database location.", + ], + key: "immersionTracking", + }, ]; export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { diff --git a/src/config/service.ts b/src/config/service.ts index e0fb75e..441e40a 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -485,6 +485,32 @@ export class ConfigService { ); } + if (isObject(src.immersionTracking)) { + const enabled = asBoolean(src.immersionTracking.enabled); + if (enabled !== undefined) { + resolved.immersionTracking.enabled = enabled; + } else if (src.immersionTracking.enabled !== undefined) { + warn( + "immersionTracking.enabled", + src.immersionTracking.enabled, + resolved.immersionTracking.enabled, + "Expected boolean.", + ); + } + + const dbPath = asString(src.immersionTracking.dbPath); + if (dbPath !== undefined) { + resolved.immersionTracking.dbPath = dbPath; + } else if (src.immersionTracking.dbPath !== undefined) { + warn( + "immersionTracking.dbPath", + src.immersionTracking.dbPath, + resolved.immersionTracking.dbPath, + "Expected string.", + ); + } + } + if (isObject(src.subtitleStyle)) { resolved.subtitleStyle = { ...resolved.subtitleStyle, diff --git a/src/core/services/app-ready-service.test.ts b/src/core/services/app-ready-service.test.ts index 93e72c9..4287841 100644 --- a/src/core/services/app-ready-service.test.ts +++ b/src/core/services/app-ready-service.test.ts @@ -24,6 +24,7 @@ function makeDeps(overrides: Partial = {}) { calls.push("createMecabTokenizerAndCheck"); }, createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), + createImmersionTracker: () => calls.push("createImmersionTracker"), loadYomitanExtension: async () => { calls.push("loadYomitanExtension"); }, @@ -43,6 +44,40 @@ test("runAppReadyRuntimeService starts websocket in auto mode when plugin missin await runAppReadyRuntimeService(deps); assert.ok(calls.includes("startSubtitleWebsocket:9001")); assert.ok(calls.includes("initializeOverlayRuntime")); + assert.ok(calls.includes("createImmersionTracker")); + assert.ok( + calls.includes("log:Runtime ready: invoking createImmersionTracker."), + ); +}); + +test("runAppReadyRuntimeService logs when createImmersionTracker dependency is missing", async () => { + const { deps, calls } = makeDeps({ + createImmersionTracker: undefined, + }); + await runAppReadyRuntimeService(deps); + assert.ok( + calls.includes( + "log:Runtime ready: createImmersionTracker dependency is missing.", + ), + ); +}); + +test("runAppReadyRuntimeService logs and continues when createImmersionTracker throws", async () => { + const { deps, calls } = makeDeps({ + createImmersionTracker: () => { + calls.push("createImmersionTracker"); + throw new Error("immersion init failed"); + }, + }); + await runAppReadyRuntimeService(deps); + assert.ok(calls.includes("createImmersionTracker")); + assert.ok( + calls.includes( + "log:Runtime ready: createImmersionTracker failed: immersion init failed", + ), + ); + assert.ok(calls.includes("initializeOverlayRuntime")); + assert.ok(calls.includes("handleInitialArgs")); }); test("runAppReadyRuntimeService logs defer message when overlay not auto-started", async () => { diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts new file mode 100644 index 0000000..37c7062 --- /dev/null +++ b/src/core/services/immersion-tracker-service.test.ts @@ -0,0 +1,371 @@ +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 { DatabaseSync } from "node:sqlite"; +import { ImmersionTrackerService } from "./immersion-tracker-service"; + +function makeDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-immersion-test-")); + return path.join(dir, "immersion.sqlite"); +} + +function cleanupDbPath(dbPath: string): void { + const dir = path.dirname(dbPath); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("startSession generates UUID-like session identifiers", () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + tracker = new ImmersionTrackerService({ dbPath }); + tracker.handleMediaChange("/tmp/episode.mkv", "Episode"); + + const privateApi = tracker as unknown as { + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const db = new DatabaseSync(dbPath); + const row = db + .prepare("SELECT session_uuid FROM imm_sessions LIMIT 1") + .get() as { session_uuid: string } | null; + db.close(); + + assert.equal(typeof row?.session_uuid, "string"); + assert.equal(row?.session_uuid?.startsWith("session-"), false); + assert.ok(/^[0-9a-fA-F-]{36}$/.test(row?.session_uuid || "")); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test("destroy finalizes active session and persists final telemetry", () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + tracker = new ImmersionTrackerService({ dbPath }); + + tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2"); + tracker.recordSubtitleLine("Hello immersion", 0, 1); + tracker.destroy(); + + const db = new DatabaseSync(dbPath); + const sessionRow = db + .prepare("SELECT ended_at_ms FROM imm_sessions LIMIT 1") + .get() as { ended_at_ms: number | null } | null; + const telemetryCountRow = db + .prepare("SELECT COUNT(*) AS total FROM imm_session_telemetry") + .get() as { total: number }; + db.close(); + + assert.ok(sessionRow); + assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0); + assert.ok(Number(telemetryCountRow.total) >= 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test("monthly rollups are grouped by calendar month", async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + tracker = new ImmersionTrackerService({ dbPath }); + const privateApi = tracker as unknown as { + db: DatabaseSync; + runRollupMaintenance: () => void; + }; + + const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0); + const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0); + + privateApi.db.exec(` + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + created_at_ms, + updated_at_ms + ) VALUES ( + 1, + 'local:/tmp/video.mkv', + 'Episode', + 1, + 0, + ${januaryStartedAtMs}, + ${januaryStartedAtMs} + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 1, + '11111111-1111-1111-1111-111111111111', + 1, + ${januaryStartedAtMs}, + 2, + ${januaryStartedAtMs}, + ${januaryStartedAtMs}, + ${januaryStartedAtMs + 5000} + ) + `); + privateApi.db.exec(` + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + words_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES ( + 1, + ${januaryStartedAtMs + 1000}, + 5000, + 5000, + 1, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 2, + '22222222-2222-2222-2222-222222222222', + 1, + ${februaryStartedAtMs}, + 2, + ${februaryStartedAtMs}, + ${februaryStartedAtMs}, + ${februaryStartedAtMs + 5000} + ) + `); + privateApi.db.exec(` + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + words_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES ( + 2, + ${februaryStartedAtMs + 1000}, + 4000, + 4000, + 2, + 3, + 3, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0 + ) + `); + + privateApi.runRollupMaintenance(); + + const rows = await tracker.getMonthlyRollups(10); + const videoRows = rows.filter((row) => row.videoId === 1); + + assert.equal(videoRows.length, 2); + assert.equal(videoRows[0].rollupDayOrMonth, 202602); + assert.equal(videoRows[1].rollupDayOrMonth, 202601); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test("flushSingle reuses cached prepared statements", () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let originalPrepare: DatabaseSync["prepare"] | null = null; + + try { + tracker = new ImmersionTrackerService({ dbPath }); + const privateApi = tracker as unknown as { + db: DatabaseSync; + flushSingle: (write: { + kind: "telemetry" | "event"; + sessionId: number; + sampleMs: number; + eventType?: number; + lineIndex?: number | null; + segmentStartMs?: number | null; + segmentEndMs?: number | null; + wordsDelta?: number; + cardsDelta?: number; + payloadJson?: string | null; + totalWatchedMs?: number; + activeWatchedMs?: number; + linesSeen?: number; + wordsSeen?: number; + tokensSeen?: number; + cardsMined?: number; + lookupCount?: number; + lookupHits?: number; + pauseCount?: number; + pauseMs?: number; + seekForwardCount?: number; + seekBackwardCount?: number; + mediaBufferEvents?: number; + }) => void; + }; + + originalPrepare = privateApi.db.prepare; + let prepareCalls = 0; + privateApi.db.prepare = (...args: Parameters) => { + prepareCalls += 1; + return originalPrepare!.apply(privateApi.db, args); + }; + const preparedRestore = originalPrepare; + + privateApi.db.exec(` + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + created_at_ms, + updated_at_ms + ) VALUES ( + 1, + 'local:/tmp/prepared.mkv', + 'Prepared', + 1, + 0, + 1000, + 1000 + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 1, + '33333333-3333-3333-3333-333333333333', + 1, + 1000, + 2, + 1000, + 1000, + 2000 + ) + `); + + privateApi.flushSingle({ + kind: "telemetry", + sessionId: 1, + sampleMs: 1500, + totalWatchedMs: 1000, + activeWatchedMs: 1000, + linesSeen: 1, + wordsSeen: 2, + tokensSeen: 2, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + }); + + privateApi.flushSingle({ + kind: "event", + sessionId: 1, + sampleMs: 1600, + eventType: 1, + lineIndex: 1, + segmentStartMs: 0, + segmentEndMs: 1000, + wordsDelta: 2, + cardsDelta: 0, + payloadJson: '{"event":"subtitle-line"}', + }); + + privateApi.db.prepare = preparedRestore; + + assert.equal(prepareCalls, 0); + } finally { + if (tracker && originalPrepare) { + const privateApi = tracker as unknown as { db: DatabaseSync }; + privateApi.db.prepare = originalPrepare; + } + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts new file mode 100644 index 0000000..3d7c74d --- /dev/null +++ b/src/core/services/immersion-tracker-service.ts @@ -0,0 +1,1416 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import * as fs from "node:fs"; +import { createLogger } from "../../logger"; + +const SCHEMA_VERSION = 1; +const DEFAULT_QUEUE_CAP = 1_000; +const DEFAULT_BATCH_SIZE = 25; +const DEFAULT_FLUSH_INTERVAL_MS = 500; +const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const EVENTS_RETENTION_MS = ONE_WEEK_MS; +const VACUUM_INTERVAL_MS = ONE_WEEK_MS; +const TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; +const MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000; +const MAX_PAYLOAD_BYTES = 256; + +const SOURCE_TYPE_LOCAL = 1; +const SOURCE_TYPE_REMOTE = 2; + +const SESSION_STATUS_ACTIVE = 1; +const SESSION_STATUS_ENDED = 2; + +const EVENT_SUBTITLE_LINE = 1; +const EVENT_MEDIA_BUFFER = 2; +const EVENT_LOOKUP = 3; +const EVENT_CARD_MINED = 4; +const EVENT_SEEK_FORWARD = 5; +const EVENT_SEEK_BACKWARD = 6; +const EVENT_PAUSE_START = 7; +const EVENT_PAUSE_END = 8; + +export interface ImmersionTrackerOptions { + dbPath: string; +} + +interface TelemetryAccumulator { + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; + pauseCount: number; + pauseMs: number; + seekForwardCount: number; + seekBackwardCount: number; + mediaBufferEvents: number; +} + +interface SessionState extends TelemetryAccumulator { + sessionId: number; + videoId: number; + startedAtMs: number; + currentLineIndex: number; + lastWallClockMs: number; + lastMediaMs: number | null; + lastPauseStartMs: number | null; + isPaused: boolean; + pendingTelemetry: boolean; +} + +interface QueuedWrite { + kind: "telemetry" | "event"; + sessionId: number; + sampleMs?: number; + totalWatchedMs?: number; + activeWatchedMs?: number; + linesSeen?: number; + wordsSeen?: number; + tokensSeen?: number; + cardsMined?: number; + lookupCount?: number; + lookupHits?: number; + pauseCount?: number; + pauseMs?: number; + seekForwardCount?: number; + seekBackwardCount?: number; + mediaBufferEvents?: number; + eventType?: number; + lineIndex?: number | null; + segmentStartMs?: number | null; + segmentEndMs?: number | null; + wordsDelta?: number; + cardsDelta?: number; + payloadJson?: string | null; +} + +interface VideoMetadata { + sourceType: number; + canonicalTitle: string; + durationMs: number; + fileSizeBytes: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; + hashSha256: string | null; + screenshotPath: string | null; + metadataJson: string | null; +} + +export interface SessionSummaryQueryRow { + videoId: number | null; + startedAtMs: number; + endedAtMs: number | null; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; +} + +export interface SessionTimelineRow { + sampleMs: number; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; +} + +export interface ImmersionSessionRollupRow { + rollupDayOrMonth: number; + videoId: number | null; + totalSessions: number; + totalActiveMin: number; + totalLinesSeen: number; + totalWordsSeen: number; + totalTokensSeen: number; + totalCards: number; + cardsPerHour: number | null; + wordsPerMin: number | null; + lookupHitRate: number | null; +} + +export class ImmersionTrackerService { + private readonly logger = createLogger("main:immersion-tracker"); + private readonly db: DatabaseSync; + private readonly queue: QueuedWrite[] = []; + private readonly queueCap: number; + private readonly batchSize: number; + private readonly flushIntervalMs: number; + private readonly maintenanceIntervalMs: number; + private readonly dbPath: string; + private readonly writeLock = { locked: false }; + private flushTimer: ReturnType | null = null; + private maintenanceTimer: ReturnType | null = null; + private flushScheduled = false; + private droppedWriteCount = 0; + private lastMaintenanceMs = 0; + private lastVacuumMs = 0; + private isDestroyed = false; + private sessionState: SessionState | null = null; + private currentVideoKey = ""; + private currentMediaPathOrUrl = ""; + private lastQueueWriteAtMs = 0; + private readonly telemetryInsertStmt: ReturnType; + private readonly eventInsertStmt: ReturnType; + + constructor(options: ImmersionTrackerOptions) { + this.dbPath = options.dbPath; + const parentDir = path.dirname(this.dbPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + this.queueCap = DEFAULT_QUEUE_CAP; + this.batchSize = DEFAULT_BATCH_SIZE; + this.flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; + this.maintenanceIntervalMs = DEFAULT_MAINTENANCE_INTERVAL_MS; + this.lastMaintenanceMs = Date.now(); + + this.db = new DatabaseSync(this.dbPath); + this.applyPragmas(); + this.ensureSchema(); + this.telemetryInsertStmt = this.db.prepare(` + INSERT INTO imm_session_telemetry ( + session_id, sample_ms, total_watched_ms, active_watched_ms, + lines_seen, words_seen, tokens_seen, cards_mined, lookup_count, + lookup_hits, pause_count, pause_ms, seek_forward_count, + seek_backward_count, media_buffer_events + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `); + this.eventInsertStmt = this.db.prepare(` + INSERT INTO imm_session_events ( + session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms, + words_delta, cards_delta, payload_json + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `); + this.scheduleMaintenance(); + this.scheduleFlush(); + } + + destroy(): void { + if (this.isDestroyed) return; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.maintenanceTimer) { + clearInterval(this.maintenanceTimer); + this.maintenanceTimer = null; + } + this.finalizeActiveSession(); + this.isDestroyed = true; + this.db.close(); + } + + async getSessionSummaries( + limit = 50, + ): Promise { + const prepared = this.db.prepare(` + SELECT + s.video_id AS videoId, + s.started_at_ms AS startedAtMs, + s.ended_at_ms AS endedAtMs, + COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs, + COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs, + COALESCE(SUM(t.lines_seen), 0) AS linesSeen, + COALESCE(SUM(t.words_seen), 0) AS wordsSeen, + COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen, + COALESCE(SUM(t.cards_mined), 0) AS cardsMined, + COALESCE(SUM(t.lookup_count), 0) AS lookupCount, + COALESCE(SUM(t.lookup_hits), 0) AS lookupHits + FROM imm_sessions s + LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id + GROUP BY s.session_id + ORDER BY s.started_at_ms DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; + } + + async getSessionTimeline( + sessionId: number, + limit = 200, + ): Promise { + const prepared = this.db.prepare(` + SELECT + sample_ms AS sampleMs, + total_watched_ms AS totalWatchedMs, + active_watched_ms AS activeWatchedMs, + lines_seen AS linesSeen, + words_seen AS wordsSeen, + tokens_seen AS tokensSeen, + cards_mined AS cardsMined + FROM imm_session_telemetry + WHERE session_id = ? + ORDER BY sample_ms DESC + LIMIT ? + `); + return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[]; + } + + async getQueryHints(): Promise<{ + totalSessions: number; + activeSessions: number; + }> { + const sessions = this.db.prepare("SELECT COUNT(*) AS total FROM imm_sessions"); + const active = this.db.prepare( + "SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL", + ); + const totalSessions = Number(sessions.get()?.total ?? 0); + const activeSessions = Number(active.get()?.total ?? 0); + return { totalSessions, activeSessions }; + } + + async getDailyRollups( + limit = 60, + ): Promise { + const prepared = this.db.prepare(` + SELECT + rollup_day AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + cards_per_hour AS cardsPerHour, + words_per_min AS wordsPerMin, + lookup_hit_rate AS lookupHitRate + FROM imm_daily_rollups + ORDER BY rollup_day DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; + } + + async getMonthlyRollups( + limit = 24, + ): Promise { + const prepared = this.db.prepare(` + SELECT + rollup_month AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + 0 AS cardsPerHour, + 0 AS wordsPerMin, + 0 AS lookupHitRate + FROM imm_monthly_rollups + ORDER BY rollup_month DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; + } + + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { + const normalizedPath = this.normalizeMediaPath(mediaPath); + const normalizedTitle = this.normalizeText(mediaTitle); + this.logger.info( + `handleMediaChange called with path=${normalizedPath || ""} title=${normalizedTitle || ""}`, + ); + if (normalizedPath === this.currentMediaPathOrUrl) { + if (normalizedTitle && normalizedTitle !== this.currentVideoKey) { + this.currentVideoKey = normalizedTitle; + this.updateVideoTitleForActiveSession(normalizedTitle); + this.logger.debug("Media title updated for existing session"); + } else { + this.logger.debug("Media change ignored; path unchanged"); + } + return; + } + this.finalizeActiveSession(); + this.currentMediaPathOrUrl = normalizedPath; + this.currentVideoKey = normalizedTitle; + if (!normalizedPath) { + this.logger.info("Media path cleared; immersion session tracking paused"); + return; + } + + const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; + const videoKey = this.buildVideoKey(normalizedPath, sourceType); + const canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath); + const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null; + const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null; + + const sessionInfo = { + videoId: this.getOrCreateVideo(videoKey, { + canonicalTitle, + sourcePath, + sourceUrl, + sourceType, + }), + startedAtMs: Date.now(), + }; + + this.logger.info( + `Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`, + ); + this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs); + this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); + } + + handleMediaTitleUpdate(mediaTitle: string | null): void { + if (!this.sessionState) return; + const normalizedTitle = this.normalizeText(mediaTitle); + if (!normalizedTitle) return; + this.currentVideoKey = normalizedTitle; + this.updateVideoTitleForActiveSession(normalizedTitle); + } + + recordSubtitleLine( + text: string, + startSec: number, + endSec: number, + ): void { + if (!this.sessionState || !text.trim()) return; + const cleaned = this.normalizeText(text); + if (!cleaned) return; + + const metrics = this.calculateTextMetrics(cleaned); + this.sessionState.currentLineIndex += 1; + this.sessionState.linesSeen += 1; + this.sessionState.wordsSeen += metrics.words; + this.sessionState.tokensSeen += metrics.tokens; + this.sessionState.pendingTelemetry = true; + + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + lineIndex: this.sessionState.currentLineIndex, + segmentStartMs: this.secToMs(startSec), + segmentEndMs: this.secToMs(endSec), + wordsDelta: metrics.words, + cardsDelta: 0, + eventType: EVENT_SUBTITLE_LINE, + payloadJson: this.sanitizePayload({ + event: "subtitle-line", + text: cleaned, + words: metrics.words, + }), + }); + } + + recordPlaybackPosition(mediaTimeSec: number | null): void { + if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) { + return; + } + const nowMs = Date.now(); + const mediaMs = Math.round(mediaTimeSec * 1000); + if (this.sessionState.lastWallClockMs <= 0) { + this.sessionState.lastWallClockMs = nowMs; + this.sessionState.lastMediaMs = mediaMs; + return; + } + + const wallDeltaMs = nowMs - this.sessionState.lastWallClockMs; + if (wallDeltaMs > 0 && wallDeltaMs < 60_000) { + this.sessionState.totalWatchedMs += wallDeltaMs; + if (!this.sessionState.isPaused) { + this.sessionState.activeWatchedMs += wallDeltaMs; + } + } + + if (this.sessionState.lastMediaMs !== null) { + const mediaDeltaMs = mediaMs - this.sessionState.lastMediaMs; + if (Math.abs(mediaDeltaMs) >= 1_000) { + if (mediaDeltaMs > 0) { + this.sessionState.seekForwardCount += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_SEEK_FORWARD, + wordsDelta: 0, + cardsDelta: 0, + segmentStartMs: this.sessionState.lastMediaMs, + segmentEndMs: mediaMs, + payloadJson: this.sanitizePayload({ + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }), + }); + } else if (mediaDeltaMs < 0) { + this.sessionState.seekBackwardCount += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_SEEK_BACKWARD, + wordsDelta: 0, + cardsDelta: 0, + segmentStartMs: this.sessionState.lastMediaMs, + segmentEndMs: mediaMs, + payloadJson: this.sanitizePayload({ + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }), + }); + } + } + } + + this.sessionState.lastWallClockMs = nowMs; + this.sessionState.lastMediaMs = mediaMs; + this.sessionState.pendingTelemetry = true; + } + + recordPauseState(isPaused: boolean): void { + if (!this.sessionState) return; + if (this.sessionState.isPaused === isPaused) return; + + const nowMs = Date.now(); + this.sessionState.isPaused = isPaused; + if (isPaused) { + this.sessionState.lastPauseStartMs = nowMs; + this.sessionState.pauseCount += 1; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_PAUSE_START, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: this.sanitizePayload({ paused: true }), + }); + } else { + if (this.sessionState.lastPauseStartMs) { + const pauseMs = Math.max(0, nowMs - this.sessionState.lastPauseStartMs); + this.sessionState.pauseMs += pauseMs; + this.sessionState.lastPauseStartMs = null; + } + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_PAUSE_END, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: this.sanitizePayload({ paused: false }), + }); + } + + this.sessionState.pendingTelemetry = true; + } + + recordLookup(hit: boolean): void { + if (!this.sessionState) return; + this.sessionState.lookupCount += 1; + if (hit) { + this.sessionState.lookupHits += 1; + } + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_LOOKUP, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: this.sanitizePayload({ + hit, + }), + }); + } + + recordCardsMined(count = 1): void { + if (!this.sessionState) return; + this.sessionState.cardsMined += count; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_CARD_MINED, + wordsDelta: 0, + cardsDelta: count, + payloadJson: this.sanitizePayload({ cardsMined: count }), + }); + } + + recordMediaBufferEvent(): void { + if (!this.sessionState) return; + this.sessionState.mediaBufferEvents += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: "event", + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_MEDIA_BUFFER, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: this.sanitizePayload({ + buffer: true, + }), + }); + } + + private recordWrite(write: QueuedWrite): void { + if (this.isDestroyed) return; + if (this.queue.length >= this.queueCap) { + const overflow = this.queue.length - this.queueCap + 1; + this.queue.splice(0, overflow); + this.droppedWriteCount += overflow; + this.logger.warn( + `Immersion tracker queue overflow; dropped ${overflow} oldest writes`, + ); + } + this.queue.push(write); + this.lastQueueWriteAtMs = Date.now(); + if (write.kind === "event" || this.queue.length >= this.batchSize) { + this.scheduleFlush(0); + } + } + + private flushTelemetry(force = false): void { + if (!this.sessionState || (!force && !this.sessionState.pendingTelemetry)) { + return; + } + this.recordWrite({ + kind: "telemetry", + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + totalWatchedMs: this.sessionState.totalWatchedMs, + activeWatchedMs: this.sessionState.activeWatchedMs, + linesSeen: this.sessionState.linesSeen, + wordsSeen: this.sessionState.wordsSeen, + tokensSeen: this.sessionState.tokensSeen, + cardsMined: this.sessionState.cardsMined, + lookupCount: this.sessionState.lookupCount, + lookupHits: this.sessionState.lookupHits, + pauseCount: this.sessionState.pauseCount, + pauseMs: this.sessionState.pauseMs, + seekForwardCount: this.sessionState.seekForwardCount, + seekBackwardCount: this.sessionState.seekBackwardCount, + mediaBufferEvents: this.sessionState.mediaBufferEvents, + }); + this.sessionState.pendingTelemetry = false; + } + + private scheduleFlush(delayMs = this.flushIntervalMs): void { + if (this.flushScheduled || this.writeLock.locked) return; + this.flushScheduled = true; + this.flushTimer = setTimeout(() => { + this.flushScheduled = false; + this.flushNow(); + }, delayMs); + } + + private flushNow(): void { + if (this.writeLock.locked || this.isDestroyed) return; + if (this.queue.length === 0) { + this.flushScheduled = false; + return; + } + + this.flushTelemetry(); + if (this.queue.length === 0) { + this.flushScheduled = false; + return; + } + + const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); + this.writeLock.locked = true; + try { + this.db.exec("BEGIN IMMEDIATE"); + for (const write of batch) { + this.flushSingle(write); + } + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + this.queue.unshift(...batch); + this.logger.warn("Immersion tracker flush failed, retrying later", error as Error); + } finally { + this.writeLock.locked = false; + this.flushScheduled = false; + if (this.queue.length > 0) { + this.scheduleFlush(this.flushIntervalMs); + } + } + } + + private flushSingle(write: QueuedWrite): void { + if (write.kind === "telemetry") { + this.telemetryInsertStmt.run( + write.sessionId, + write.sampleMs!, + write.totalWatchedMs!, + write.activeWatchedMs!, + write.linesSeen!, + write.wordsSeen!, + write.tokensSeen!, + write.cardsMined!, + write.lookupCount!, + write.lookupHits!, + write.pauseCount!, + write.pauseMs!, + write.seekForwardCount!, + write.seekBackwardCount!, + write.mediaBufferEvents!, + ); + return; + } + + this.eventInsertStmt.run( + write.sessionId, + write.sampleMs!, + write.eventType!, + write.lineIndex ?? null, + write.segmentStartMs ?? null, + write.segmentEndMs ?? null, + write.wordsDelta ?? 0, + write.cardsDelta ?? 0, + write.payloadJson ?? null, + ); + } + + private applyPragmas(): void { + this.db.exec("PRAGMA journal_mode = WAL"); + this.db.exec("PRAGMA synchronous = NORMAL"); + this.db.exec("PRAGMA foreign_keys = ON"); + this.db.exec("PRAGMA busy_timeout = 2500"); + } + + private ensureSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS imm_schema_version ( + schema_version INTEGER PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + ); + `); + + const currentVersion = this.db + .prepare( + "SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1", + ) + .get() as { schema_version: number } | null; + if (currentVersion?.schema_version === SCHEMA_VERSION) { + return; + } + + this.db.exec(` + CREATE TABLE IF NOT EXISTS imm_videos( + video_id INTEGER PRIMARY KEY AUTOINCREMENT, + video_key TEXT NOT NULL UNIQUE, + canonical_title TEXT NOT NULL, + source_type INTEGER NOT NULL, + source_path TEXT, + source_url TEXT, + 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_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS imm_sessions( + session_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_uuid TEXT NOT NULL UNIQUE, + video_id INTEGER NOT NULL, + started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER, + status INTEGER NOT NULL, + locale_id INTEGER, target_lang_id INTEGER, + difficulty_tier INTEGER, subtitle_mode INTEGER, + created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, + FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS imm_session_telemetry( + telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + sample_ms INTEGER 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, + words_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, + 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, + FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS imm_session_events( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + ts_ms INTEGER NOT NULL, + event_type INTEGER NOT NULL, + line_index INTEGER, + segment_start_ms INTEGER, + segment_end_ms INTEGER, + words_delta INTEGER NOT NULL DEFAULT 0, + cards_delta INTEGER NOT NULL DEFAULT 0, + payload_json TEXT, + FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS 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_words_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, + words_per_min REAL, + lookup_hit_rate REAL, + PRIMARY KEY (rollup_day, video_id) + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS 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_words_seen INTEGER NOT NULL DEFAULT 0, + total_tokens_seen INTEGER NOT NULL DEFAULT 0, + total_cards INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (rollup_month, video_id) + ); + `); + + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_video_started + ON imm_sessions(video_id, started_at_ms DESC) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_status_started + ON imm_sessions(status, started_at_ms DESC) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_telemetry_session_sample + ON imm_session_telemetry(session_id, sample_ms DESC) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_session_ts + ON imm_session_events(session_id, ts_ms DESC) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_type_ts + ON imm_session_events(event_type, ts_ms DESC) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_rollups_day_video + ON imm_daily_rollups(rollup_day, video_id) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_rollups_month_video + ON imm_monthly_rollups(rollup_month, video_id) + `); + + this.db.exec(` + INSERT INTO imm_schema_version(schema_version, applied_at_ms) + VALUES (${SCHEMA_VERSION}, ${Date.now()}) + ON CONFLICT DO NOTHING + `); + } + + private scheduleMaintenance(): void { + this.maintenanceTimer = setInterval(() => { + this.runMaintenance(); + }, this.maintenanceIntervalMs); + this.runMaintenance(); + } + + private runMaintenance(): void { + if (this.isDestroyed) return; + try { + this.flushTelemetry(true); + this.flushNow(); + const nowMs = Date.now(); + const eventCutoff = nowMs - EVENTS_RETENTION_MS; + const telemetryCutoff = nowMs - TELEMETRY_RETENTION_MS; + const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS; + const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS; + const dayCutoff = Math.floor(dailyCutoff / 86_400_000); + const monthCutoff = this.toMonthKey(monthlyCutoff); + + this.db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff); + this.db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff); + this.db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff); + this.db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff); + this.db + .prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) + .run(telemetryCutoff); + this.runRollupMaintenance(); + + if ( + nowMs - this.lastVacuumMs >= VACUUM_INTERVAL_MS + && !this.writeLock.locked + ) { + this.db.exec("VACUUM"); + this.lastVacuumMs = nowMs; + } + this.lastMaintenanceMs = nowMs; + } catch (error) { + this.logger.warn( + "Immersion tracker maintenance failed, will retry later", + (error as Error).message, + ); + } + } + + private runRollupMaintenance(): void { + this.db.exec(` + INSERT OR REPLACE INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards, cards_per_hour, + words_per_min, lookup_hit_rate + ) + SELECT + CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS cards_per_hour, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS words_per_min, + CASE + WHEN COALESCE(SUM(t.lookup_count), 0) > 0 + THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL) + ELSE NULL + END AS lookup_hit_rate + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_day, s.video_id + `); + + this.db.exec(` + INSERT OR REPLACE INTO imm_monthly_rollups ( + rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards + ) + SELECT + CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_month, s.video_id + `); + } + + private toMonthKey(timestampMs: number): number { + const monthDate = new Date(timestampMs); + return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1; + } + + private startSession(videoId: number, startedAtMs?: number): void { + const nowMs = startedAtMs ?? Date.now(); + const result = this.startSessionStatement(videoId, nowMs); + const sessionId = Number(result.lastInsertRowid); + this.sessionState = { + sessionId, + videoId, + startedAtMs: nowMs, + currentLineIndex: 0, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + wordsSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + lastWallClockMs: 0, + lastMediaMs: null, + lastPauseStartMs: null, + isPaused: false, + pendingTelemetry: true, + }; + this.recordWrite({ + kind: "telemetry", + sessionId, + sampleMs: nowMs, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + wordsSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + }); + this.scheduleFlush(0); + } + + private startSessionStatement(videoId: number, startedAtMs: number): { + lastInsertRowid: number | bigint; + } { + const sessionUuid = crypto.randomUUID(); + return this.db + .prepare(` + INSERT INTO imm_sessions ( + session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + `) + .run( + sessionUuid, + videoId, + startedAtMs, + SESSION_STATUS_ACTIVE, + startedAtMs, + startedAtMs, + ); + } + + private finalizeActiveSession(): void { + if (!this.sessionState) return; + const endedAt = Date.now(); + if (this.sessionState.lastPauseStartMs) { + this.sessionState.pauseMs += Math.max( + 0, + endedAt - this.sessionState.lastPauseStartMs, + ); + this.sessionState.lastPauseStartMs = null; + } + const finalWallNow = endedAt; + if (this.sessionState.lastWallClockMs > 0) { + const wallDelta = finalWallNow - this.sessionState.lastWallClockMs; + if (wallDelta > 0 && wallDelta < 60_000) { + this.sessionState.totalWatchedMs += wallDelta; + if (!this.sessionState.isPaused) { + this.sessionState.activeWatchedMs += wallDelta; + } + } + } + this.flushTelemetry(true); + this.flushNow(); + this.sessionState.pendingTelemetry = false; + + this.db + .prepare( + "UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?", + ) + .run(endedAt, SESSION_STATUS_ENDED, Date.now(), this.sessionState.sessionId); + this.sessionState = null; + } + + private getOrCreateVideo(videoKey: string, details: { + canonicalTitle: string; + sourcePath: string | null; + sourceUrl: string | null; + sourceType: number; + }): number { + const existing = this.db + .prepare("SELECT video_id FROM imm_videos WHERE video_key = ?") + .get(videoKey) as { video_id: number } | null; + if (existing?.video_id) { + this.db + .prepare( + "UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?", + ) + .run(details.canonicalTitle || "unknown", Date.now(), existing.video_id); + return existing.video_id; + } + + const nowMs = Date.now(); + const insert = this.db.prepare(` + INSERT INTO imm_videos ( + video_key, canonical_title, source_type, source_path, source_url, + duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px, + fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path, + metadata_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = insert.run( + videoKey, + details.canonicalTitle || "unknown", + details.sourceType, + details.sourcePath, + details.sourceUrl, + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + nowMs, + nowMs, + ); + return Number(result.lastInsertRowid); + } + + private updateVideoMetadata(videoId: number, metadata: VideoMetadata): void { + this.db + .prepare(` + UPDATE imm_videos + SET + duration_ms = ?, + file_size_bytes = ?, + codec_id = ?, + container_id = ?, + width_px = ?, + height_px = ?, + fps_x100 = ?, + bitrate_kbps = ?, + audio_codec_id = ?, + hash_sha256 = ?, + screenshot_path = ?, + metadata_json = ?, + updated_at_ms = ? + WHERE video_id = ? + `) + .run( + metadata.durationMs, + metadata.fileSizeBytes, + metadata.codecId, + metadata.containerId, + metadata.widthPx, + metadata.heightPx, + metadata.fpsX100, + metadata.bitrateKbps, + metadata.audioCodecId, + metadata.hashSha256, + metadata.screenshotPath, + metadata.metadataJson, + Date.now(), + videoId, + ); + } + + private captureVideoMetadataAsync( + videoId: number, + sourceType: number, + mediaPath: string, + ): void { + if (sourceType !== SOURCE_TYPE_LOCAL) return; + void (async () => { + try { + const metadata = await this.getLocalVideoMetadata(mediaPath); + this.updateVideoMetadata(videoId, metadata); + } catch (error) { + this.logger.warn( + "Unable to capture local video metadata", + (error as Error).message, + ); + } + })(); + } + + private async getLocalVideoMetadata(mediaPath: string): Promise { + const hash = await this.computeSha256(mediaPath); + const info = await this.runFfprobe(mediaPath); + const stat = await fs.promises.stat(mediaPath); + return { + sourceType: SOURCE_TYPE_LOCAL, + canonicalTitle: this.deriveCanonicalTitle(mediaPath), + durationMs: info.durationMs || 0, + fileSizeBytes: Number.isFinite(stat.size) ? stat.size : null, + codecId: info.codecId ?? null, + containerId: info.containerId ?? null, + widthPx: info.widthPx ?? null, + heightPx: info.heightPx ?? null, + fpsX100: info.fpsX100 ?? null, + bitrateKbps: info.bitrateKbps ?? null, + audioCodecId: info.audioCodecId ?? null, + hashSha256: hash, + screenshotPath: null, + metadataJson: null, + }; + } + + private async computeSha256(mediaPath: string): Promise { + return new Promise((resolve) => { + const file = fs.createReadStream(mediaPath); + const digest = crypto.createHash("sha256"); + file.on("data", (chunk) => digest.update(chunk)); + file.on("end", () => resolve(digest.digest("hex"))); + file.on("error", () => resolve(null)); + }); + } + + private runFfprobe(mediaPath: string): Promise<{ + durationMs: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; + }> { + return new Promise((resolve) => { + const child = spawn("ffprobe", [ + "-v", + "error", + "-print_format", + "json", + "-show_entries", + "stream=codec_type,codec_tag_string,width,height,avg_frame_rate,bit_rate", + "-show_entries", + "format=duration,bit_rate", + mediaPath, + ]); + + let output = ""; + let errorOutput = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString("utf-8"); + }); + child.stderr.on("data", (chunk) => { + errorOutput += chunk.toString("utf-8"); + }); + child.on("error", () => resolve(this.emptyMetadata())); + child.on("close", () => { + if (errorOutput && output.length === 0) { + resolve(this.emptyMetadata()); + return; + } + + try { + const parsed = JSON.parse(output) as { + format?: { duration?: string; bit_rate?: string }; + streams?: Array<{ + codec_type?: string; + codec_tag_string?: string; + width?: number; + height?: number; + avg_frame_rate?: string; + bit_rate?: string; + }>; + }; + + const durationText = parsed.format?.duration; + const bitrateText = parsed.format?.bit_rate; + const durationMs = Number(durationText) + ? Math.round(Number(durationText) * 1000) + : null; + const bitrateKbps = Number(bitrateText) + ? Math.round(Number(bitrateText) / 1000) + : null; + + let codecId: number | null = null; + let containerId: number | null = null; + let widthPx: number | null = null; + let heightPx: number | null = null; + let fpsX100: number | null = null; + let audioCodecId: number | null = null; + + for (const stream of parsed.streams ?? []) { + if (stream.codec_type === "video") { + widthPx = this.toNullableInt(stream.width); + heightPx = this.toNullableInt(stream.height); + fpsX100 = this.parseFps(stream.avg_frame_rate); + codecId = this.hashToCode(stream.codec_tag_string); + containerId = 0; + } + if (stream.codec_type === "audio") { + audioCodecId = this.hashToCode(stream.codec_tag_string); + if (audioCodecId && audioCodecId > 0) { + break; + } + } + } + + resolve({ + durationMs, + codecId, + containerId, + widthPx, + heightPx, + fpsX100, + bitrateKbps, + audioCodecId, + }); + } catch { + resolve(this.emptyMetadata()); + } + }); + }); + } + + private emptyMetadata(): { + durationMs: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; + } { + return { + durationMs: null, + codecId: null, + containerId: null, + widthPx: null, + heightPx: null, + fpsX100: null, + bitrateKbps: null, + audioCodecId: null, + }; + } + + private parseFps(value?: string): number | null { + if (!value || typeof value !== "string") return null; + const [num, den] = value.split("/"); + const n = Number(num); + const d = Number(den); + if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null; + const fps = n / d; + return Number.isFinite(fps) ? Math.round(fps * 100) : null; + } + + private hashToCode(input?: string): number | null { + if (!input) return null; + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff; + } + return hash || null; + } + + private sanitizePayload(payload: Record): string { + const json = JSON.stringify(payload); + return json.length <= MAX_PAYLOAD_BYTES + ? json + : JSON.stringify({ truncated: true }); + } + + private calculateTextMetrics(value: string): { words: number; tokens: number } { + const words = value.split(/\s+/).filter(Boolean).length; + const cjkCount = (value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0); + const tokens = Math.max(words, cjkCount); + return { words, tokens }; + } + + private secToMs(seconds: number): number { + const coerced = Number(seconds); + if (!Number.isFinite(coerced)) return 0; + return Math.round(coerced * 1000); + } + + private normalizeMediaPath(mediaPath: string | null): string { + if (!mediaPath || !mediaPath.trim()) return ""; + return mediaPath.trim(); + } + + private normalizeText(value: string | null | undefined): string { + if (!value) return ""; + return value.trim().replace(/\s+/g, " "); + } + + private buildVideoKey(mediaPath: string, sourceType: number): string { + if (sourceType === SOURCE_TYPE_REMOTE) { + return `remote:${mediaPath}`; + } + return `local:${mediaPath}`; + } + + private isRemoteSource(mediaPath: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); + } + + private deriveCanonicalTitle(mediaPath: string): string { + if (this.isRemoteSource(mediaPath)) { + try { + const parsed = new URL(mediaPath); + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length > 0) { + const leaf = decodeURIComponent(parts[parts.length - 1]); + return this.normalizeText(leaf.replace(/\.[^/.]+$/, "")); + } + return this.normalizeText(parsed.hostname) || "unknown"; + } catch { + return this.normalizeText(mediaPath); + } + } + + const filename = path.basename(mediaPath); + return this.normalizeText(filename.replace(/\.[^/.]+$/, "")); + } + + private toNullableInt(value: number | null | undefined): number | null { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; + } + + private updateVideoTitleForActiveSession(canonicalTitle: string): void { + if (!this.sessionState) return; + this.db + .prepare( + "UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?", + ) + .run(canonicalTitle, Date.now(), this.sessionState.videoId); + } +} diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 57d82c7..56c328f 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -89,6 +89,7 @@ export { } from "./mpv-render-metrics-service"; export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service"; +export { ImmersionTrackerService } from "./immersion-tracker-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; export { runStartupBootstrapRuntimeService } from "./startup-service"; diff --git a/src/core/services/mining-service.test.ts b/src/core/services/mining-service.test.ts index ef0c427..34bf4bf 100644 --- a/src/core/services/mining-service.test.ts +++ b/src/core/services/mining-service.test.ts @@ -52,19 +52,23 @@ test("copyCurrentSubtitleService copies current subtitle text", () => { test("mineSentenceCardService handles missing integration and disconnected mpv", async () => { const osd: string[] = []; - await mineSentenceCardService({ + assert.equal( + await mineSentenceCardService({ ankiIntegration: null, mpvClient: null, showMpvOsd: (text) => osd.push(text), - }); + }), + false, + ); assert.equal(osd.at(-1), "AnkiConnect integration not enabled"); - await mineSentenceCardService({ + assert.equal( + await mineSentenceCardService({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, - createSentenceCard: async () => {}, + createSentenceCard: async () => false, }, mpvClient: { connected: false, @@ -73,7 +77,9 @@ test("mineSentenceCardService handles missing integration and disconnected mpv", currentSubEnd: 2, }, showMpvOsd: (text) => osd.push(text), - }); + }), + false, + ); assert.equal(osd.at(-1), "MPV not connected"); }); @@ -86,13 +92,14 @@ test("mineSentenceCardService creates sentence card from mpv subtitle state", as secondarySub?: string; }> = []; - await mineSentenceCardService({ + const createdCard = await mineSentenceCardService({ ankiIntegration: { updateLastAddedFromClipboard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {}, markLastCardAsAudioCard: async () => {}, createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { created.push({ sentence, startTime, endTime, secondarySub }); + return true; }, }, mpvClient: { @@ -105,6 +112,7 @@ test("mineSentenceCardService creates sentence card from mpv subtitle state", as showMpvOsd: () => {}, }); + assert.equal(createdCard, true); assert.deepEqual(created, [ { sentence: "subtitle line", @@ -136,6 +144,7 @@ test("handleMultiCopyDigitService copies available history and reports truncatio test("handleMineSentenceDigitService reports async create failures", async () => { const osd: string[] = []; const logs: Array<{ message: string; err: unknown }> = []; + let cardsMined = 0; handleMineSentenceDigitService(2, { subtitleTimingTracker: { @@ -157,6 +166,9 @@ test("handleMineSentenceDigitService reports async create failures", async () => getCurrentSecondarySubText: () => "sub2", showMpvOsd: (text) => osd.push(text), logError: (message, err) => logs.push({ message, err }), + onCardsMined: (count) => { + cardsMined += count; + }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -165,4 +177,37 @@ test("handleMineSentenceDigitService reports async create failures", async () => assert.equal(logs[0]?.message, "mineSentenceMultiple failed:"); assert.equal((logs[0]?.err as Error).message, "mine boom"); assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom"))); + assert.equal(cardsMined, 0); +}); + +test("handleMineSentenceDigitService increments successful card count", async () => { + const osd: string[] = []; + let cardsMined = 0; + + handleMineSentenceDigitService(2, { + subtitleTimingTracker: { + getRecentBlocks: () => ["one", "two"], + getCurrentSubtitle: () => null, + findTiming: (text) => + text === "one" + ? { startTime: 1, endTime: 3 } + : { startTime: 4, endTime: 7 }, + }, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => true, + }, + getCurrentSecondarySubText: () => "sub2", + showMpvOsd: (text) => osd.push(text), + logError: () => {}, + onCardsMined: (count) => { + cardsMined += count; + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(cardsMined, 1); }); diff --git a/src/core/services/mining-service.ts b/src/core/services/mining-service.ts index 1f7c932..afb266d 100644 --- a/src/core/services/mining-service.ts +++ b/src/core/services/mining-service.ts @@ -13,7 +13,7 @@ interface AnkiIntegrationLike { startTime: number, endTime: number, secondarySub?: string, - ) => Promise; + ) => Promise; } interface MpvClientLike { @@ -111,21 +111,21 @@ export async function mineSentenceCardService(deps: { ankiIntegration: AnkiIntegrationLike | null; mpvClient: MpvClientLike | null; showMpvOsd: (text: string) => void; -}): Promise { +}): Promise { const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); - if (!anki) return; + if (!anki) return false; const mpvClient = deps.mpvClient; if (!mpvClient || !mpvClient.connected) { deps.showMpvOsd("MPV not connected"); - return; + return false; } if (!mpvClient.currentSubText) { deps.showMpvOsd("No current subtitle"); - return; + return false; } - await anki.createSentenceCard( + return await anki.createSentenceCard( mpvClient.currentSubText, mpvClient.currentSubStart, mpvClient.currentSubEnd, @@ -141,6 +141,7 @@ export function handleMineSentenceDigitService( getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; + onCardsMined?: (count: number) => void; }, ): void { if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; @@ -165,6 +166,7 @@ export function handleMineSentenceDigitService( const rangeStart = Math.min(...timings.map((t) => t.startTime)); const rangeEnd = Math.max(...timings.map((t) => t.endTime)); const sentence = blocks.join(" "); + const cardsToMine = 1; deps.ankiIntegration .createSentenceCard( sentence, @@ -172,6 +174,11 @@ export function handleMineSentenceDigitService( rangeEnd, deps.getCurrentSecondarySubText(), ) + .then((created) => { + if (created) { + deps.onCardsMined?.(cardsToMine); + } + }) .catch((err) => { deps.logError("mineSentenceMultiple failed:", err); deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 798ede9..d9e7f6a 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -21,6 +21,7 @@ import { MPV_REQUEST_ID_SUBTEXT, MPV_REQUEST_ID_SUBTEXT_ASS, MPV_REQUEST_ID_SUB_USE_MARGINS, + MPV_REQUEST_ID_PAUSE, } from "./mpv-protocol"; type MpvProtocolCommand = { @@ -57,6 +58,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ "sub-shadow-offset", "sub-ass-override", "sub-use-margins", + "pause", "media-title", ]; @@ -76,6 +78,10 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ { command: ["get_property", "media-title"], }, + { + command: ["get_property", "pause"], + request_id: MPV_REQUEST_ID_PAUSE, + }, { command: ["get_property", "secondary-sub-text"], request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 1aa595f..56cd8f4 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -84,6 +84,8 @@ function createDeps(overrides: Partial = {}): { setPendingPauseAtSubEnd: () => {}, getPauseAtTime: () => null, setPauseAtTime: () => {}, + emitTimePosChange: () => {}, + emitPauseChange: () => {}, autoLoadSecondarySubTrack: () => {}, setCurrentVideoPath: () => {}, emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 37a8048..e3b8066 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -30,6 +30,7 @@ export const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; export const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; +export const MPV_REQUEST_ID_PAUSE = 123; export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; @@ -60,6 +61,8 @@ export interface MpvProtocolHandleMessageDeps { getCurrentSubEnd: () => number; emitMediaPathChange: (payload: { path: string }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void; + emitTimePosChange: (payload: { time: number }) => void; + emitPauseChange: (payload: { paused: boolean }) => void; emitSubtitleMetricsChange: (payload: Partial) => void; setCurrentSecondarySubText: (text: string) => void; resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; @@ -160,6 +163,7 @@ export async function dispatchMpvProtocolMessage( ); deps.syncCurrentAudioStreamIndex(); } else if (msg.name === "time-pos") { + deps.emitTimePosChange({ time: (msg.data as number) || 0 }); deps.setCurrentTimePos((msg.data as number) || 0); if ( deps.getPauseAtTime() !== null && @@ -168,6 +172,8 @@ export async function dispatchMpvProtocolMessage( deps.setPauseAtTime(null); deps.sendCommand({ command: ["set_property", "pause", true] }); } + } else if (msg.name === "pause") { + deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); } else if (msg.name === "media-title") { deps.emitMediaTitleChange({ title: typeof msg.data === "string" ? msg.data.trim() : null, @@ -348,6 +354,8 @@ export async function dispatchMpvProtocolMessage( deps.getSubtitleMetrics().subUseMargins, ), }); + } else if (msg.request_id === MPV_REQUEST_ID_PAUSE) { + deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index 9de82df..a808767 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -117,6 +117,8 @@ export interface MpvIpcClientEventMap { "subtitle-change": { text: string; isOverlayVisible: boolean }; "subtitle-ass-change": { text: string }; "subtitle-timing": { text: string; start: number; end: number }; + "time-pos-change": { time: number }; + "pause-change": { paused: boolean }; "secondary-subtitle-change": { text: string }; "media-path-change": { path: string }; "media-title-change": { title: string | null }; @@ -258,9 +260,13 @@ export class MpvIpcClient implements MpvClient { connect(): void { if (this.connected || this.connecting) { + logger.debug( + `MPV IPC connect request skipped; connected=${this.connected}, connecting=${this.connecting}`, + ); return; } + logger.info("MPV IPC connect requested."); this.connecting = true; this.transport.connect(); } @@ -313,6 +319,12 @@ export class MpvIpcClient implements MpvClient { emitSubtitleTiming: (payload) => { this.emit("subtitle-timing", payload); }, + emitTimePosChange: (payload) => { + this.emit("time-pos-change", payload); + }, + emitPauseChange: (payload) => { + this.emit("pause-change", payload); + }, emitSecondarySubtitleChange: (payload) => { this.emit("secondary-subtitle-change", payload); }, diff --git a/src/core/services/startup-service.ts b/src/core/services/startup-service.ts index c3b7a32..3e11b86 100644 --- a/src/core/services/startup-service.ts +++ b/src/core/services/startup-service.ts @@ -99,6 +99,7 @@ export interface AppReadyRuntimeDeps { log: (message: string) => void; createMecabTokenizerAndCheck: () => Promise; createSubtitleTimingTracker: () => void; + createImmersionTracker?: () => void; loadYomitanExtension: () => Promise; texthookerOnlyMode: boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; @@ -173,6 +174,16 @@ export async function runAppReadyRuntimeService( } deps.createSubtitleTimingTracker(); + if (deps.createImmersionTracker) { + deps.log("Runtime ready: invoking createImmersionTracker."); + try { + deps.createImmersionTracker(); + } catch (error) { + deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`); + } + } else { + deps.log("Runtime ready: createImmersionTracker dependency is missing."); + } await deps.loadYomitanExtension(); if (deps.texthookerOnlyMode) { diff --git a/src/main.ts b/src/main.ts index 7a2257c..4e1d5b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import { markLastCardAsAudioCardService, DEFAULT_MPV_SUBTITLE_RENDER_METRICS, mineSentenceCardService, + ImmersionTrackerService, openYomitanSettingsWindow, playNextSubtitleRuntimeService, registerGlobalShortcutsService, @@ -262,6 +263,7 @@ function resolveConfigDir(): string { const CONFIG_DIR = resolveConfigDir(); const USER_DATA_PATH = CONFIG_DIR; const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; +const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, "immersion.sqlite"); const configService = new ConfigService(CONFIG_DIR); const anilistTokenStore = createAnilistTokenStore( path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE), @@ -580,6 +582,68 @@ function openRuntimeOptionsPalette(): void { } function getResolvedConfig() { return configService.getConfig(); } +function getConfiguredImmersionDbPath(): string { + const configuredDbPath = getResolvedConfig().immersionTracking?.dbPath?.trim(); + return configuredDbPath + ? configuredDbPath + : DEFAULT_IMMERSION_DB_PATH; +} + +let isImmersionTrackerMediaSeedInProgress = false; + +type ImmersionMediaState = { + path: string | null; + title: string | null; +}; + +async function readMpvPropertyAsString( + mpvClient: MpvIpcClient | null | undefined, + propertyName: string, +): Promise { + if (!mpvClient) { + return null; + } + try { + const value = await mpvClient.requestProperty(propertyName); + return typeof value === "string" ? value.trim() || null : null; + } catch { + return null; + } +} + +async function getCurrentMpvMediaStateForTracker(): Promise { + const statePath = appState.currentMediaPath?.trim() || null; + if (statePath) { + return { + path: statePath, + title: appState.currentMediaTitle?.trim() || null, + }; + } + + const mpvClient = appState.mpvClient; + const trackedPath = mpvClient?.currentVideoPath?.trim() || null; + if (trackedPath) { + return { + path: trackedPath, + title: appState.currentMediaTitle?.trim() || null, + }; + } + + const [pathFromProperty, filenameFromProperty, titleFromProperty] = + await Promise.all([ + readMpvPropertyAsString(mpvClient, "path"), + readMpvPropertyAsString(mpvClient, "filename"), + readMpvPropertyAsString(mpvClient, "media-title"), + ]); + + const resolvedPath = pathFromProperty || filenameFromProperty || null; + const resolvedTitle = appState.currentMediaTitle?.trim() || titleFromProperty || null; + + return { + path: resolvedPath, + title: resolvedTitle, + }; +} function getInitialInvisibleOverlayVisibility(): boolean { return getInitialInvisibleOverlayVisibilityService( @@ -609,6 +673,83 @@ function getJimakuMaxEntryResults(): number { return getJimakuMaxEntryResultsSer async function resolveJimakuApiKey(): Promise { return resolveJimakuApiKeyService(() => getResolvedConfig()); } +function seedImmersionTrackerFromCurrentMedia(): void { + const tracker = appState.immersionTracker; + if (!tracker) { + logger.debug("Immersion tracker seeding skipped: tracker not initialized."); + return; + } + if (isImmersionTrackerMediaSeedInProgress) { + logger.debug( + "Immersion tracker seeding already in progress; skipping duplicate call.", + ); + return; + } + logger.debug("Starting immersion tracker media-state seed loop."); + isImmersionTrackerMediaSeedInProgress = true; + + void (async () => { + const waitMs = 250; + const attempts = 120; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const mediaState = await getCurrentMpvMediaStateForTracker(); + if (mediaState.path) { + logger.info( + `Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ` + + `${mediaState.path}`, + ); + tracker.handleMediaChange(mediaState.path, mediaState.title); + return; + } + + const mpvClient = appState.mpvClient; + if (!mpvClient || !mpvClient.connected) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; + } + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } + + logger.info( + "Immersion tracker seed failed: media path still unavailable after startup warmup", + ); + })().finally(() => { + isImmersionTrackerMediaSeedInProgress = false; + }); +} + +function syncImmersionTrackerFromCurrentMediaState(): void { + const tracker = appState.immersionTracker; + if (!tracker) { + logger.debug( + "Immersion tracker sync skipped: tracker not initialized yet.", + ); + return; + } + + const pathFromState = appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim(); + if (pathFromState) { + logger.debug( + "Immersion tracker sync using path from current media state.", + ); + tracker.handleMediaChange(pathFromState, appState.currentMediaTitle); + return; + } + + if (!isImmersionTrackerMediaSeedInProgress) { + logger.debug( + "Immersion tracker sync did not find media path; starting seed loop.", + ); + seedImmersionTrackerFromCurrentMedia(); + } else { + logger.debug( + "Immersion tracker sync found seed loop already running.", + ); + } +} + async function jimakuFetchJson( endpoint: string, query: Record = {}, @@ -1176,6 +1317,32 @@ const startupState = runStartupBootstrapRuntimeService( const tracker = new SubtitleTimingTracker(); appState.subtitleTimingTracker = tracker; }, + createImmersionTracker: () => { + const config = getResolvedConfig(); + if (config.immersionTracking?.enabled === false) { + logger.info("Immersion tracking disabled in config"); + return; + } + try { + logger.debug( + "Immersion tracker startup requested: creating tracker service.", + ); + const dbPath = getConfiguredImmersionDbPath(); + logger.info(`Creating immersion tracker with dbPath=${dbPath}`); + appState.immersionTracker = new ImmersionTrackerService({ + dbPath, + }); + logger.debug("Immersion tracker initialized successfully."); + if (appState.mpvClient && !appState.mpvClient.connected) { + logger.info("Auto-connecting MPV client for immersion tracking"); + appState.mpvClient.connect(); + } + seedImmersionTrackerFromCurrentMedia(); + } catch (error) { + logger.warn("Immersion tracker startup failed; disabling tracking.", error); + appState.immersionTracker = null; + } + }, loadYomitanExtension: async () => { await loadYomitanExtension(); }, @@ -1208,6 +1375,10 @@ const startupState = runStartupBootstrapRuntimeService( if (appState.subtitleTimingTracker) { appState.subtitleTimingTracker.destroy(); } + if (appState.immersionTracker) { + appState.immersionTracker.destroy(); + appState.immersionTracker = null; + } if (appState.ankiIntegration) { appState.ankiIntegration.destroy(); } @@ -1292,6 +1463,15 @@ function handleCliCommand( function handleInitialArgs(): void { if (!appState.initialArgs) return; + if ( + !appState.texthookerOnlyMode && + appState.immersionTracker && + appState.mpvClient && + !appState.mpvClient.connected + ) { + logger.info("Auto-connecting MPV client for immersion tracking"); + appState.mpvClient.connect(); + } handleCliCommand(appState.initialArgs, "initial"); } @@ -1314,9 +1494,14 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { broadcastToOverlayWindows("secondary-subtitle:set", text); }); mpvClient.on("subtitle-timing", ({ text, start, end }) => { - if (text.trim() && appState.subtitleTimingTracker) { - appState.subtitleTimingTracker.recordSubtitle(text, start, end); + if (!text.trim()) { + return; } + appState.immersionTracker?.recordSubtitleLine(text, start, end); + if (!appState.subtitleTimingTracker) { + return; + } + appState.subtitleTimingTracker.recordSubtitle(text, start, end); void maybeRunAnilistPostWatchUpdate().catch((error) => { logger.error("AniList post-watch update failed unexpectedly", error); }); @@ -1329,11 +1514,20 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { void maybeProbeAnilistDuration(mediaKey); void ensureAnilistMediaGuess(mediaKey); } + syncImmersionTrackerFromCurrentMediaState(); }); mpvClient.on("media-title-change", ({ title }) => { mediaRuntime.updateCurrentMediaTitle(title); anilistCurrentMediaGuess = null; anilistCurrentMediaGuessPromise = null; + appState.immersionTracker?.handleMediaTitleUpdate(title); + syncImmersionTrackerFromCurrentMediaState(); + }); + mpvClient.on("time-pos-change", ({ time }) => { + appState.immersionTracker?.recordPlaybackPosition(time); + }); + mpvClient.on("pause-change", ({ paused }) => { + appState.immersionTracker?.recordPauseState(paused); }); mpvClient.on("subtitle-metrics-change", ({ patch }) => { updateMpvSubtitleRenderMetrics(patch); @@ -1357,6 +1551,7 @@ function createMpvClientRuntimeService(): MpvIpcClient { }, }); bindMpvClientEventHandlers(mpvClient); + mpvClient.connect(); return mpvClient; } @@ -1395,7 +1590,11 @@ async function tokenizeSubtitle(text: string): Promise { appState.yomitanParserInitPromise = promise; }, isKnownWord: (text) => - Boolean(appState.ankiIntegration?.isKnownWord(text)), + (() => { + const hit = Boolean(appState.ankiIntegration?.isKnownWord(text)); + appState.immersionTracker?.recordLookup(hit); + return hit; + })(), getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ?? getResolvedConfig().ankiConnect.nPlusOne.matchMode, @@ -1721,13 +1920,16 @@ async function markLastCardAsAudioCard(): Promise { } async function mineSentenceCard(): Promise { - await mineSentenceCardService( + const created = await mineSentenceCardService( { ankiIntegration: appState.ankiIntegration, mpvClient: appState.mpvClient, showMpvOsd: (text) => showMpvOsd(text), }, ); + if (created) { + appState.immersionTracker?.recordCardsMined(1); + } } function cancelPendingMineSentenceMultiple(): void { @@ -1758,6 +1960,9 @@ function handleMineSentenceDigit(count: number): void { logError: (message, err) => { logger.error(message, err); }, + onCardsMined: (cards) => { + appState.immersionTracker?.recordCardsMined(cards); + }, }, ); } diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index ce5af7f..c25f645 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -35,6 +35,7 @@ export interface AppReadyRuntimeDepsFactoryInput { setLogLevel: AppReadyRuntimeDeps["setLogLevel"]; createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"]; createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"]; + createImmersionTracker?: AppReadyRuntimeDeps["createImmersionTracker"]; loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"]; texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"]; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"]; @@ -81,6 +82,7 @@ export function createAppReadyRuntimeDeps( setLogLevel: params.setLogLevel, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, createSubtitleTimingTracker: params.createSubtitleTimingTracker, + createImmersionTracker: params.createImmersionTracker, loadYomitanExtension: params.loadYomitanExtension, texthookerOnlyMode: params.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: diff --git a/src/main/state.ts b/src/main/state.ts index 85e4384..316458b 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -12,6 +12,7 @@ import type { import type { CliArgs } from "../cli/args"; import type { SubtitleTimingTracker } from "../subtitle-timing-tracker"; import type { AnkiIntegration } from "../anki-integration"; +import type { ImmersionTrackerService } from "../core/services"; import type { MpvIpcClient } from "../core/services"; import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services"; import type { RuntimeOptionsManager } from "../runtime-options"; @@ -54,6 +55,7 @@ export interface AppState { mecabTokenizer: MecabTokenizer | null; keybindings: Keybinding[]; subtitleTimingTracker: SubtitleTimingTracker | null; + immersionTracker: ImmersionTrackerService | null; ankiIntegration: AnkiIntegration | null; secondarySubMode: SecondarySubMode; lastSecondarySubToggleAtMs: number; @@ -123,6 +125,7 @@ export function createAppState(values: AppStateInitialValues): AppState { mecabTokenizer: null, keybindings: [], subtitleTimingTracker: null, + immersionTracker: null, ankiIntegration: null, secondarySubMode: "hover", lastSecondarySubToggleAtMs: 0, diff --git a/src/types.ts b/src/types.ts index f7fe614..200674e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -351,6 +351,11 @@ export interface YoutubeSubgenConfig { primarySubLanguages?: string[]; } +export interface ImmersionTrackingConfig { + enabled?: boolean; + dbPath?: string; +} + export interface Config { subtitlePosition?: SubtitlePosition; keybindings?: Keybinding[]; @@ -367,6 +372,7 @@ export interface Config { anilist?: AnilistConfig; invisibleOverlay?: InvisibleOverlayConfig; youtubeSubgen?: YoutubeSubgenConfig; + immersionTracking?: ImmersionTrackingConfig; logging?: { level?: "debug" | "info" | "warn" | "error"; }; @@ -481,6 +487,10 @@ export interface ResolvedConfig { whisperModel: string; primarySubLanguages: string[]; }; + immersionTracking: { + enabled: boolean; + dbPath?: string; + }; logging: { level: "debug" | "info" | "warn" | "error"; }; diff --git a/subminer b/subminer index ce998ca..0baf357 100755 --- a/subminer +++ b/subminer @@ -2440,11 +2440,37 @@ function parseArgs( fail(`Unknown option: ${arg}`); } - break; + if (!parsed.target) { + if (isUrlTarget(arg)) { + parsed.target = arg; + parsed.targetKind = "url"; + } else { + const resolved = resolvePathMaybe(arg); + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { + parsed.target = resolved; + parsed.targetKind = "file"; + } else if ( + fs.existsSync(resolved) && + fs.statSync(resolved).isDirectory() + ) { + parsed.directory = resolved; + } else { + fail(`Not a file, directory, or supported URL: ${arg}`); + } + } + i += 1; + continue; + } + + fail(`Unexpected positional argument: ${arg}`); } const positional = argv.slice(i); if (positional.length > 0) { + if (parsed.target || parsed.directory) { + fail(`Unexpected positional argument: ${positional[0]}`); + } + const target = positional[0]; if (isUrlTarget(target)) { parsed.target = target; @@ -2463,6 +2489,10 @@ function parseArgs( fail(`Not a file, directory, or supported URL: ${target}`); } } + + if (positional.length > 1) { + fail(`Unexpected positional argument: ${positional[1]}`); + } } return parsed;