From 79bf5ebefb4dc0b108b70ac019022eaa1b34d977 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Feb 2026 00:59:09 -0800 Subject: [PATCH] test: add immersion tracking startup safety and sqlite tests --- docs/public/config.example.jsonc | 8 + package.json | 2 +- src/core/services/app-ready-service.test.ts | 18 + .../immersion-tracker-service.test.ts | 368 ++++++++++++++++++ .../services/immersion-tracker-service.ts | 75 ++-- src/core/services/startup-service.ts | 6 +- src/main.ts | 30 +- 7 files changed, 457 insertions(+), 50 deletions(-) create mode 100644 src/core/services/immersion-tracker-service.test.ts 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/src/core/services/app-ready-service.test.ts b/src/core/services/app-ready-service.test.ts index 0017fa3..4287841 100644 --- a/src/core/services/app-ready-service.test.ts +++ b/src/core/services/app-ready-service.test.ts @@ -62,6 +62,24 @@ test("runAppReadyRuntimeService logs when createImmersionTracker dependency is m ); }); +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 () => { const { deps, calls } = makeDeps({ shouldAutoInitializeOverlayRuntimeFromConfig: () => false, 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..cc95325 --- /dev/null +++ b/src/core/services/immersion-tracker-service.test.ts @@ -0,0 +1,368 @@ +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 session with a single telemetry flush path", () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + tracker = new ImmersionTrackerService({ dbPath }); + const privateApi = tracker as unknown as { + flushTelemetry: (force?: boolean) => void; + }; + + let flushCalls = 0; + const originalFlushTelemetry = privateApi.flushTelemetry.bind(tracker); + privateApi.flushTelemetry = (force?: boolean): void => { + flushCalls += 1; + originalFlushTelemetry(force); + }; + + tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2"); + tracker.recordSubtitleLine("Hello immersion", 0, 1); + tracker.destroy(); + + assert.equal(flushCalls, 1); + } 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 + ) + `); + + 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 + ) + `); + + 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 index 38718fd..17abc6e 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -10,8 +10,9 @@ 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 WEEK_MS = 7 * 24 * 60 * 60 * 1000; -const EVENTS_RETENTION_MS = 7 * 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; @@ -159,7 +160,6 @@ export class ImmersionTrackerService { private maintenanceTimer: ReturnType | null = null; private flushScheduled = false; private droppedWriteCount = 0; - private pendingFlush = false; private lastMaintenanceMs = 0; private lastVacuumMs = 0; private isDestroyed = false; @@ -167,6 +167,8 @@ export class ImmersionTrackerService { private currentVideoKey = ""; private currentMediaPathOrUrl = ""; private lastQueueWriteAtMs = 0; + private readonly telemetryInsertStmt: ReturnType; + private readonly eventInsertStmt: ReturnType; constructor(options: ImmersionTrackerOptions) { this.dbPath = options.dbPath; @@ -184,6 +186,24 @@ export class ImmersionTrackerService { 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(); } @@ -199,10 +219,7 @@ export class ImmersionTrackerService { clearInterval(this.maintenanceTimer); this.maintenanceTimer = null; } - this.flushTelemetry(true); - this.flushNow(); this.finalizeActiveSession(); - this.flushNow(); this.db.close(); } @@ -336,7 +353,7 @@ export class ImmersionTrackerService { } const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; - const videoKey = this.buildVideoKey(normalizedPath, sourceType, normalizedTitle); + 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; @@ -622,7 +639,6 @@ export class ImmersionTrackerService { const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); this.writeLock.locked = true; - this.pendingFlush = true; try { this.db.exec("BEGIN IMMEDIATE"); for (const write of batch) { @@ -635,7 +651,6 @@ export class ImmersionTrackerService { this.logger.warn("Immersion tracker flush failed, retrying later", error as Error); } finally { this.writeLock.locked = false; - this.pendingFlush = false; this.flushScheduled = false; if (this.queue.length > 0) { this.scheduleFlush(this.flushIntervalMs); @@ -645,17 +660,7 @@ export class ImmersionTrackerService { private flushSingle(write: QueuedWrite): void { if (write.kind === "telemetry") { - const stmt = 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 ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - ) - `); - stmt.run( + this.telemetryInsertStmt.run( write.sessionId, write.sampleMs!, write.totalWatchedMs!, @@ -675,15 +680,7 @@ export class ImmersionTrackerService { return; } - const eventStmt = 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 ( - ?, ?, ?, ?, ?, ?, ?, ?, ? - ) - `); - eventStmt.run( + this.eventInsertStmt.run( write.sessionId, write.sampleMs!, write.eventType!, @@ -871,7 +868,7 @@ export class ImmersionTrackerService { const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS; const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS; const dayCutoff = Math.floor(dailyCutoff / 86_400_000); - const monthCutoff = Math.floor(monthlyCutoff / 2_592_000_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); @@ -882,7 +879,10 @@ export class ImmersionTrackerService { .run(telemetryCutoff); this.runRollupMaintenance(); - if (nowMs - this.lastVacuumMs >= WEEK_MS && !this.writeLock.locked) { + if ( + nowMs - this.lastVacuumMs >= VACUUM_INTERVAL_MS + && !this.writeLock.locked + ) { this.db.exec("VACUUM"); this.lastVacuumMs = nowMs; } @@ -938,7 +938,7 @@ export class ImmersionTrackerService { total_words_seen, total_tokens_seen, total_cards ) SELECT - CAST(s.started_at_ms / 2592000000 AS INTEGER) AS rollup_month, + 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, @@ -953,6 +953,11 @@ export class ImmersionTrackerService { `); } + 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); @@ -1005,9 +1010,7 @@ export class ImmersionTrackerService { private startSessionStatement(videoId: number, startedAtMs: number): { lastInsertRowid: number | bigint; } { - const sessionUuid = `session-${videoId}-${startedAtMs}-${Math.random() - .toString(16) - .slice(2, 10)}`; + const sessionUuid = crypto.randomUUID(); return this.db .prepare(` INSERT INTO imm_sessions ( @@ -1367,7 +1370,7 @@ export class ImmersionTrackerService { return value.trim().replace(/\s+/g, " "); } - private buildVideoKey(mediaPath: string, sourceType: number, title: string): string { + private buildVideoKey(mediaPath: string, sourceType: number): string { if (sourceType === SOURCE_TYPE_REMOTE) { return `remote:${mediaPath}`; } diff --git a/src/core/services/startup-service.ts b/src/core/services/startup-service.ts index 8de1588..3e11b86 100644 --- a/src/core/services/startup-service.ts +++ b/src/core/services/startup-service.ts @@ -176,7 +176,11 @@ export async function runAppReadyRuntimeService( deps.createSubtitleTimingTracker(); if (deps.createImmersionTracker) { deps.log("Runtime ready: invoking createImmersionTracker."); - deps.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."); } diff --git a/src/main.ts b/src/main.ts index 724bee2..4e1d5b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1323,19 +1323,25 @@ const startupState = runStartupBootstrapRuntimeService( logger.info("Immersion tracking disabled in config"); return; } - 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, - }); - if (appState.mpvClient && !appState.mpvClient.connected) { - logger.info("Auto-connecting MPV client for immersion tracking"); - appState.mpvClient.connect(); + 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; } - seedImmersionTrackerFromCurrentMedia(); }, loadYomitanExtension: async () => { await loadYomitanExtension();