Merge pull request #8 from ksyasuda/feature/add-sqlite-session-tracking

Add SQLite session tracking with docs updates
This commit is contained in:
2026-02-17 03:27:57 -08:00
committed by GitHub
28 changed files with 2370 additions and 34 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies

View File

@@ -260,5 +260,15 @@
"anilist": { "anilist": {
"enabled": false, "enabled": false,
"accessToken": "" "accessToken": ""
},
// ==========================================
// Immersion Tracking
// Enable/disable immersion tracking.
// Set dbPath to override the default app data path.
// ==========================================
"immersionTracking": {
"enabled": true,
"dbPath": ""
} }
} }

View File

@@ -61,6 +61,7 @@ The configuration file includes several main sections:
- [**Subtitle Style**](#subtitle-style) - Appearance customization - [**Subtitle Style**](#subtitle-style) - Appearance customization
- [**Texthooker**](#texthooker) - Control browser opening behavior - [**Texthooker**](#texthooker) - Control browser opening behavior
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server - [**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 - [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
### AnkiConnect ### 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 | | `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
| `port` | number | WebSocket server port (default: 6677) | | `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 `<config dir>/immersion.sqlite`. |
When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
```text
<config directory>/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 ### YouTube Subtitle Generation
Set defaults used by the `subminer` launcher for YouTube subtitle extraction/transcription: Set defaults used by the `subminer` launcher for YouTube subtitle extraction/transcription:

View File

@@ -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 - **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) - **Mining focus**: Target sentences with exactly one unknown word (true N+1)
- **Progress visualization**: See your growing vocabulary visually represented in real content - **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 SubMiners app-data `immersion.sqlite`.
- Set an explicit path to move the database (for backups, cloud syncing, or easier inspection).

View File

@@ -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.

View File

@@ -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",

View File

@@ -7,21 +7,48 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift" SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift"
OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts" OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts"
OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos" 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 # Only build on macOS
if [[ "$(uname)" != "Darwin" ]]; then if [[ "$(uname)" != "Darwin" ]]; then
echo "Skipping macOS helper build (not on macOS)" echo "Skipping macOS helper build (not on macOS)"
fallback_to_source
exit 0 exit 0
fi fi
# Create output directory # Create output directory
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
# Compile Swift script to binary # Compile Swift script to binary, fallback to source if unavailable or compilation fails
echo "Compiling macOS window tracking helper..." if ! build_swift_helper; then
swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY" fallback_to_source
fi
# Make executable
chmod +x "$OUTPUT_BINARY"
echo "✓ Built $OUTPUT_BINARY"

View File

@@ -1054,7 +1054,7 @@ export class AnkiIntegration {
startTime: number, startTime: number,
endTime: number, endTime: number,
secondarySubText?: string, secondarySubText?: string,
): Promise<void> { ): Promise<boolean> {
return this.cardCreationService.createSentenceCard( return this.cardCreationService.createSentenceCard(
sentence, sentence,
startTime, startTime,

View File

@@ -460,23 +460,23 @@ export class CardCreationService {
startTime: number, startTime: number,
endTime: number, endTime: number,
secondarySubText?: string, secondarySubText?: string,
): Promise<void> { ): Promise<boolean> {
if (this.deps.isUpdateInProgress()) { if (this.deps.isUpdateInProgress()) {
this.deps.showOsdNotification("Anki update already in progress"); this.deps.showOsdNotification("Anki update already in progress");
return; return false;
} }
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceCardModel = sentenceCardConfig.model; const sentenceCardModel = sentenceCardConfig.model;
if (!sentenceCardModel) { if (!sentenceCardModel) {
this.deps.showOsdNotification("sentenceCardModel not configured"); this.deps.showOsdNotification("sentenceCardModel not configured");
return; return false;
} }
const mpvClient = this.deps.getMpvClient(); const mpvClient = this.deps.getMpvClient();
if (!mpvClient || !mpvClient.currentVideoPath) { if (!mpvClient || !mpvClient.currentVideoPath) {
this.deps.showOsdNotification("No video loaded"); this.deps.showOsdNotification("No video loaded");
return; return false;
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
@@ -488,7 +488,8 @@ export class CardCreationService {
} }
this.deps.showOsdNotification("Creating sentence card..."); 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 videoPath = mpvClient.currentVideoPath;
const fields: Record<string, string> = {}; const fields: Record<string, string> = {};
const errors: string[] = []; const errors: string[] = [];
@@ -533,7 +534,7 @@ export class CardCreationService {
this.deps.showOsdNotification( this.deps.showOsdNotification(
`Sentence card failed: ${(error as Error).message}`, `Sentence card failed: ${(error as Error).message}`,
); );
return; return false;
} }
try { try {
@@ -637,7 +638,18 @@ export class CardCreationService {
const errorSuffix = const errorSuffix =
errors.length > 0 ? `${errors.join(", ")} failed` : undefined; errors.length > 0 ? `${errors.join(", ")} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix); 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 { private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {

View File

@@ -18,6 +18,8 @@ test("loads defaults when config is missing", () => {
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.equal(config.anilist.enabled, false); 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", () => { 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); 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", () => { test("parses jsonc and warns/falls back on invalid value", () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -239,6 +239,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
invisibleOverlay: { invisibleOverlay: {
startupVisibility: "platform-default", startupVisibility: "platform-default",
}, },
immersionTracking: {
enabled: true,
},
}; };
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect; export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
@@ -509,6 +512,19 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
description: description:
"Comma-separated primary subtitle language priority used by the launcher.", "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[] = [ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
@@ -621,6 +637,14 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ["Anilist API credentials and update behavior."], description: ["Anilist API credentials and update behavior."],
key: "anilist", 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 { export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {

View File

@@ -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)) { if (isObject(src.subtitleStyle)) {
resolved.subtitleStyle = { resolved.subtitleStyle = {
...resolved.subtitleStyle, ...resolved.subtitleStyle,

View File

@@ -24,6 +24,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
calls.push("createMecabTokenizerAndCheck"); calls.push("createMecabTokenizerAndCheck");
}, },
createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"),
createImmersionTracker: () => calls.push("createImmersionTracker"),
loadYomitanExtension: async () => { loadYomitanExtension: async () => {
calls.push("loadYomitanExtension"); calls.push("loadYomitanExtension");
}, },
@@ -43,6 +44,40 @@ test("runAppReadyRuntimeService starts websocket in auto mode when plugin missin
await runAppReadyRuntimeService(deps); await runAppReadyRuntimeService(deps);
assert.ok(calls.includes("startSubtitleWebsocket:9001")); assert.ok(calls.includes("startSubtitleWebsocket:9001"));
assert.ok(calls.includes("initializeOverlayRuntime")); 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 () => { test("runAppReadyRuntimeService logs defer message when overlay not auto-started", async () => {

View File

@@ -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<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);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,7 @@ export {
} from "./mpv-render-metrics-service"; } from "./mpv-render-metrics-service";
export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service"; export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service";
export { handleMpvCommandFromIpcService } from "./ipc-command-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service";
export { ImmersionTrackerService } from "./immersion-tracker-service";
export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service";
export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service";
export { runStartupBootstrapRuntimeService } from "./startup-service"; export { runStartupBootstrapRuntimeService } from "./startup-service";

View File

@@ -52,19 +52,23 @@ test("copyCurrentSubtitleService copies current subtitle text", () => {
test("mineSentenceCardService handles missing integration and disconnected mpv", async () => { test("mineSentenceCardService handles missing integration and disconnected mpv", async () => {
const osd: string[] = []; const osd: string[] = [];
await mineSentenceCardService({ assert.equal(
await mineSentenceCardService({
ankiIntegration: null, ankiIntegration: null,
mpvClient: null, mpvClient: null,
showMpvOsd: (text) => osd.push(text), showMpvOsd: (text) => osd.push(text),
}); }),
false,
);
assert.equal(osd.at(-1), "AnkiConnect integration not enabled"); assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
await mineSentenceCardService({ assert.equal(
await mineSentenceCardService({
ankiIntegration: { ankiIntegration: {
updateLastAddedFromClipboard: async () => {}, updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {}, createSentenceCard: async () => false,
}, },
mpvClient: { mpvClient: {
connected: false, connected: false,
@@ -73,7 +77,9 @@ test("mineSentenceCardService handles missing integration and disconnected mpv",
currentSubEnd: 2, currentSubEnd: 2,
}, },
showMpvOsd: (text) => osd.push(text), showMpvOsd: (text) => osd.push(text),
}); }),
false,
);
assert.equal(osd.at(-1), "MPV not connected"); assert.equal(osd.at(-1), "MPV not connected");
}); });
@@ -86,13 +92,14 @@ test("mineSentenceCardService creates sentence card from mpv subtitle state", as
secondarySub?: string; secondarySub?: string;
}> = []; }> = [];
await mineSentenceCardService({ const createdCard = await mineSentenceCardService({
ankiIntegration: { ankiIntegration: {
updateLastAddedFromClipboard: async () => {}, updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub }); created.push({ sentence, startTime, endTime, secondarySub });
return true;
}, },
}, },
mpvClient: { mpvClient: {
@@ -105,6 +112,7 @@ test("mineSentenceCardService creates sentence card from mpv subtitle state", as
showMpvOsd: () => {}, showMpvOsd: () => {},
}); });
assert.equal(createdCard, true);
assert.deepEqual(created, [ assert.deepEqual(created, [
{ {
sentence: "subtitle line", sentence: "subtitle line",
@@ -136,6 +144,7 @@ test("handleMultiCopyDigitService copies available history and reports truncatio
test("handleMineSentenceDigitService reports async create failures", async () => { test("handleMineSentenceDigitService reports async create failures", async () => {
const osd: string[] = []; const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = []; const logs: Array<{ message: string; err: unknown }> = [];
let cardsMined = 0;
handleMineSentenceDigitService(2, { handleMineSentenceDigitService(2, {
subtitleTimingTracker: { subtitleTimingTracker: {
@@ -157,6 +166,9 @@ test("handleMineSentenceDigitService reports async create failures", async () =>
getCurrentSecondarySubText: () => "sub2", getCurrentSecondarySubText: () => "sub2",
showMpvOsd: (text) => osd.push(text), showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }), logError: (message, err) => logs.push({ message, err }),
onCardsMined: (count) => {
cardsMined += count;
},
}); });
await new Promise((resolve) => setImmediate(resolve)); 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]?.message, "mineSentenceMultiple failed:");
assert.equal((logs[0]?.err as Error).message, "mine boom"); assert.equal((logs[0]?.err as Error).message, "mine boom");
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: 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);
}); });

View File

@@ -13,7 +13,7 @@ interface AnkiIntegrationLike {
startTime: number, startTime: number,
endTime: number, endTime: number,
secondarySub?: string, secondarySub?: string,
) => Promise<void>; ) => Promise<boolean>;
} }
interface MpvClientLike { interface MpvClientLike {
@@ -111,21 +111,21 @@ export async function mineSentenceCardService(deps: {
ankiIntegration: AnkiIntegrationLike | null; ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null; mpvClient: MpvClientLike | null;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
}): Promise<void> { }): Promise<boolean> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
if (!anki) return; if (!anki) return false;
const mpvClient = deps.mpvClient; const mpvClient = deps.mpvClient;
if (!mpvClient || !mpvClient.connected) { if (!mpvClient || !mpvClient.connected) {
deps.showMpvOsd("MPV not connected"); deps.showMpvOsd("MPV not connected");
return; return false;
} }
if (!mpvClient.currentSubText) { if (!mpvClient.currentSubText) {
deps.showMpvOsd("No current subtitle"); deps.showMpvOsd("No current subtitle");
return; return false;
} }
await anki.createSentenceCard( return await anki.createSentenceCard(
mpvClient.currentSubText, mpvClient.currentSubText,
mpvClient.currentSubStart, mpvClient.currentSubStart,
mpvClient.currentSubEnd, mpvClient.currentSubEnd,
@@ -141,6 +141,7 @@ export function handleMineSentenceDigitService(
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined?: (count: number) => void;
}, },
): void { ): void {
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
@@ -165,6 +166,7 @@ export function handleMineSentenceDigitService(
const rangeStart = Math.min(...timings.map((t) => t.startTime)); const rangeStart = Math.min(...timings.map((t) => t.startTime));
const rangeEnd = Math.max(...timings.map((t) => t.endTime)); const rangeEnd = Math.max(...timings.map((t) => t.endTime));
const sentence = blocks.join(" "); const sentence = blocks.join(" ");
const cardsToMine = 1;
deps.ankiIntegration deps.ankiIntegration
.createSentenceCard( .createSentenceCard(
sentence, sentence,
@@ -172,6 +174,11 @@ export function handleMineSentenceDigitService(
rangeEnd, rangeEnd,
deps.getCurrentSecondarySubText(), deps.getCurrentSecondarySubText(),
) )
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
}
})
.catch((err) => { .catch((err) => {
deps.logError("mineSentenceMultiple failed:", err); deps.logError("mineSentenceMultiple failed:", err);
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);

View File

@@ -21,6 +21,7 @@ import {
MPV_REQUEST_ID_SUBTEXT, MPV_REQUEST_ID_SUBTEXT,
MPV_REQUEST_ID_SUBTEXT_ASS, MPV_REQUEST_ID_SUBTEXT_ASS,
MPV_REQUEST_ID_SUB_USE_MARGINS, MPV_REQUEST_ID_SUB_USE_MARGINS,
MPV_REQUEST_ID_PAUSE,
} from "./mpv-protocol"; } from "./mpv-protocol";
type MpvProtocolCommand = { type MpvProtocolCommand = {
@@ -57,6 +58,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
"sub-shadow-offset", "sub-shadow-offset",
"sub-ass-override", "sub-ass-override",
"sub-use-margins", "sub-use-margins",
"pause",
"media-title", "media-title",
]; ];
@@ -76,6 +78,10 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
{ {
command: ["get_property", "media-title"], command: ["get_property", "media-title"],
}, },
{
command: ["get_property", "pause"],
request_id: MPV_REQUEST_ID_PAUSE,
},
{ {
command: ["get_property", "secondary-sub-text"], command: ["get_property", "secondary-sub-text"],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,

View File

@@ -84,6 +84,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
setPendingPauseAtSubEnd: () => {}, setPendingPauseAtSubEnd: () => {},
getPauseAtTime: () => null, getPauseAtTime: () => null,
setPauseAtTime: () => {}, setPauseAtTime: () => {},
emitTimePosChange: () => {},
emitPauseChange: () => {},
autoLoadSecondarySubTrack: () => {}, autoLoadSecondarySubTrack: () => {},
setCurrentVideoPath: () => {}, setCurrentVideoPath: () => {},
emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), emitSecondarySubtitleVisibility: (payload) => state.events.push(payload),

View File

@@ -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_SHADOW_OFFSET = 120;
export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121;
export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; 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_SECONDARY = 200;
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
@@ -60,6 +61,8 @@ export interface MpvProtocolHandleMessageDeps {
getCurrentSubEnd: () => number; getCurrentSubEnd: () => number;
emitMediaPathChange: (payload: { path: string }) => void; emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
emitPauseChange: (payload: { paused: boolean }) => void;
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void; emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
setCurrentSecondarySubText: (text: string) => void; setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
@@ -160,6 +163,7 @@ export async function dispatchMpvProtocolMessage(
); );
deps.syncCurrentAudioStreamIndex(); deps.syncCurrentAudioStreamIndex();
} else if (msg.name === "time-pos") { } else if (msg.name === "time-pos") {
deps.emitTimePosChange({ time: (msg.data as number) || 0 });
deps.setCurrentTimePos((msg.data as number) || 0); deps.setCurrentTimePos((msg.data as number) || 0);
if ( if (
deps.getPauseAtTime() !== null && deps.getPauseAtTime() !== null &&
@@ -168,6 +172,8 @@ export async function dispatchMpvProtocolMessage(
deps.setPauseAtTime(null); deps.setPauseAtTime(null);
deps.sendCommand({ command: ["set_property", "pause", true] }); 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") { } else if (msg.name === "media-title") {
deps.emitMediaTitleChange({ deps.emitMediaTitleChange({
title: typeof msg.data === "string" ? msg.data.trim() : null, title: typeof msg.data === "string" ? msg.data.trim() : null,
@@ -348,6 +354,8 @@ export async function dispatchMpvProtocolMessage(
deps.getSubtitleMetrics().subUseMargins, 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) { } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) {
deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number });
} else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) {

View File

@@ -117,6 +117,8 @@ export interface MpvIpcClientEventMap {
"subtitle-change": { text: string; isOverlayVisible: boolean }; "subtitle-change": { text: string; isOverlayVisible: boolean };
"subtitle-ass-change": { text: string }; "subtitle-ass-change": { text: string };
"subtitle-timing": { text: string; start: number; end: number }; "subtitle-timing": { text: string; start: number; end: number };
"time-pos-change": { time: number };
"pause-change": { paused: boolean };
"secondary-subtitle-change": { text: string }; "secondary-subtitle-change": { text: string };
"media-path-change": { path: string }; "media-path-change": { path: string };
"media-title-change": { title: string | null }; "media-title-change": { title: string | null };
@@ -258,9 +260,13 @@ export class MpvIpcClient implements MpvClient {
connect(): void { connect(): void {
if (this.connected || this.connecting) { if (this.connected || this.connecting) {
logger.debug(
`MPV IPC connect request skipped; connected=${this.connected}, connecting=${this.connecting}`,
);
return; return;
} }
logger.info("MPV IPC connect requested.");
this.connecting = true; this.connecting = true;
this.transport.connect(); this.transport.connect();
} }
@@ -313,6 +319,12 @@ export class MpvIpcClient implements MpvClient {
emitSubtitleTiming: (payload) => { emitSubtitleTiming: (payload) => {
this.emit("subtitle-timing", payload); this.emit("subtitle-timing", payload);
}, },
emitTimePosChange: (payload) => {
this.emit("time-pos-change", payload);
},
emitPauseChange: (payload) => {
this.emit("pause-change", payload);
},
emitSecondarySubtitleChange: (payload) => { emitSecondarySubtitleChange: (payload) => {
this.emit("secondary-subtitle-change", payload); this.emit("secondary-subtitle-change", payload);
}, },

View File

@@ -99,6 +99,7 @@ export interface AppReadyRuntimeDeps {
log: (message: string) => void; log: (message: string) => void;
createMecabTokenizerAndCheck: () => Promise<void>; createMecabTokenizerAndCheck: () => Promise<void>;
createSubtitleTimingTracker: () => void; createSubtitleTimingTracker: () => void;
createImmersionTracker?: () => void;
loadYomitanExtension: () => Promise<void>; loadYomitanExtension: () => Promise<void>;
texthookerOnlyMode: boolean; texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
@@ -173,6 +174,16 @@ export async function runAppReadyRuntimeService(
} }
deps.createSubtitleTimingTracker(); 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(); await deps.loadYomitanExtension();
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {

View File

@@ -113,6 +113,7 @@ import {
markLastCardAsAudioCardService, markLastCardAsAudioCardService,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS, DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
mineSentenceCardService, mineSentenceCardService,
ImmersionTrackerService,
openYomitanSettingsWindow, openYomitanSettingsWindow,
playNextSubtitleRuntimeService, playNextSubtitleRuntimeService,
registerGlobalShortcutsService, registerGlobalShortcutsService,
@@ -262,6 +263,7 @@ function resolveConfigDir(): string {
const CONFIG_DIR = resolveConfigDir(); const CONFIG_DIR = resolveConfigDir();
const USER_DATA_PATH = CONFIG_DIR; const USER_DATA_PATH = CONFIG_DIR;
const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; 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 configService = new ConfigService(CONFIG_DIR);
const anilistTokenStore = createAnilistTokenStore( const anilistTokenStore = createAnilistTokenStore(
path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE), path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE),
@@ -580,6 +582,68 @@ function openRuntimeOptionsPalette(): void {
} }
function getResolvedConfig() { return configService.getConfig(); } 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<string | null> {
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<ImmersionMediaState> {
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 { function getInitialInvisibleOverlayVisibility(): boolean {
return getInitialInvisibleOverlayVisibilityService( return getInitialInvisibleOverlayVisibilityService(
@@ -609,6 +673,83 @@ function getJimakuMaxEntryResults(): number { return getJimakuMaxEntryResultsSer
async function resolveJimakuApiKey(): Promise<string | null> { return resolveJimakuApiKeyService(() => getResolvedConfig()); } async function resolveJimakuApiKey(): Promise<string | null> { 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<T>( async function jimakuFetchJson<T>(
endpoint: string, endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {}, query: Record<string, string | number | boolean | null | undefined> = {},
@@ -1176,6 +1317,32 @@ const startupState = runStartupBootstrapRuntimeService(
const tracker = new SubtitleTimingTracker(); const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker; 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 () => { loadYomitanExtension: async () => {
await loadYomitanExtension(); await loadYomitanExtension();
}, },
@@ -1208,6 +1375,10 @@ const startupState = runStartupBootstrapRuntimeService(
if (appState.subtitleTimingTracker) { if (appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.destroy(); appState.subtitleTimingTracker.destroy();
} }
if (appState.immersionTracker) {
appState.immersionTracker.destroy();
appState.immersionTracker = null;
}
if (appState.ankiIntegration) { if (appState.ankiIntegration) {
appState.ankiIntegration.destroy(); appState.ankiIntegration.destroy();
} }
@@ -1292,6 +1463,15 @@ function handleCliCommand(
function handleInitialArgs(): void { function handleInitialArgs(): void {
if (!appState.initialArgs) return; 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"); handleCliCommand(appState.initialArgs, "initial");
} }
@@ -1314,9 +1494,14 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
broadcastToOverlayWindows("secondary-subtitle:set", text); broadcastToOverlayWindows("secondary-subtitle:set", text);
}); });
mpvClient.on("subtitle-timing", ({ text, start, end }) => { mpvClient.on("subtitle-timing", ({ text, start, end }) => {
if (text.trim() && appState.subtitleTimingTracker) { if (!text.trim()) {
appState.subtitleTimingTracker.recordSubtitle(text, start, end); return;
} }
appState.immersionTracker?.recordSubtitleLine(text, start, end);
if (!appState.subtitleTimingTracker) {
return;
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
void maybeRunAnilistPostWatchUpdate().catch((error) => { void maybeRunAnilistPostWatchUpdate().catch((error) => {
logger.error("AniList post-watch update failed unexpectedly", error); logger.error("AniList post-watch update failed unexpectedly", error);
}); });
@@ -1329,11 +1514,20 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
void maybeProbeAnilistDuration(mediaKey); void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey); void ensureAnilistMediaGuess(mediaKey);
} }
syncImmersionTrackerFromCurrentMediaState();
}); });
mpvClient.on("media-title-change", ({ title }) => { mpvClient.on("media-title-change", ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title); mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null; anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = 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 }) => { mpvClient.on("subtitle-metrics-change", ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch); updateMpvSubtitleRenderMetrics(patch);
@@ -1357,6 +1551,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
}, },
}); });
bindMpvClientEventHandlers(mpvClient); bindMpvClientEventHandlers(mpvClient);
mpvClient.connect();
return mpvClient; return mpvClient;
} }
@@ -1395,7 +1590,11 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
appState.yomitanParserInitPromise = promise; appState.yomitanParserInitPromise = promise;
}, },
isKnownWord: (text) => isKnownWord: (text) =>
Boolean(appState.ankiIntegration?.isKnownWord(text)), (() => {
const hit = Boolean(appState.ankiIntegration?.isKnownWord(text));
appState.immersionTracker?.recordLookup(hit);
return hit;
})(),
getKnownWordMatchMode: () => getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ?? appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode, getResolvedConfig().ankiConnect.nPlusOne.matchMode,
@@ -1721,13 +1920,16 @@ async function markLastCardAsAudioCard(): Promise<void> {
} }
async function mineSentenceCard(): Promise<void> { async function mineSentenceCard(): Promise<void> {
await mineSentenceCardService( const created = await mineSentenceCardService(
{ {
ankiIntegration: appState.ankiIntegration, ankiIntegration: appState.ankiIntegration,
mpvClient: appState.mpvClient, mpvClient: appState.mpvClient,
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}, },
); );
if (created) {
appState.immersionTracker?.recordCardsMined(1);
}
} }
function cancelPendingMineSentenceMultiple(): void { function cancelPendingMineSentenceMultiple(): void {
@@ -1758,6 +1960,9 @@ function handleMineSentenceDigit(count: number): void {
logError: (message, err) => { logError: (message, err) => {
logger.error(message, err); logger.error(message, err);
}, },
onCardsMined: (cards) => {
appState.immersionTracker?.recordCardsMined(cards);
},
}, },
); );
} }

View File

@@ -35,6 +35,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
setLogLevel: AppReadyRuntimeDeps["setLogLevel"]; setLogLevel: AppReadyRuntimeDeps["setLogLevel"];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"]; createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"];
createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"]; createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"];
createImmersionTracker?: AppReadyRuntimeDeps["createImmersionTracker"];
loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"]; loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"];
texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"]; texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"]; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"];
@@ -81,6 +82,7 @@ export function createAppReadyRuntimeDeps(
setLogLevel: params.setLogLevel, setLogLevel: params.setLogLevel,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker, createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker,
loadYomitanExtension: params.loadYomitanExtension, loadYomitanExtension: params.loadYomitanExtension,
texthookerOnlyMode: params.texthookerOnlyMode, texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: shouldAutoInitializeOverlayRuntimeFromConfig:

View File

@@ -12,6 +12,7 @@ import type {
import type { CliArgs } from "../cli/args"; import type { CliArgs } from "../cli/args";
import type { SubtitleTimingTracker } from "../subtitle-timing-tracker"; import type { SubtitleTimingTracker } from "../subtitle-timing-tracker";
import type { AnkiIntegration } from "../anki-integration"; import type { AnkiIntegration } from "../anki-integration";
import type { ImmersionTrackerService } from "../core/services";
import type { MpvIpcClient } from "../core/services"; import type { MpvIpcClient } from "../core/services";
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services"; import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from "../core/services";
import type { RuntimeOptionsManager } from "../runtime-options"; import type { RuntimeOptionsManager } from "../runtime-options";
@@ -54,6 +55,7 @@ export interface AppState {
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[]; keybindings: Keybinding[];
subtitleTimingTracker: SubtitleTimingTracker | null; subtitleTimingTracker: SubtitleTimingTracker | null;
immersionTracker: ImmersionTrackerService | null;
ankiIntegration: AnkiIntegration | null; ankiIntegration: AnkiIntegration | null;
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
lastSecondarySubToggleAtMs: number; lastSecondarySubToggleAtMs: number;
@@ -123,6 +125,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
mecabTokenizer: null, mecabTokenizer: null,
keybindings: [], keybindings: [],
subtitleTimingTracker: null, subtitleTimingTracker: null,
immersionTracker: null,
ankiIntegration: null, ankiIntegration: null,
secondarySubMode: "hover", secondarySubMode: "hover",
lastSecondarySubToggleAtMs: 0, lastSecondarySubToggleAtMs: 0,

View File

@@ -351,6 +351,11 @@ export interface YoutubeSubgenConfig {
primarySubLanguages?: string[]; primarySubLanguages?: string[];
} }
export interface ImmersionTrackingConfig {
enabled?: boolean;
dbPath?: string;
}
export interface Config { export interface Config {
subtitlePosition?: SubtitlePosition; subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[]; keybindings?: Keybinding[];
@@ -367,6 +372,7 @@ export interface Config {
anilist?: AnilistConfig; anilist?: AnilistConfig;
invisibleOverlay?: InvisibleOverlayConfig; invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig; youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
logging?: { logging?: {
level?: "debug" | "info" | "warn" | "error"; level?: "debug" | "info" | "warn" | "error";
}; };
@@ -481,6 +487,10 @@ export interface ResolvedConfig {
whisperModel: string; whisperModel: string;
primarySubLanguages: string[]; primarySubLanguages: string[];
}; };
immersionTracking: {
enabled: boolean;
dbPath?: string;
};
logging: { logging: {
level: "debug" | "info" | "warn" | "error"; level: "debug" | "info" | "warn" | "error";
}; };

View File

@@ -2440,11 +2440,37 @@ function parseArgs(
fail(`Unknown option: ${arg}`); 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); const positional = argv.slice(i);
if (positional.length > 0) { if (positional.length > 0) {
if (parsed.target || parsed.directory) {
fail(`Unexpected positional argument: ${positional[0]}`);
}
const target = positional[0]; const target = positional[0];
if (isUrlTarget(target)) { if (isUrlTarget(target)) {
parsed.target = target; parsed.target = target;
@@ -2463,6 +2489,10 @@ function parseArgs(
fail(`Not a file, directory, or supported URL: ${target}`); fail(`Not a file, directory, or supported URL: ${target}`);
} }
} }
if (positional.length > 1) {
fail(`Unexpected positional argument: ${positional[1]}`);
}
} }
return parsed; return parsed;