mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Fix child-process arg warning
This commit is contained in:
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 SubMiner’s app-data `immersion.sqlite`.
|
||||||
|
- Set an explicit path to move the database (for backups, cloud syncing, or easier inspection).
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,22 @@ 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 defer message when overlay not auto-started", async () => {
|
test("runAppReadyRuntimeService logs defer message when overlay not auto-started", async () => {
|
||||||
|
|||||||
1413
src/core/services/immersion-tracker-service.ts
Normal file
1413
src/core/services/immersion-tracker-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,12 @@ export async function runAppReadyRuntimeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
|
if (deps.createImmersionTracker) {
|
||||||
|
deps.log("Runtime ready: invoking createImmersionTracker.");
|
||||||
|
deps.createImmersionTracker();
|
||||||
|
} else {
|
||||||
|
deps.log("Runtime ready: createImmersionTracker dependency is missing.");
|
||||||
|
}
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
|
|
||||||
if (deps.texthookerOnlyMode) {
|
if (deps.texthookerOnlyMode) {
|
||||||
|
|||||||
207
src/main.ts
207
src/main.ts
@@ -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,26 @@ 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;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
"Immersion tracker startup requested: creating tracker service.",
|
||||||
|
);
|
||||||
|
const dbPath = getConfiguredImmersionDbPath();
|
||||||
|
logger.info(`Creating immersion tracker with dbPath=${dbPath}`);
|
||||||
|
appState.immersionTracker = new ImmersionTrackerService({
|
||||||
|
dbPath,
|
||||||
|
});
|
||||||
|
if (appState.mpvClient && !appState.mpvClient.connected) {
|
||||||
|
logger.info("Auto-connecting MPV client for immersion tracking");
|
||||||
|
appState.mpvClient.connect();
|
||||||
|
}
|
||||||
|
seedImmersionTrackerFromCurrentMedia();
|
||||||
|
},
|
||||||
loadYomitanExtension: async () => {
|
loadYomitanExtension: async () => {
|
||||||
await loadYomitanExtension();
|
await loadYomitanExtension();
|
||||||
},
|
},
|
||||||
@@ -1208,6 +1369,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 +1457,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 +1488,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 +1508,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 +1545,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
bindMpvClientEventHandlers(mpvClient);
|
bindMpvClientEventHandlers(mpvClient);
|
||||||
|
mpvClient.connect();
|
||||||
return mpvClient;
|
return mpvClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,7 +1584,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 +1914,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 +1954,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);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -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";
|
||||||
};
|
};
|
||||||
|
|||||||
26
subminer
26
subminer
@@ -2440,11 +2440,33 @@ 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 && !parsed.target && !parsed.directory) {
|
||||||
const target = positional[0];
|
const target = positional[0];
|
||||||
if (isUrlTarget(target)) {
|
if (isUrlTarget(target)) {
|
||||||
parsed.target = target;
|
parsed.target = target;
|
||||||
|
|||||||
Reference in New Issue
Block a user