mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
test: add immersion tracking startup safety and sqlite tests
This commit is contained in:
@@ -46,6 +46,14 @@
|
|||||||
"level": "info"
|
"level": "info"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Immersion Tracking
|
||||||
|
// Persist mined subtitle/session telemetry for analytics.
|
||||||
|
// ==========================================
|
||||||
|
"immersionTracking": {
|
||||||
|
"enabled": true,
|
||||||
|
"dbPath": ""
|
||||||
|
},
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
"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",
|
"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: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:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
"test": "pnpm run test:config && pnpm run test:core",
|
"test": "pnpm run test:config && pnpm run test:core",
|
||||||
"test:config": "pnpm run build && pnpm run test:config:dist",
|
"test:config": "pnpm run build && pnpm run test:config:dist",
|
||||||
|
|||||||
@@ -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 () => {
|
test("runAppReadyRuntimeService logs defer message when overlay not auto-started", async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||||
|
|||||||
368
src/core/services/immersion-tracker-service.test.ts
Normal file
368
src/core/services/immersion-tracker-service.test.ts
Normal file
@@ -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<DatabaseSync["prepare"]>) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,8 +10,9 @@ const DEFAULT_QUEUE_CAP = 1_000;
|
|||||||
const DEFAULT_BATCH_SIZE = 25;
|
const DEFAULT_BATCH_SIZE = 25;
|
||||||
const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||||
const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const EVENTS_RETENTION_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 TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const DAILY_ROLLUP_RETENTION_MS = 365 * 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 MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||||
@@ -159,7 +160,6 @@ export class ImmersionTrackerService {
|
|||||||
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private flushScheduled = false;
|
private flushScheduled = false;
|
||||||
private droppedWriteCount = 0;
|
private droppedWriteCount = 0;
|
||||||
private pendingFlush = false;
|
|
||||||
private lastMaintenanceMs = 0;
|
private lastMaintenanceMs = 0;
|
||||||
private lastVacuumMs = 0;
|
private lastVacuumMs = 0;
|
||||||
private isDestroyed = false;
|
private isDestroyed = false;
|
||||||
@@ -167,6 +167,8 @@ export class ImmersionTrackerService {
|
|||||||
private currentVideoKey = "";
|
private currentVideoKey = "";
|
||||||
private currentMediaPathOrUrl = "";
|
private currentMediaPathOrUrl = "";
|
||||||
private lastQueueWriteAtMs = 0;
|
private lastQueueWriteAtMs = 0;
|
||||||
|
private readonly telemetryInsertStmt: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly eventInsertStmt: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
|
||||||
constructor(options: ImmersionTrackerOptions) {
|
constructor(options: ImmersionTrackerOptions) {
|
||||||
this.dbPath = options.dbPath;
|
this.dbPath = options.dbPath;
|
||||||
@@ -184,6 +186,24 @@ export class ImmersionTrackerService {
|
|||||||
this.db = new DatabaseSync(this.dbPath);
|
this.db = new DatabaseSync(this.dbPath);
|
||||||
this.applyPragmas();
|
this.applyPragmas();
|
||||||
this.ensureSchema();
|
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.scheduleMaintenance();
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
@@ -199,10 +219,7 @@ export class ImmersionTrackerService {
|
|||||||
clearInterval(this.maintenanceTimer);
|
clearInterval(this.maintenanceTimer);
|
||||||
this.maintenanceTimer = null;
|
this.maintenanceTimer = null;
|
||||||
}
|
}
|
||||||
this.flushTelemetry(true);
|
|
||||||
this.flushNow();
|
|
||||||
this.finalizeActiveSession();
|
this.finalizeActiveSession();
|
||||||
this.flushNow();
|
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +353,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL;
|
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 canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
|
||||||
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null;
|
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null;
|
||||||
const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? 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));
|
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length));
|
||||||
this.writeLock.locked = true;
|
this.writeLock.locked = true;
|
||||||
this.pendingFlush = true;
|
|
||||||
try {
|
try {
|
||||||
this.db.exec("BEGIN IMMEDIATE");
|
this.db.exec("BEGIN IMMEDIATE");
|
||||||
for (const write of batch) {
|
for (const write of batch) {
|
||||||
@@ -635,7 +651,6 @@ export class ImmersionTrackerService {
|
|||||||
this.logger.warn("Immersion tracker flush failed, retrying later", error as Error);
|
this.logger.warn("Immersion tracker flush failed, retrying later", error as Error);
|
||||||
} finally {
|
} finally {
|
||||||
this.writeLock.locked = false;
|
this.writeLock.locked = false;
|
||||||
this.pendingFlush = false;
|
|
||||||
this.flushScheduled = false;
|
this.flushScheduled = false;
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
this.scheduleFlush(this.flushIntervalMs);
|
this.scheduleFlush(this.flushIntervalMs);
|
||||||
@@ -645,17 +660,7 @@ export class ImmersionTrackerService {
|
|||||||
|
|
||||||
private flushSingle(write: QueuedWrite): void {
|
private flushSingle(write: QueuedWrite): void {
|
||||||
if (write.kind === "telemetry") {
|
if (write.kind === "telemetry") {
|
||||||
const stmt = this.db.prepare(`
|
this.telemetryInsertStmt.run(
|
||||||
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(
|
|
||||||
write.sessionId,
|
write.sessionId,
|
||||||
write.sampleMs!,
|
write.sampleMs!,
|
||||||
write.totalWatchedMs!,
|
write.totalWatchedMs!,
|
||||||
@@ -675,15 +680,7 @@ export class ImmersionTrackerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventStmt = this.db.prepare(`
|
this.eventInsertStmt.run(
|
||||||
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(
|
|
||||||
write.sessionId,
|
write.sessionId,
|
||||||
write.sampleMs!,
|
write.sampleMs!,
|
||||||
write.eventType!,
|
write.eventType!,
|
||||||
@@ -871,7 +868,7 @@ export class ImmersionTrackerService {
|
|||||||
const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS;
|
const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS;
|
||||||
const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS;
|
const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS;
|
||||||
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
|
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_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_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
|
||||||
@@ -882,7 +879,10 @@ export class ImmersionTrackerService {
|
|||||||
.run(telemetryCutoff);
|
.run(telemetryCutoff);
|
||||||
this.runRollupMaintenance();
|
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.db.exec("VACUUM");
|
||||||
this.lastVacuumMs = nowMs;
|
this.lastVacuumMs = nowMs;
|
||||||
}
|
}
|
||||||
@@ -938,7 +938,7 @@ export class ImmersionTrackerService {
|
|||||||
total_words_seen, total_tokens_seen, total_cards
|
total_words_seen, total_tokens_seen, total_cards
|
||||||
)
|
)
|
||||||
SELECT
|
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,
|
s.video_id AS video_id,
|
||||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||||
COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min,
|
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 {
|
private startSession(videoId: number, startedAtMs?: number): void {
|
||||||
const nowMs = startedAtMs ?? Date.now();
|
const nowMs = startedAtMs ?? Date.now();
|
||||||
const result = this.startSessionStatement(videoId, nowMs);
|
const result = this.startSessionStatement(videoId, nowMs);
|
||||||
@@ -1005,9 +1010,7 @@ export class ImmersionTrackerService {
|
|||||||
private startSessionStatement(videoId: number, startedAtMs: number): {
|
private startSessionStatement(videoId: number, startedAtMs: number): {
|
||||||
lastInsertRowid: number | bigint;
|
lastInsertRowid: number | bigint;
|
||||||
} {
|
} {
|
||||||
const sessionUuid = `session-${videoId}-${startedAtMs}-${Math.random()
|
const sessionUuid = crypto.randomUUID();
|
||||||
.toString(16)
|
|
||||||
.slice(2, 10)}`;
|
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(`
|
.prepare(`
|
||||||
INSERT INTO imm_sessions (
|
INSERT INTO imm_sessions (
|
||||||
@@ -1367,7 +1370,7 @@ export class ImmersionTrackerService {
|
|||||||
return value.trim().replace(/\s+/g, " ");
|
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) {
|
if (sourceType === SOURCE_TYPE_REMOTE) {
|
||||||
return `remote:${mediaPath}`;
|
return `remote:${mediaPath}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,11 @@ export async function runAppReadyRuntimeService(
|
|||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
if (deps.createImmersionTracker) {
|
if (deps.createImmersionTracker) {
|
||||||
deps.log("Runtime ready: invoking 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 {
|
} else {
|
||||||
deps.log("Runtime ready: createImmersionTracker dependency is missing.");
|
deps.log("Runtime ready: createImmersionTracker dependency is missing.");
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/main.ts
30
src/main.ts
@@ -1323,19 +1323,25 @@ const startupState = runStartupBootstrapRuntimeService(
|
|||||||
logger.info("Immersion tracking disabled in config");
|
logger.info("Immersion tracking disabled in config");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.debug(
|
try {
|
||||||
"Immersion tracker startup requested: creating tracker service.",
|
logger.debug(
|
||||||
);
|
"Immersion tracker startup requested: creating tracker service.",
|
||||||
const dbPath = getConfiguredImmersionDbPath();
|
);
|
||||||
logger.info(`Creating immersion tracker with dbPath=${dbPath}`);
|
const dbPath = getConfiguredImmersionDbPath();
|
||||||
appState.immersionTracker = new ImmersionTrackerService({
|
logger.info(`Creating immersion tracker with dbPath=${dbPath}`);
|
||||||
dbPath,
|
appState.immersionTracker = new ImmersionTrackerService({
|
||||||
});
|
dbPath,
|
||||||
if (appState.mpvClient && !appState.mpvClient.connected) {
|
});
|
||||||
logger.info("Auto-connecting MPV client for immersion tracking");
|
logger.debug("Immersion tracker initialized successfully.");
|
||||||
appState.mpvClient.connect();
|
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 () => {
|
loadYomitanExtension: async () => {
|
||||||
await loadYomitanExtension();
|
await loadYomitanExtension();
|
||||||
|
|||||||
Reference in New Issue
Block a user