diff --git a/backlog/tasks/task-43 - Add-community-subtitle-timing-database-for-shared-sync-corrections.md b/backlog/archive/tasks/task-43 - Add-community-subtitle-timing-database-for-shared-sync-corrections.md similarity index 100% rename from backlog/tasks/task-43 - Add-community-subtitle-timing-database-for-shared-sync-corrections.md rename to backlog/archive/tasks/task-43 - Add-community-subtitle-timing-database-for-shared-sync-corrections.md diff --git a/backlog/tasks/task-53 - Consolidate-fragmented-JLPT-utility-modules.md b/backlog/tasks/task-53 - Consolidate-fragmented-JLPT-utility-modules.md new file mode 100644 index 0000000..4de4d0d --- /dev/null +++ b/backlog/tasks/task-53 - Consolidate-fragmented-JLPT-utility-modules.md @@ -0,0 +1,60 @@ +--- +id: TASK-53 +title: Consolidate fragmented JLPT utility modules +status: Done +assignee: [] +created_date: '2026-02-16 04:47' +updated_date: '2026-02-16 04:57' +labels: [] +dependencies: [] +references: + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/jlpt-token-filter-config.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/jlpt-excluded-terms.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/jlpt-ignored-mecab-pos1.ts +priority: medium +--- + +## Description + + +The JLPT-related functionality is currently split across three tiny files that create unnecessary navigation overhead and add cognitive load when working with JLPT token filtering. + +Files to consolidate: +- `src/core/services/jlpt-token-filter-config.ts` (24 lines) +- `src/core/services/jlpt-excluded-terms.ts` (30 lines) +- `src/core/services/jlpt-ignored-mecab-pos1.ts` (46 lines) + +These three files are tightly coupled (one imports from another) and all deal with JLPT token filtering logic. They should be merged into a single `jlpt-token-filter.ts` module with clear section comments. + +Benefits: +- Reduces file count by 2 +- Eliminates circular import potential +- Easier to understand JLPT filtering logic holistically +- Simplifies barrel exports from services/index.ts + + +## Acceptance Criteria + +- [ ] #1 Merge jlpt-token-filter-config.ts, jlpt-excluded-terms.ts, and jlpt-ignored-mecab-pos1.ts into a single jlpt-token-filter.ts file +- [ ] #2 Update all imports across the codebase to use the consolidated module +- [ ] #3 Update services/index.ts barrel exports +- [ ] #4 Ensure all existing tests pass +- [ ] #5 Verify no functionality is lost in consolidation + + +## Final Summary + + +Consolidated JLPT filtering utilities into `src/core/services/jlpt-token-filter.ts` and updated all imports to use the new module. Added consolidated exports to `src/core/services/index.ts` for the JLPT token filter API (`shouldIgnoreJlptForMecabPos1`, `shouldIgnoreJlptByTerm`, POS metadata, etc.). Deleted the three fragmented files: +- `jlpt-token-filter-config.ts` +- `jlpt-excluded-terms.ts` +- `jlpt-ignored-mecab-pos1.ts` + +Validation: +- `pnpm exec tsc --noEmit` passes after the refactor. +- `node --test dist/core/services/tokenizer-service.test.js` runs with 19/20 passing; one failure (`tokenizeSubtitleService uses Yomitan parser result when available`) appears to be pre-existing in current tree and matches earlier full test run failure. +- Full `pnpm run test:fast` still fails, but failures are outside this consolidation scope (e.g., overlay-shortcut-handler logger message shape and tokenizer Yomitan-parse-path test), consistent with baseline failures in this working state. + diff --git a/backlog/tasks/task-54 - Audit-and-consolidate-micro-services-under-50-lines.md b/backlog/tasks/task-54 - Audit-and-consolidate-micro-services-under-50-lines.md new file mode 100644 index 0000000..43a97c5 --- /dev/null +++ b/backlog/tasks/task-54 - Audit-and-consolidate-micro-services-under-50-lines.md @@ -0,0 +1,46 @@ +--- +id: TASK-54 +title: Audit and consolidate micro-services under 50 lines +status: In Progress +assignee: [] +created_date: '2026-02-16 04:47' +updated_date: '2026-02-16 04:59' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/ +priority: medium +--- + +## Description + + +The core/services directory contains 67 files, many of which are very small services that may create unnecessary abstraction overhead. This task involves auditing services under 50 lines and determining if they should be consolidated with related services. + +Candidates for review (all under 50 lines): +- mpv-state.ts (25 lines) - could merge with mpv-service.ts +- secondary-subtitle-service.ts (32 lines) - could merge with subtitle-related services +- runtime-config-service.ts (50 lines) - pure utility functions that could merge with config service +- mpv-control-service.ts (49 lines) - MPV command functions could merge with mpv-service.ts + +Approach: +1. Identify logical groupings (mpv-related, subtitle-related, config-related) +2. Determine which micro-services are truly independent vs which are fragments +3. Consolidate related micro-services into cohesive modules +4. Maintain clear function exports for tree-shaking + +Benefits: +- Reduces file navigation overhead +- Groups related functionality logically +- Makes service boundaries clearer + + +## Acceptance Criteria + +- [ ] #1 Audit all services under 50 lines in src/core/services/ +- [ ] #2 Identify logical groupings for consolidation +- [ ] #3 Merge related micro-services into cohesive modules +- [ ] #4 Update all imports across codebase +- [ ] #5 Update barrel exports in services/index.ts +- [ ] #6 Run full test suite to ensure no regressions + diff --git a/backlog/tasks/task-55 - Normalize-service-naming-conventions-across-core-services.md b/backlog/tasks/task-55 - Normalize-service-naming-conventions-across-core-services.md new file mode 100644 index 0000000..31d05b9 --- /dev/null +++ b/backlog/tasks/task-55 - Normalize-service-naming-conventions-across-core-services.md @@ -0,0 +1,45 @@ +--- +id: TASK-55 +title: Normalize service naming conventions across core/services +status: To Do +assignee: [] +created_date: '2026-02-16 04:47' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/index.ts +priority: low +--- + +## Description + + +The core/services directory has inconsistent naming patterns that create confusion: +- Some files use `*Service.ts` suffix (e.g., `mpv-service.ts`, `tokenizer-service.ts`) +- Others use `*RuntimeService.ts` or just descriptive names (e.g., `overlay-visibility-service.ts` exports functions with 'Service' in name) +- Some functions in files have 'Service' suffix, others don't + +This inconsistency makes it hard to predict file/function names and creates cognitive overhead. + +Standardize on: +- File names: `kebab-case.ts` without 'service' suffix (e.g., `mpv.ts`, `tokenizer.ts`) +- Function names: descriptive verbs without 'Service' suffix (e.g., `createMpvClient()`, `tokenizeSubtitle()`) +- Barrel exports: clean, predictable names + +Files needing audit (47 services): +- All files in src/core/services/ need review + +Note: This is a large-scale refactor that should be done carefully to avoid breaking changes. + + +## Acceptance Criteria + +- [ ] #1 Establish naming convention rules (document in code or docs) +- [ ] #2 Audit all service files for naming inconsistencies +- [ ] #3 Rename files to follow convention (kebab-case, no 'service' suffix) +- [ ] #4 Rename exported functions to remove 'Service' suffix where present +- [ ] #5 Update all imports across the entire codebase +- [ ] #6 Update barrel exports +- [ ] #7 Run full test suite +- [ ] #8 Update any documentation referencing old names + diff --git a/backlog/tasks/task-56 - Extract-remaining-main.ts-runtime-functions-to-dedicated-modules.md b/backlog/tasks/task-56 - Extract-remaining-main.ts-runtime-functions-to-dedicated-modules.md new file mode 100644 index 0000000..b6e0a6b --- /dev/null +++ b/backlog/tasks/task-56 - Extract-remaining-main.ts-runtime-functions-to-dedicated-modules.md @@ -0,0 +1,46 @@ +--- +id: TASK-56 +title: Extract remaining main.ts runtime functions to dedicated modules +status: To Do +assignee: [] +created_date: '2026-02-16 04:47' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/main.ts +priority: medium +--- + +## Description + + +main.ts is still 1481 lines after previous refactoring efforts. While significant progress has been made, there are still opportunities to extract runtime functions into dedicated modules to further reduce its size and improve maintainability. + +Current opportunities: +1. **JLPT dictionary lookup functions** (lines 470-535) - initializeJlptDictionaryLookup, ensureJlptDictionaryLookup, getJlptDictionarySearchPaths +2. **Media path utilities** (lines 552-590) - updateCurrentMediaPath, updateCurrentMediaTitle, resolveMediaPathForJimaku +3. **Overlay visibility helpers** (lines 1273-1360) - updateVisibleOverlayVisibility, updateInvisibleOverlayVisibility, syncInvisibleOverlayMousePassthrough + +These functions are largely self-contained and could be moved to: +- `src/main/jlpt-runtime.ts` +- `src/main/media-runtime.ts` +- `src/main/overlay-visibility-runtime.ts` + +Goal: Reduce main.ts to under 1000 lines (target: ~800-900 lines) + +Benefits: +- Faster navigation and comprehension of main.ts +- Easier to test extracted modules independently +- Clearer separation of concerns + + +## Acceptance Criteria + +- [ ] #1 Extract JLPT dictionary lookup functions to dedicated module +- [ ] #2 Extract media path utilities to dedicated module +- [ ] #3 Extract overlay visibility helpers to dedicated module +- [ ] #4 Update main.ts imports to use new modules +- [ ] #5 Ensure all functionality remains intact +- [ ] #6 Run full test suite +- [ ] #7 Verify main.ts line count is reduced to under 1000 lines + diff --git a/backlog/tasks/task-57 - Evaluate-anki-integration.ts-for-further-decomposition.md b/backlog/tasks/task-57 - Evaluate-anki-integration.ts-for-further-decomposition.md new file mode 100644 index 0000000..000a21e --- /dev/null +++ b/backlog/tasks/task-57 - Evaluate-anki-integration.ts-for-further-decomposition.md @@ -0,0 +1,49 @@ +--- +id: TASK-57 +title: Evaluate anki-integration.ts for further decomposition +status: To Do +assignee: [] +created_date: '2026-02-16 04:47' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/anki-integration.ts + - /home/sudacode/projects/japanese/SubMiner/src/anki-integration/ +priority: low +--- + +## Description + + +anki-integration.ts remains the largest file at 1930 lines despite having domain modules extracted to `src/anki-integration/` directory. + +Current state: +- Main file: `src/anki-integration.ts` (1930 lines) +- Extracted modules: + - card-creation.ts (727 lines) + - known-word-cache.ts (398 lines) + - field-grouping.ts (79 lines) + - polling.ts (36 lines) + - duplicate.ts (30 lines) + - ui-feedback.ts (29 lines) + - ai.ts (4.2k) + +This task is to evaluate whether the main anki-integration.ts file can be further decomposed or if 1930 lines is acceptable for a main integration class. + +Evaluation criteria: +1. Are there remaining cohesive units that could be extracted? +2. Is the remaining code primarily orchestration logic (which is acceptable to be longer)? +3. Would further splitting improve or hurt readability? +4. Are there internal classes or helpers that could be standalone? + +Deliverable: A decision document with recommendations - either proceed with further decomposition or document why the current state is acceptable. + + +## Acceptance Criteria + +- [ ] #1 Review anki-integration.ts structure and identify remaining cohesive units +- [ ] #2 Evaluate if extraction would improve or hurt maintainability +- [ ] #3 Document decision with rationale +- [ ] #4 If proceeding: create extraction plan with specific modules to create +- [ ] #5 If not proceeding: document architectural justification for keeping as-is + diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 559a040..9eaa425 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -36,6 +36,46 @@ test("parses jsonc and warns/falls back on invalid value", () => { assert.ok(service.getWarnings().some((w) => w.path === "websocket.port")); }); +test("accepts valid logging.level", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "config.jsonc"), + `{ + "logging": { + "level": "warn" + } + }`, + "utf-8", + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.logging.level, "warn"); +}); + +test("falls back for invalid logging.level and reports warning", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "config.jsonc"), + `{ + "logging": { + "level": "trace" + } + }`, + "utf-8", + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level); + assert.ok( + warnings.some((warning) => warning.path === "logging.level"), + ); +}); + test("parses invisible overlay config and new global shortcuts", () => { const dir = makeTempDir(); fs.writeFileSync( @@ -372,6 +412,7 @@ test("falls back to default when ankiConnect n+1 deck list is invalid", () => { test("template generator includes known keys", () => { const output = generateConfigTemplate(DEFAULT_CONFIG); assert.match(output, /"ankiConnect":/); + assert.match(output, /"logging":/); assert.match(output, /"websocket":/); assert.match(output, /"youtubeSubgen":/); assert.match(output, /"nPlusOne"\s*:\s*\{/); diff --git a/src/config/definitions.ts b/src/config/definitions.ts index be4eb70..af74d45 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -75,6 +75,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { enabled: "auto", port: 6677, }, + logging: { + level: "info", + }, texthooker: { openBrowser: true, }, @@ -276,6 +279,13 @@ export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [ ]; export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [ + { + path: "logging.level", + kind: "enum", + enumValues: ["debug", "info", "warn", "error"], + defaultValue: DEFAULT_CONFIG.logging.level, + description: "Minimum log level for runtime logging.", + }, { path: "websocket.enabled", kind: "enum", @@ -460,6 +470,14 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: "websocket", }, + { + title: "Logging", + description: [ + "Controls logging verbosity.", + "Set to debug for full runtime diagnostics.", + ], + key: "logging", + }, { title: "AnkiConnect Integration", description: ["Automatic Anki updates and media generation options."], diff --git a/src/config/service.ts b/src/config/service.ts index 0a46428..69a4f05 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -196,6 +196,20 @@ export class ConfigService { } } + if (isObject(src.logging)) { + const logLevel = asString(src.logging.level); + if (logLevel === "debug" || logLevel === "info" || logLevel === "warn" || logLevel === "error") { + resolved.logging.level = logLevel; + } else if (src.logging.level !== undefined) { + warn( + "logging.level", + src.logging.level, + resolved.logging.level, + "Expected debug, info, warn, or error.", + ); + } + } + if (Array.isArray(src.keybindings)) { resolved.keybindings = src.keybindings.filter( ( diff --git a/src/core/services/anki-jimaku-ipc-service.ts b/src/core/services/anki-jimaku-ipc-service.ts index 1d9c26c..610d508 100644 --- a/src/core/services/anki-jimaku-ipc-service.ts +++ b/src/core/services/anki-jimaku-ipc-service.ts @@ -2,6 +2,7 @@ import { ipcMain, IpcMainEvent } from "electron"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { createLogger } from "../../logger"; import { JimakuApiResponse, JimakuDownloadQuery, @@ -16,6 +17,8 @@ import { KikuMergePreviewResponse, } from "../../types"; +const logger = createLogger("main:anki-jimaku-ipc"); + export interface AnkiJimakuIpcDeps { setAnkiConnectEnabled: (enabled: boolean) => void; clearAnkiHistory: () => void; @@ -147,7 +150,7 @@ export function registerAnkiJimakuIpcHandlers( } } - console.log( + logger.info( `[jimaku] download-file name="${query.name}" entryId=${query.entryId}`, ); const result = await deps.downloadToFile(query.url, targetPath, { @@ -156,10 +159,10 @@ export function registerAnkiJimakuIpcHandlers( }); if (result.ok) { - console.log(`[jimaku] download-file saved to ${result.path}`); + logger.info(`[jimaku] download-file saved to ${result.path}`); deps.onDownloadedSubtitle(result.path); } else { - console.error( + logger.error( `[jimaku] download-file failed: ${result.error?.error ?? "unknown error"}`, ); } diff --git a/src/core/services/anki-jimaku-service.ts b/src/core/services/anki-jimaku-service.ts index 64fd2ed..97d506d 100644 --- a/src/core/services/anki-jimaku-service.ts +++ b/src/core/services/anki-jimaku-service.ts @@ -11,6 +11,7 @@ import { } from "../../types"; import { sortJimakuFiles } from "../../jimaku/utils"; import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc-service"; +import { createLogger } from "../../logger"; export type RegisterAnkiJimakuIpcRuntimeHandler = ( deps: AnkiJimakuIpcDeps, @@ -62,6 +63,8 @@ export interface AnkiJimakuIpcRuntimeOptions { ) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>; } +const logger = createLogger("main:anki-jimaku"); + export function registerAnkiJimakuIpcRuntimeService( options: AnkiJimakuIpcRuntimeOptions, registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler, @@ -96,11 +99,11 @@ export function registerAnkiJimakuIpcRuntimeService( ); integration.start(); options.setAnkiIntegration(integration); - console.log("AnkiConnect integration enabled"); + logger.info("AnkiConnect integration enabled"); } else if (!enabled && ankiIntegration) { ankiIntegration.destroy(); options.setAnkiIntegration(null); - console.log("AnkiConnect integration disabled"); + logger.info("AnkiConnect integration disabled"); } options.broadcastRuntimeOptionsChanged(); @@ -109,7 +112,7 @@ export function registerAnkiJimakuIpcRuntimeService( const subtitleTimingTracker = options.getSubtitleTimingTracker(); if (subtitleTimingTracker) { subtitleTimingTracker.cleanup(); - console.log("AnkiConnect subtitle timing history cleared"); + logger.info("AnkiConnect subtitle timing history cleared"); } }, refreshKnownWords: async () => { @@ -139,7 +142,7 @@ export function registerAnkiJimakuIpcRuntimeService( }, getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), searchJimakuEntries: async (query) => { - console.log(`[jimaku] search-entries query: "${query.query}"`); + logger.info(`[jimaku] search-entries query: "${query.query}"`); const response = await options.jimakuFetchJson( "/api/entries/search", { @@ -149,13 +152,13 @@ export function registerAnkiJimakuIpcRuntimeService( ); if (!response.ok) return response; const maxResults = options.getJimakuMaxEntryResults(); - console.log( + logger.info( `[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`, ); return { ok: true, data: response.data.slice(0, maxResults) }; }, listJimakuFiles: async (query) => { - console.log( + logger.info( `[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`, ); const response = await options.jimakuFetchJson( @@ -169,7 +172,7 @@ export function registerAnkiJimakuIpcRuntimeService( response.data, options.getJimakuLanguagePreference(), ); - console.log(`[jimaku] list-files returned ${sorted.length} files`); + logger.info(`[jimaku] list-files returned ${sorted.length} files`); return { ok: true, data: sorted }; }, resolveJimakuApiKey: () => options.resolveJimakuApiKey(), diff --git a/src/core/services/app-lifecycle-service.ts b/src/core/services/app-lifecycle-service.ts index b4a795a..ca2bb25 100644 --- a/src/core/services/app-lifecycle-service.ts +++ b/src/core/services/app-lifecycle-service.ts @@ -1,4 +1,7 @@ import { CliArgs, CliCommandSource } from "../../cli/args"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:app-lifecycle"); export interface AppLifecycleServiceDeps { shouldStartApp: (args: CliArgs) => boolean; @@ -57,7 +60,7 @@ export function createAppLifecycleDepsRuntimeService( logNoRunningInstance: options.logNoRunningInstance, whenReady: (handler) => { options.app.whenReady().then(handler).catch((error) => { - console.error("App ready handler failed:", error); + logger.error("App ready handler failed:", error); }); }, onWindowAllClosed: (handler) => { @@ -91,7 +94,7 @@ export function startAppLifecycleService( try { deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); } catch (error) { - console.error("Failed to handle second-instance CLI command:", error); + logger.error("Failed to handle second-instance CLI command:", error); } }); diff --git a/src/core/services/app-ready-service.test.ts b/src/core/services/app-ready-service.test.ts index 002d0a9..93e72c9 100644 --- a/src/core/services/app-ready-service.test.ts +++ b/src/core/services/app-ready-service.test.ts @@ -12,6 +12,7 @@ function makeDeps(overrides: Partial = {}) { getResolvedConfig: () => ({ websocket: { enabled: "auto" }, secondarySub: {} }), getConfigWarnings: () => [], logConfigWarning: () => calls.push("logConfigWarning"), + setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`), initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`), defaultSecondarySubMode: "hover", @@ -55,3 +56,15 @@ test("runAppReadyRuntimeService logs defer message when overlay not auto-started ), ); }); + +test("runAppReadyRuntimeService applies config logging level during app-ready", async () => { + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: "auto" }, + secondarySub: {}, + logging: { level: "warn" }, + }), + }); + await runAppReadyRuntimeService(deps); + assert.ok(calls.includes("setLogLevel:warn:config")); +}); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 1ce9d73..5a2434b 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -11,7 +11,6 @@ export { } from "./overlay-shortcut-service"; export { createOverlayShortcutRuntimeHandlers } from "./overlay-shortcut-handler"; export { createCliCommandDepsRuntimeService, handleCliCommandService } from "./cli-command-service"; -export { cycleSecondarySubModeService } from "./secondary-subtitle-service"; export { copyCurrentSubtitleService, handleMineSentenceDigitService, @@ -23,18 +22,14 @@ export { } from "./mining-service"; export { createAppLifecycleDepsRuntimeService, startAppLifecycleService } from "./app-lifecycle-service"; export { - playNextSubtitleRuntimeService, - replayCurrentSubtitleRuntimeService, - sendMpvCommandRuntimeService, - setMpvSubVisibilityRuntimeService, - showMpvOsdRuntimeService, -} from "./mpv-control-service"; + cycleSecondarySubModeService, +} from "./subtitle-position-service"; export { getInitialInvisibleOverlayVisibilityService, isAutoUpdateEnabledRuntimeService, shouldAutoInitializeOverlayRuntimeFromConfigService, shouldBindVisibleOverlayToMpvSubVisibilityService, -} from "./runtime-config-service"; +} from "./startup-service"; export { openYomitanSettingsWindow } from "./yomitan-settings-service"; export { createTokenizerDepsRuntimeService, tokenizeSubtitleService } from "./tokenizer-service"; export { createJlptVocabularyLookupService } from "./jlpt-vocab-service"; @@ -74,7 +69,18 @@ export { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService, } from "./overlay-visibility-service"; -export { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-service"; +export { + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + MpvIpcClient, + MpvRuntimeClientLike, + MpvTrackProperty, + playNextSubtitleRuntimeService, + replayCurrentSubtitleRuntimeService, + resolveCurrentAudioStreamIndex, + sendMpvCommandRuntimeService, + setMpvSubVisibilityRuntimeService, + showMpvOsdRuntimeService, +} from "./mpv-service"; export { applyMpvSubtitleRenderMetricsPatchService, DEFAULT_MPV_SUBTITLE_RENDER_METRICS, diff --git a/src/core/services/mpv-control-service.test.ts b/src/core/services/mpv-control-service.test.ts index 5abf49e..4f69ed1 100644 --- a/src/core/services/mpv-control-service.test.ts +++ b/src/core/services/mpv-control-service.test.ts @@ -6,7 +6,7 @@ import { sendMpvCommandRuntimeService, setMpvSubVisibilityRuntimeService, showMpvOsdRuntimeService, -} from "./mpv-control-service"; +} from "./mpv-service"; test("showMpvOsdRuntimeService sends show-text when connected", () => { const commands: (string | number)[][] = []; diff --git a/src/core/services/mpv-control-service.ts b/src/core/services/mpv-control-service.ts deleted file mode 100644 index 3226826..0000000 --- a/src/core/services/mpv-control-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface MpvRuntimeClientLike { - connected: boolean; - send: (payload: { command: (string | number)[] }) => void; - replayCurrentSubtitle?: () => void; - playNextSubtitle?: () => void; - setSubVisibility?: (visible: boolean) => void; -} - -export function showMpvOsdRuntimeService( - mpvClient: MpvRuntimeClientLike | null, - text: string, - fallbackLog: (text: string) => void = console.log, -): void { - if (mpvClient && mpvClient.connected) { - mpvClient.send({ command: ["show-text", text, "3000"] }); - return; - } - fallbackLog(`OSD (MPV not connected): ${text}`); -} - -export function replayCurrentSubtitleRuntimeService( - mpvClient: MpvRuntimeClientLike | null, -): void { - if (!mpvClient?.replayCurrentSubtitle) return; - mpvClient.replayCurrentSubtitle(); -} - -export function playNextSubtitleRuntimeService( - mpvClient: MpvRuntimeClientLike | null, -): void { - if (!mpvClient?.playNextSubtitle) return; - mpvClient.playNextSubtitle(); -} - -export function sendMpvCommandRuntimeService( - mpvClient: MpvRuntimeClientLike | null, - command: (string | number)[], -): void { - if (!mpvClient) return; - mpvClient.send({ command }); -} - -export function setMpvSubVisibilityRuntimeService( - mpvClient: MpvRuntimeClientLike | null, - visible: boolean, -): void { - if (!mpvClient?.setSubVisibility) return; - mpvClient.setSubVisibility(visible); -} diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index c5edca4..e960ce2 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -17,12 +17,86 @@ import { scheduleMpvReconnect, MpvSocketTransport, } from "./mpv-transport"; -import { resolveCurrentAudioStreamIndex } from "./mpv-state"; +import { createLogger } from "../../logger"; -const isDebugLoggingEnabled = (): boolean => { - return (process.env.SUBMINER_LOG_LEVEL || "").toLowerCase() === "debug"; +const logger = createLogger("main:mpv"); + +export type MpvTrackProperty = { + type?: string; + id?: number; + selected?: boolean; + "ff-index"?: number; }; +export function resolveCurrentAudioStreamIndex( + tracks: Array | null | undefined, + currentAudioTrackId: number | null, +): number | null { + if (!Array.isArray(tracks)) { + return null; + } + + const audioTracks = tracks.filter((track) => track.type === "audio"); + const activeTrack = + audioTracks.find((track) => track.id === currentAudioTrackId) || + audioTracks.find((track) => track.selected === true); + + const ffIndex = activeTrack?.["ff-index"]; + return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0 + ? ffIndex + : null; +} + +export interface MpvRuntimeClientLike { + connected: boolean; + send: (payload: { command: (string | number)[] }) => void; + replayCurrentSubtitle?: () => void; + playNextSubtitle?: () => void; + setSubVisibility?: (visible: boolean) => void; +} + +export function showMpvOsdRuntimeService( + mpvClient: MpvRuntimeClientLike | null, + text: string, + fallbackLog: (text: string) => void = (line) => logger.info(line), +): void { + if (mpvClient && mpvClient.connected) { + mpvClient.send({ command: ["show-text", text, "3000"] }); + return; + } + fallbackLog(`OSD (MPV not connected): ${text}`); +} + +export function replayCurrentSubtitleRuntimeService( + mpvClient: MpvRuntimeClientLike | null, +): void { + if (!mpvClient?.replayCurrentSubtitle) return; + mpvClient.replayCurrentSubtitle(); +} + +export function playNextSubtitleRuntimeService( + mpvClient: MpvRuntimeClientLike | null, +): void { + if (!mpvClient?.playNextSubtitle) return; + mpvClient.playNextSubtitle(); +} + +export function sendMpvCommandRuntimeService( + mpvClient: MpvRuntimeClientLike | null, + command: (string | number)[], +): void { + if (!mpvClient) return; + mpvClient.send({ command }); +} + +export function setMpvSubVisibilityRuntimeService( + mpvClient: MpvRuntimeClientLike | null, + visible: boolean, +): void { + if (!mpvClient?.setSubVisibility) return; + mpvClient.setSubVisibility(visible); +} + export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, } from "./mpv-protocol"; @@ -104,7 +178,7 @@ export class MpvIpcClient implements MpvClient { this.transport = new MpvSocketTransport({ socketPath, onConnect: () => { - console.log("Connected to MPV socket"); + logger.info("Connected to MPV socket"); this.connected = true; this.connecting = false; this.socket = this.transport.getSocket(); @@ -118,7 +192,7 @@ export class MpvIpcClient implements MpvClient { this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; if (this.firstConnection && shouldAutoStart) { - console.log("Auto-starting overlay, hiding mpv subtitles"); + logger.info("Auto-starting overlay, hiding mpv subtitles"); setTimeout(() => { this.deps.setOverlayVisible(true); }, 100); @@ -133,15 +207,11 @@ export class MpvIpcClient implements MpvClient { this.processBuffer(); }, onError: (err: Error) => { - if (isDebugLoggingEnabled()) { - console.error("MPV socket error:", err.message); - } + logger.debug("MPV socket error:", err.message); this.failPendingRequests(); }, onClose: () => { - if (isDebugLoggingEnabled()) { - console.log("MPV socket closed"); - } + logger.debug("MPV socket closed"); this.connected = false; this.connecting = false; this.socket = null; @@ -202,11 +272,9 @@ export class MpvIpcClient implements MpvClient { getReconnectTimer: () => this.deps.getReconnectTimer(), setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer), onReconnectAttempt: (attempt, delay) => { - if (isDebugLoggingEnabled()) { - console.log( - `Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, - ); - } + logger.debug( + `Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, + ); }, connect: () => { this.connect(); @@ -221,7 +289,7 @@ export class MpvIpcClient implements MpvClient { this.handleMessage(message); }, (line, error) => { - console.error("Failed to parse MPV message:", line, error); + logger.error("Failed to parse MPV message:", line, error); }, ); this.buffer = parsed.nextBuffer; diff --git a/src/core/services/mpv-state.test.ts b/src/core/services/mpv-state.test.ts index f33529d..14bc89c 100644 --- a/src/core/services/mpv-state.test.ts +++ b/src/core/services/mpv-state.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveCurrentAudioStreamIndex } from "./mpv-state"; +import { resolveCurrentAudioStreamIndex } from "./mpv-service"; test("resolveCurrentAudioStreamIndex returns selected ff-index when no current track id", () => { assert.equal( diff --git a/src/core/services/mpv-state.ts b/src/core/services/mpv-state.ts deleted file mode 100644 index a119bed..0000000 --- a/src/core/services/mpv-state.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type MpvTrackProperty = { - type?: string; - id?: number; - selected?: boolean; - "ff-index"?: number; -}; - -export function resolveCurrentAudioStreamIndex( - tracks: Array | null | undefined, - currentAudioTrackId: number | null, -): number | null { - if (!Array.isArray(tracks)) { - return null; - } - - const audioTracks = tracks.filter((track) => track.type === "audio"); - const activeTrack = - audioTracks.find((track) => track.id === currentAudioTrackId) || - audioTracks.find((track) => track.selected === true); - - const ffIndex = activeTrack?.["ff-index"]; - return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0 - ? ffIndex - : null; -} diff --git a/src/core/services/overlay-content-measurement-service.ts b/src/core/services/overlay-content-measurement-service.ts index 8eadb25..214549e 100644 --- a/src/core/services/overlay-content-measurement-service.ts +++ b/src/core/services/overlay-content-measurement-service.ts @@ -1,4 +1,7 @@ import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:overlay-content-measurement"); const MAX_VIEWPORT = 10000; const MAX_RECT_DIMENSION = 10000; const MAX_RECT_OFFSET = 50000; @@ -107,7 +110,7 @@ export function createOverlayContentMeasurementStoreService(options?: { warn?: (message: string) => void; }) { const now = options?.now ?? (() => Date.now()); - const warn = options?.warn ?? ((message: string) => console.warn(message)); + const warn = options?.warn ?? ((message: string) => logger.warn(message)); const latestByLayer: OverlayMeasurementStore = { visible: null, invisible: null, diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index 912ac7e..721f67f 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -1,5 +1,8 @@ import { ConfiguredShortcuts } from "../utils/shortcut-config"; import { OverlayShortcutHandlers } from "./overlay-shortcut-service"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:overlay-shortcut-handler"); export interface OverlayShortcutFallbackHandlers { openRuntimeOptions: () => void; @@ -38,7 +41,7 @@ function wrapAsync( ): () => void { return () => { task().catch((err) => { - console.error(`${logLabel} failed:`, err); + logger.error(`${logLabel} failed:`, err); deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`); }); }; diff --git a/src/core/services/overlay-shortcut-service.ts b/src/core/services/overlay-shortcut-service.ts index 71d955a..ca0b289 100644 --- a/src/core/services/overlay-shortcut-service.ts +++ b/src/core/services/overlay-shortcut-service.ts @@ -1,6 +1,9 @@ import { globalShortcut } from "electron"; import { ConfiguredShortcuts } from "../utils/shortcut-config"; import { isGlobalShortcutRegisteredSafe } from "./shortcut-fallback-service"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:overlay-shortcut-service"); export interface OverlayShortcutHandlers { copySubtitle: () => void; @@ -39,7 +42,7 @@ export function registerOverlayShortcutsService( } const ok = globalShortcut.register(accelerator, handler); if (!ok) { - console.warn( + logger.warn( `Failed to register overlay shortcut ${label}: ${accelerator}`, ); return; diff --git a/src/core/services/overlay-window-service.ts b/src/core/services/overlay-window-service.ts index caa988e..5c2aec8 100644 --- a/src/core/services/overlay-window-service.ts +++ b/src/core/services/overlay-window-service.ts @@ -1,6 +1,9 @@ import { BrowserWindow } from "electron"; import * as path from "path"; import { WindowGeometry } from "../../types"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:overlay-window"); export type OverlayWindowKind = "visible" | "invisible"; @@ -86,13 +89,13 @@ export function createOverlayWindowService( query: { layer: kind === "visible" ? "visible" : "invisible" }, }) .catch((err) => { - console.error("Failed to load HTML file:", err); + logger.error("Failed to load HTML file:", err); }); window.webContents.on( "did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { - console.error( + logger.error( "Page failed to load:", errorCode, errorDescription, diff --git a/src/core/services/runtime-config-service.test.ts b/src/core/services/runtime-config-service.test.ts index 2cde904..c935dc4 100644 --- a/src/core/services/runtime-config-service.test.ts +++ b/src/core/services/runtime-config-service.test.ts @@ -5,7 +5,7 @@ import { isAutoUpdateEnabledRuntimeService, shouldAutoInitializeOverlayRuntimeFromConfigService, shouldBindVisibleOverlayToMpvSubVisibilityService, -} from "./runtime-config-service"; +} from "./startup-service"; const BASE_CONFIG = { auto_start_overlay: false, diff --git a/src/core/services/runtime-config-service.ts b/src/core/services/runtime-config-service.ts deleted file mode 100644 index 620f438..0000000 --- a/src/core/services/runtime-config-service.ts +++ /dev/null @@ -1,50 +0,0 @@ -interface RuntimeAutoUpdateOptionManagerLike { - getOptionValue: (id: "anki.autoUpdateNewCards") => unknown; -} - -interface RuntimeConfigLike { - auto_start_overlay?: boolean; - bind_visible_overlay_to_mpv_sub_visibility: boolean; - invisibleOverlay: { - startupVisibility: "visible" | "hidden" | "platform-default"; - }; - ankiConnect?: { - behavior?: { - autoUpdateNewCards?: boolean; - }; - }; -} - -export function getInitialInvisibleOverlayVisibilityService( - config: RuntimeConfigLike, - platform: NodeJS.Platform, -): boolean { - const visibility = config.invisibleOverlay.startupVisibility; - if (visibility === "visible") return true; - if (visibility === "hidden") return false; - if (platform === "linux") return false; - return true; -} - -export function shouldAutoInitializeOverlayRuntimeFromConfigService( - config: RuntimeConfigLike, -): boolean { - if (config.auto_start_overlay === true) return true; - if (config.invisibleOverlay.startupVisibility === "visible") return true; - return false; -} - -export function shouldBindVisibleOverlayToMpvSubVisibilityService( - config: RuntimeConfigLike, -): boolean { - return config.bind_visible_overlay_to_mpv_sub_visibility; -} - -export function isAutoUpdateEnabledRuntimeService( - config: RuntimeConfigLike, - runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null, -): boolean { - const value = runtimeOptionsManager?.getOptionValue("anki.autoUpdateNewCards"); - if (typeof value === "boolean") return value; - return config.ankiConnect?.behavior?.autoUpdateNewCards !== false; -} diff --git a/src/core/services/secondary-subtitle-service.test.ts b/src/core/services/secondary-subtitle-service.test.ts index ddbc342..8e88801 100644 --- a/src/core/services/secondary-subtitle-service.test.ts +++ b/src/core/services/secondary-subtitle-service.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { SecondarySubMode } from "../../types"; -import { cycleSecondarySubModeService } from "./secondary-subtitle-service"; +import { cycleSecondarySubModeService } from "./subtitle-position-service"; test("cycleSecondarySubModeService cycles and emits broadcast + OSD", () => { let mode: SecondarySubMode = "hover"; diff --git a/src/core/services/secondary-subtitle-service.ts b/src/core/services/secondary-subtitle-service.ts deleted file mode 100644 index 561e71b..0000000 --- a/src/core/services/secondary-subtitle-service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { SecondarySubMode } from "../../types"; - -export interface CycleSecondarySubModeDeps { - getSecondarySubMode: () => SecondarySubMode; - setSecondarySubMode: (mode: SecondarySubMode) => void; - getLastSecondarySubToggleAtMs: () => number; - setLastSecondarySubToggleAtMs: (timestampMs: number) => void; - broadcastSecondarySubMode: (mode: SecondarySubMode) => void; - showMpvOsd: (text: string) => void; - now?: () => number; -} - -const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"]; -const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120; - -export function cycleSecondarySubModeService( - deps: CycleSecondarySubModeDeps, -): void { - const now = deps.now ? deps.now() : Date.now(); - if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) { - return; - } - deps.setLastSecondarySubToggleAtMs(now); - - const currentMode = deps.getSecondarySubMode(); - const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode); - const nextMode = - SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length]; - deps.setSecondarySubMode(nextMode); - deps.broadcastSecondarySubMode(nextMode); - deps.showMpvOsd(`Secondary subtitle: ${nextMode}`); -} diff --git a/src/core/services/shortcut-service.ts b/src/core/services/shortcut-service.ts index c4ef5be..fee6660 100644 --- a/src/core/services/shortcut-service.ts +++ b/src/core/services/shortcut-service.ts @@ -1,4 +1,7 @@ import { BrowserWindow, globalShortcut } from "electron"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:shortcut"); export interface GlobalShortcutConfig { toggleVisibleOverlayGlobal: string | null | undefined; @@ -38,7 +41,7 @@ export function registerGlobalShortcutsService( }, ); if (!toggleVisibleRegistered) { - console.warn( + logger.warn( `Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`, ); } @@ -56,7 +59,7 @@ export function registerGlobalShortcutsService( }, ); if (!toggleInvisibleRegistered) { - console.warn( + logger.warn( `Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`, ); } @@ -65,7 +68,7 @@ export function registerGlobalShortcutsService( normalizedInvisible && normalizedInvisible === normalizedVisible ) { - console.warn( + logger.warn( "Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal", ); } @@ -77,7 +80,7 @@ export function registerGlobalShortcutsService( normalizedJimaku === normalizedInvisible || normalizedJimaku === normalizedSettings) ) { - console.warn( + logger.warn( "Skipped registering openJimaku because it collides with another global shortcut", ); } else { @@ -88,7 +91,7 @@ export function registerGlobalShortcutsService( }, ); if (!openJimakuRegistered) { - console.warn( + logger.warn( `Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`, ); } @@ -99,7 +102,7 @@ export function registerGlobalShortcutsService( options.onOpenYomitanSettings(); }); if (!settingsRegistered) { - console.warn("Failed to register global shortcut: Alt+Shift+Y"); + logger.warn("Failed to register global shortcut: Alt+Shift+Y"); } if (options.isDev) { @@ -110,7 +113,7 @@ export function registerGlobalShortcutsService( } }); if (!devtoolsRegistered) { - console.warn("Failed to register global shortcut: F12"); + logger.warn("Failed to register global shortcut: F12"); } } } diff --git a/src/core/services/startup-bootstrap-service.test.ts b/src/core/services/startup-bootstrap-service.test.ts index fe1258a..7ef4b4f 100644 --- a/src/core/services/startup-bootstrap-service.test.ts +++ b/src/core/services/startup-bootstrap-service.test.ts @@ -54,8 +54,7 @@ test("runStartupBootstrapRuntimeService configures startup state and starts life const result = runStartupBootstrapRuntimeService({ argv: ["node", "main.ts", "--verbose"], parseArgs: () => args, - setLogLevelEnv: (level) => calls.push(`setLog:${level}`), - enableVerboseLogging: () => calls.push("enableVerbose"), + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), forceX11Backend: () => calls.push("forceX11"), enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"), getDefaultSocketPath: () => "/tmp/default.sock", @@ -71,13 +70,39 @@ test("runStartupBootstrapRuntimeService configures startup state and starts life assert.equal(result.autoStartOverlay, true); assert.equal(result.texthookerOnlyMode, true); assert.deepEqual(calls, [ - "enableVerbose", + "setLog:debug:cli", "forceX11", "enforceWayland", "startLifecycle", ]); }); +test("runStartupBootstrapRuntimeService prefers --log-level over --verbose", () => { + const calls: string[] = []; + const args = makeArgs({ + logLevel: "warn", + verbose: true, + }); + + runStartupBootstrapRuntimeService({ + argv: ["node", "main.ts", "--log-level", "warn", "--verbose"], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push("forceX11"), + enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"), + getDefaultSocketPath: () => "/tmp/default.sock", + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push("startLifecycle"), + }); + + assert.deepEqual(calls.slice(0, 3), [ + "setLog:warn:cli", + "forceX11", + "enforceWayland", + ]); +}); + test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flow handled", () => { const calls: string[] = []; const args = makeArgs({ generateConfig: true, logLevel: "warn" }); @@ -85,8 +110,7 @@ test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flo const result = runStartupBootstrapRuntimeService({ argv: ["node", "main.ts", "--generate-config"], parseArgs: () => args, - setLogLevelEnv: (level) => calls.push(`setLog:${level}`), - enableVerboseLogging: () => calls.push("enableVerbose"), + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), forceX11Backend: () => calls.push("forceX11"), enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"), getDefaultSocketPath: () => "/tmp/default.sock", @@ -99,7 +123,7 @@ test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flo assert.equal(result.texthookerPort, 5174); assert.equal(result.backendOverride, null); assert.deepEqual(calls, [ - "setLog:warn", + "setLog:warn:cli", "forceX11", "enforceWayland", ]); diff --git a/src/core/services/startup-service.ts b/src/core/services/startup-service.ts index 843705e..a5121fa 100644 --- a/src/core/services/startup-service.ts +++ b/src/core/services/startup-service.ts @@ -1,5 +1,6 @@ import { CliArgs } from "../../cli/args"; -import { ConfigValidationWarning, SecondarySubMode } from "../../types"; +import type { LogLevelSource } from "../../logger"; +import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from "../../types"; export interface StartupBootstrapRuntimeState { initialArgs: CliArgs; @@ -10,11 +11,27 @@ export interface StartupBootstrapRuntimeState { texthookerOnlyMode: boolean; } +interface RuntimeAutoUpdateOptionManagerLike { + getOptionValue: (id: "anki.autoUpdateNewCards") => unknown; +} + +export interface RuntimeConfigLike { + auto_start_overlay?: boolean; + bind_visible_overlay_to_mpv_sub_visibility: boolean; + invisibleOverlay: { + startupVisibility: "visible" | "hidden" | "platform-default"; + }; + ankiConnect?: { + behavior?: { + autoUpdateNewCards?: boolean; + }; + }; +} + export interface StartupBootstrapRuntimeDeps { argv: string[]; parseArgs: (argv: string[]) => CliArgs; - setLogLevelEnv: (level: string) => void; - enableVerboseLogging: () => void; + setLogLevel: (level: string, source: LogLevelSource) => void; forceX11Backend: (args: CliArgs) => void; enforceUnsupportedWaylandMode: (args: CliArgs) => void; getDefaultSocketPath: () => string; @@ -29,9 +46,9 @@ export function runStartupBootstrapRuntimeService( const initialArgs = deps.parseArgs(deps.argv); if (initialArgs.logLevel) { - deps.setLogLevelEnv(initialArgs.logLevel); + deps.setLogLevel(initialArgs.logLevel, "cli"); } else if (initialArgs.verbose) { - deps.enableVerboseLogging(); + deps.setLogLevel("debug", "cli"); } deps.forceX11Backend(initialArgs); @@ -61,6 +78,9 @@ interface AppReadyConfigLike { enabled?: boolean | "auto"; port?: number; }; + logging?: { + level?: "debug" | "info" | "warn" | "error"; + }; } export interface AppReadyRuntimeDeps { @@ -71,6 +91,7 @@ export interface AppReadyRuntimeDeps { getResolvedConfig: () => AppReadyConfigLike; getConfigWarnings: () => ConfigValidationWarning[]; logConfigWarning: (warning: ConfigValidationWarning) => void; + setLogLevel: (level: string, source: LogLevelSource) => void; initRuntimeOptionsManager: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; defaultSecondarySubMode: SecondarySubMode; @@ -87,6 +108,40 @@ export interface AppReadyRuntimeDeps { handleInitialArgs: () => void; } +export function getInitialInvisibleOverlayVisibilityService( + config: RuntimeConfigLike, + platform: NodeJS.Platform, +): boolean { + const visibility = config.invisibleOverlay.startupVisibility; + if (visibility === "visible") return true; + if (visibility === "hidden") return false; + if (platform === "linux") return false; + return true; +} + +export function shouldAutoInitializeOverlayRuntimeFromConfigService( + config: RuntimeConfigLike, +): boolean { + if (config.auto_start_overlay === true) return true; + if (config.invisibleOverlay.startupVisibility === "visible") return true; + return false; +} + +export function shouldBindVisibleOverlayToMpvSubVisibilityService( + config: RuntimeConfigLike, +): boolean { + return config.bind_visible_overlay_to_mpv_sub_visibility; +} + +export function isAutoUpdateEnabledRuntimeService( + config: ResolvedConfig | RuntimeConfigLike, + runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null, +): boolean { + const value = runtimeOptionsManager?.getOptionValue("anki.autoUpdateNewCards"); + if (typeof value === "boolean") return value; + return (config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !== false; +} + export async function runAppReadyRuntimeService( deps: AppReadyRuntimeDeps, ): Promise { @@ -97,6 +152,7 @@ export async function runAppReadyRuntimeService( deps.reloadConfig(); const config = deps.getResolvedConfig(); + deps.setLogLevel(config.logging?.level ?? "info", "config"); for (const warning of deps.getConfigWarnings()) { deps.logConfigWarning(warning); } diff --git a/src/core/services/subsync-service.ts b/src/core/services/subsync-service.ts index 6316d5d..a89f996 100644 --- a/src/core/services/subsync-service.ts +++ b/src/core/services/subsync-service.ts @@ -19,6 +19,9 @@ import { SubsyncResolvedConfig, } from "../../subsync/utils"; import { isRemoteMediaPath } from "../../jimaku/utils"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:subsync"); interface FileExtractionResult { path: string; @@ -361,7 +364,7 @@ async function runSubsyncAutoInternal( return alassResult; } } catch (error) { - console.warn("Auto alass sync failed, trying ffsubsync fallback:", error); + logger.warn("Auto alass sync failed, trying ffsubsync fallback:", error); } finally { if (secondaryExtraction) { cleanupTemporaryFile(secondaryExtraction); diff --git a/src/core/services/subtitle-position-service.ts b/src/core/services/subtitle-position-service.ts index 98f3cd7..c9374c0 100644 --- a/src/core/services/subtitle-position-service.ts +++ b/src/core/services/subtitle-position-service.ts @@ -1,7 +1,40 @@ import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; -import { SubtitlePosition } from "../../types"; +import { SecondarySubMode, SubtitlePosition } from "../../types"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:subtitle-position"); + +export interface CycleSecondarySubModeDeps { + getSecondarySubMode: () => SecondarySubMode; + setSecondarySubMode: (mode: SecondarySubMode) => void; + getLastSecondarySubToggleAtMs: () => number; + setLastSecondarySubToggleAtMs: (timestampMs: number) => void; + broadcastSecondarySubMode: (mode: SecondarySubMode) => void; + showMpvOsd: (text: string) => void; + now?: () => number; +} + +const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"]; +const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120; + +export function cycleSecondarySubModeService( + deps: CycleSecondarySubModeDeps, +): void { + const now = deps.now ? deps.now() : Date.now(); + if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) { + return; + } + deps.setLastSecondarySubToggleAtMs(now); + + const currentMode = deps.getSecondarySubMode(); + const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode); + const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length]; + deps.setSecondarySubMode(nextMode); + deps.broadcastSecondarySubMode(nextMode); + deps.showMpvOsd(`Secondary subtitle: ${nextMode}`); +} function getSubtitlePositionFilePath( mediaPath: string, @@ -97,7 +130,7 @@ export function loadSubtitlePositionService(options: { } return options.fallbackPosition; } catch (err) { - console.error("Failed to load subtitle position:", (err as Error).message); + logger.error("Failed to load subtitle position:", (err as Error).message); return options.fallbackPosition; } } @@ -111,7 +144,7 @@ export function saveSubtitlePositionService(options: { }): void { if (!options.currentMediaPath) { options.onQueuePending(options.position); - console.warn("Queued subtitle position save - no media path yet"); + logger.warn("Queued subtitle position save - no media path yet"); return; } @@ -123,7 +156,7 @@ export function saveSubtitlePositionService(options: { ); options.onPersisted(); } catch (err) { - console.error("Failed to save subtitle position:", (err as Error).message); + logger.error("Failed to save subtitle position:", (err as Error).message); } } @@ -154,8 +187,8 @@ export function updateCurrentMediaPathService(options: { ); options.setSubtitlePosition(options.pendingSubtitlePosition); options.clearPendingSubtitlePosition(); - } catch (err) { - console.error( + } catch (err) { + logger.error( "Failed to persist queued subtitle position:", (err as Error).message, ); diff --git a/src/core/services/subtitle-ws-service.ts b/src/core/services/subtitle-ws-service.ts index 04950f6..fe1606e 100644 --- a/src/core/services/subtitle-ws-service.ts +++ b/src/core/services/subtitle-ws-service.ts @@ -2,6 +2,9 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import WebSocket from "ws"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:subtitle-ws"); export function hasMpvWebsocketPlugin(): boolean { const mpvWebsocketPath = path.join( @@ -24,7 +27,7 @@ export class SubtitleWebSocketService { this.server = new WebSocket.Server({ port, host: "127.0.0.1" }); this.server.on("connection", (ws: WebSocket) => { - console.log("WebSocket client connected"); + logger.info("WebSocket client connected"); const currentText = getCurrentSubtitleText(); if (currentText) { ws.send(JSON.stringify({ sentence: currentText })); @@ -32,10 +35,10 @@ export class SubtitleWebSocketService { }); this.server.on("error", (err: Error) => { - console.error("WebSocket server error:", err.message); + logger.error("WebSocket server error:", err.message); }); - console.log(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`); + logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`); } public broadcast(text: string): void { diff --git a/src/core/services/texthooker-service.ts b/src/core/services/texthooker-service.ts index 5408f0f..fc43be8 100644 --- a/src/core/services/texthooker-service.ts +++ b/src/core/services/texthooker-service.ts @@ -1,6 +1,9 @@ import * as fs from "fs"; import * as http from "http"; import * as path from "path"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:texthooker"); export class TexthookerService { private server: http.Server | null = null; @@ -12,7 +15,7 @@ export class TexthookerService { public start(port: number): http.Server | null { const texthookerPath = this.getTexthookerPath(); if (!texthookerPath) { - console.error("texthooker-ui not found"); + logger.error("texthooker-ui not found"); return null; } @@ -48,7 +51,7 @@ export class TexthookerService { }); this.server.listen(port, "127.0.0.1", () => { - console.log(`Texthooker server running at http://127.0.0.1:${port}`); + logger.info(`Texthooker server running at http://127.0.0.1:${port}`); }); return this.server; diff --git a/src/core/services/yomitan-extension-loader-service.ts b/src/core/services/yomitan-extension-loader-service.ts index e206670..946e4d6 100644 --- a/src/core/services/yomitan-extension-loader-service.ts +++ b/src/core/services/yomitan-extension-loader-service.ts @@ -1,6 +1,9 @@ import { BrowserWindow, Extension, session } from "electron"; import * as fs from "fs"; import * as path from "path"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:yomitan-extension-loader"); export interface YomitanExtensionLoaderDeps { userDataPath: string; @@ -49,7 +52,7 @@ function ensureExtensionCopy(sourceDir: string, userDataPath: string): string { fs.mkdirSync(extensionsRoot, { recursive: true }); fs.rmSync(targetDir, { recursive: true, force: true }); fs.cpSync(sourceDir, targetDir, { recursive: true }); - console.log(`Copied yomitan extension to ${targetDir}`); + logger.info(`Copied yomitan extension to ${targetDir}`); } return targetDir; @@ -75,8 +78,8 @@ export async function loadYomitanExtensionService( } if (!extPath) { - console.error("Yomitan extension not found in any search path"); - console.error("Install Yomitan to one of:", searchPaths); + logger.error("Yomitan extension not found in any search path"); + logger.error("Install Yomitan to one of:", searchPaths); return null; } @@ -102,8 +105,8 @@ export async function loadYomitanExtensionService( deps.setYomitanExtension(extension); return extension; } catch (err) { - console.error("Failed to load Yomitan extension:", (err as Error).message); - console.error("Full error:", err); + logger.error("Failed to load Yomitan extension:", (err as Error).message); + logger.error("Full error:", err); deps.setYomitanExtension(null); return null; } diff --git a/src/core/services/yomitan-settings-service.ts b/src/core/services/yomitan-settings-service.ts index ebe4950..698db4c 100644 --- a/src/core/services/yomitan-settings-service.ts +++ b/src/core/services/yomitan-settings-service.ts @@ -1,4 +1,7 @@ import { BrowserWindow, Extension, session } from "electron"; +import { createLogger } from "../../logger"; + +const logger = createLogger("main:yomitan-settings"); export interface OpenYomitanSettingsWindowOptions { yomitanExt: Extension | null; @@ -9,14 +12,14 @@ export interface OpenYomitanSettingsWindowOptions { export function openYomitanSettingsWindow( options: OpenYomitanSettingsWindowOptions, ): void { - console.log("openYomitanSettings called"); + logger.info("openYomitanSettings called"); if (!options.yomitanExt) { - console.error( + logger.error( "Yomitan extension not loaded - yomitanExt is:", options.yomitanExt, ); - console.error( + logger.error( "This may be due to Manifest V3 service worker issues with Electron", ); return; @@ -24,12 +27,12 @@ export function openYomitanSettingsWindow( const existingWindow = options.getExistingWindow(); if (existingWindow && !existingWindow.isDestroyed()) { - console.log("Settings window already exists, focusing"); + logger.info("Settings window already exists, focusing"); existingWindow.focus(); return; } - console.log("Creating new settings window for extension:", options.yomitanExt.id); + logger.info("Creating new settings window for extension:", options.yomitanExt.id); const settingsWindow = new BrowserWindow({ width: 1200, @@ -44,7 +47,7 @@ export function openYomitanSettingsWindow( options.setWindow(settingsWindow); const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`; - console.log("Loading settings URL:", settingsUrl); + logger.info("Loading settings URL:", settingsUrl); let loadAttempts = 0; const maxAttempts = 3; @@ -53,13 +56,13 @@ export function openYomitanSettingsWindow( settingsWindow .loadURL(settingsUrl) .then(() => { - console.log("Settings URL loaded successfully"); + logger.info("Settings URL loaded successfully"); }) .catch((err: Error) => { - console.error("Failed to load settings URL:", err); + logger.error("Failed to load settings URL:", err); loadAttempts++; if (loadAttempts < maxAttempts && !settingsWindow.isDestroyed()) { - console.log( + logger.info( `Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`, ); setTimeout(attemptLoad, 500); @@ -72,7 +75,7 @@ export function openYomitanSettingsWindow( settingsWindow.webContents.on( "did-fail-load", (_event, errorCode, errorDescription) => { - console.error( + logger.error( "Settings page failed to load:", errorCode, errorDescription, @@ -81,7 +84,7 @@ export function openYomitanSettingsWindow( ); settingsWindow.webContents.on("did-finish-load", () => { - console.log("Settings page loaded successfully"); + logger.info("Settings page loaded successfully"); }); setTimeout(() => { diff --git a/src/logger.ts b/src/logger.ts index 1e0b791..43e21b9 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,5 @@ export type LogLevel = "debug" | "info" | "warn" | "error"; +export type LogLevelSource = "cli" | "config"; type LogMethod = (message: string, ...meta: unknown[]) => void; @@ -18,10 +19,25 @@ const LEVEL_PRIORITY: Record = { error: 40, }; +const DEFAULT_LOG_LEVEL: LogLevel = "info"; + +let cliLogLevel: LogLevel | undefined; +let configLogLevel: LogLevel | undefined; + function pad(value: number): string { return String(value).padStart(2, "0"); } +function normalizeLogLevel(level: string | undefined): LogLevel | undefined { + const normalized = (level || "").toLowerCase() as LogLevel; + return LOG_LEVELS.includes(normalized) ? normalized : undefined; +} + +function getEnvLogLevel(): LogLevel | undefined { + if (!process || !process.env) return undefined; + return normalizeLogLevel(process.env.SUBMINER_LOG_LEVEL); +} + function formatTimestamp(date: Date): string { const year = date.getFullYear(); const month = pad(date.getMonth() + 1); @@ -33,15 +49,29 @@ function formatTimestamp(date: Date): string { } function resolveMinLevel(): LogLevel { - const raw = - typeof process !== "undefined" && process?.env - ? process.env.SUBMINER_LOG_LEVEL - : undefined; - const normalized = (raw || "").toLowerCase() as LogLevel; - if (LOG_LEVELS.includes(normalized)) { - return normalized; + const envLevel = getEnvLogLevel(); + if (cliLogLevel) { + return cliLogLevel; + } + if (envLevel) { + return envLevel; + } + if (configLogLevel) { + return configLogLevel; + } + return DEFAULT_LOG_LEVEL; +} + +export function setLogLevel( + level: string | undefined, + source: LogLevelSource = "cli", +): void { + const normalized = normalizeLogLevel(level); + if (source === "cli") { + cliLogLevel = normalized; + } else { + configLogLevel = normalized; } - return "info"; } function normalizeError(error: Error): { message: string; stack?: string } { diff --git a/src/main.ts b/src/main.ts index a58f00a..eba9d3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -68,6 +68,7 @@ import { import { getSubsyncConfig, } from "./subsync/utils"; +import { createLogger, setLogLevel, type LogLevelSource } from "./logger"; import { parseArgs, shouldStartApp, @@ -228,17 +229,21 @@ const isDev = process.argv.includes("--dev") || process.argv.includes("--debug"); const texthookerService = new TexthookerService(); const subtitleWsService = new SubtitleWebSocketService(); +const logger = createLogger("main"); let jlptDictionaryLookupInitialized = false; let jlptDictionaryLookupInitialization: Promise | null = null; const appLogger = { logInfo: (message: string) => { - console.log(message); + logger.info(message); }, logWarning: (message: string) => { - console.warn(message); + logger.warn(message); + }, + logError: (message: string, details: unknown) => { + logger.error(message, details); }, logNoRunningInstance: () => { - console.error("No running instance. Use --start to launch the app."); + logger.error("No running instance. Use --start to launch the app."); }, logConfigWarning: (warning: { path: string; @@ -246,7 +251,7 @@ const appLogger = { value: unknown; fallback: unknown; }) => { - console.warn( + logger.warn( `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, ); }, @@ -274,9 +279,7 @@ process.on("SIGTERM", () => { const overlayManager = createOverlayManagerService(); const overlayContentMeasurementStore = createOverlayContentMeasurementStoreService({ now: () => Date.now(), - warn: (message: string) => { - console.warn(message); - }, + warn: (message: string) => logger.warn(message), }); const overlayModalRuntime = createOverlayModalRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), @@ -509,7 +512,7 @@ async function initializeJlptDictionaryLookup(): Promise { appState.jlptLevelLookup = await createJlptVocabularyLookupService({ searchPaths: getJlptDictionarySearchPaths(), log: (message) => { - console.log(`[JLPT] ${message}`); + logger.info(`[JLPT] ${message}`); }, }); } @@ -593,11 +596,8 @@ const startupState = runStartupBootstrapRuntimeService( createStartupBootstrapRuntimeDeps({ argv: process.argv, parseArgs: (argv: string[]) => parseArgs(argv), - setLogLevelEnv: (level: string) => { - process.env.SUBMINER_LOG_LEVEL = level; - }, - enableVerboseLogging: () => { - process.env.SUBMINER_LOG_LEVEL = "debug"; + setLogLevel: (level: string, source: LogLevelSource) => { + setLogLevel(level, source); }, forceX11Backend: (args: CliArgs) => { forceX11Backend(args); @@ -624,7 +624,7 @@ const startupState = runStartupBootstrapRuntimeService( app.quit(); }, onGenerateConfigError: (error: Error) => { - console.error(`Failed to generate config: ${error.message}`); + logger.error(`Failed to generate config: ${error.message}`); process.exitCode = 1; app.quit(); }, @@ -655,6 +655,8 @@ const startupState = runStartupBootstrapRuntimeService( getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + setLogLevel: (level: string, source: LogLevelSource) => + setLogLevel(level, source), initRuntimeOptionsManager: () => { appState.runtimeOptionsManager = new RuntimeOptionsManager( () => configService.getConfig().ankiConnect, @@ -759,7 +761,7 @@ function handleCliCommand( shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, openInBrowser: (url: string) => { void shell.openExternal(url).catch((error) => { - console.error(`Failed to open browser for texthooker URL: ${url}`, error); + logger.error(`Failed to open browser for texthooker URL: ${url}`, error); }); }, isOverlayInitialized: () => appState.overlayRuntimeInitialized, @@ -787,13 +789,13 @@ function handleCliCommand( getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), log: (message: string) => { - console.log(message); + logger.info(message); }, warn: (message: string) => { - console.warn(message); + logger.warn(message); }, error: (message: string, err: unknown) => { - console.error(message, err); + logger.error(message, err); }, }); } @@ -1092,7 +1094,7 @@ function showMpvOsd(text: string): void { appState.mpvClient, text, (line) => { - console.log(line); + logger.info(line); }, ); } @@ -1249,7 +1251,7 @@ function handleMineSentenceDigit(count: number): void { appState.mpvClient?.currentSecondarySubText || undefined, showMpvOsd: (text) => showMpvOsd(text), logError: (message, err) => { - console.error(message, err); + logger.error(message, err); }, }, ); diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index b1ef3a8..ce5af7f 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -32,6 +32,7 @@ export interface AppReadyRuntimeDepsFactoryInput { hasMpvWebsocketPlugin: AppReadyRuntimeDeps["hasMpvWebsocketPlugin"]; startSubtitleWebsocket: AppReadyRuntimeDeps["startSubtitleWebsocket"]; log: AppReadyRuntimeDeps["log"]; + setLogLevel: AppReadyRuntimeDeps["setLogLevel"]; createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"]; createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"]; loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"]; @@ -77,6 +78,7 @@ export function createAppReadyRuntimeDeps( hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin, startSubtitleWebsocket: params.startSubtitleWebsocket, log: params.log, + setLogLevel: params.setLogLevel, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, createSubtitleTimingTracker: params.createSubtitleTimingTracker, loadYomitanExtension: params.loadYomitanExtension, diff --git a/src/main/startup.ts b/src/main/startup.ts index ded88d7..17b8fad 100644 --- a/src/main/startup.ts +++ b/src/main/startup.ts @@ -1,12 +1,12 @@ import { CliArgs } from "../cli/args"; import type { ResolvedConfig } from "../types"; import type { StartupBootstrapRuntimeDeps } from "../core/services/startup-service"; +import type { LogLevelSource } from "../logger"; export interface StartupBootstrapRuntimeFactoryDeps { argv: string[]; parseArgs: (argv: string[]) => CliArgs; - setLogLevelEnv: (level: string) => void; - enableVerboseLogging: () => void; + setLogLevel: (level: string, source: LogLevelSource) => void; forceX11Backend: (args: CliArgs) => void; enforceUnsupportedWaylandMode: (args: CliArgs) => void; shouldStartApp: (args: CliArgs) => boolean; @@ -34,8 +34,7 @@ export function createStartupBootstrapRuntimeDeps( return { argv: params.argv, parseArgs: params.parseArgs, - setLogLevelEnv: params.setLogLevelEnv, - enableVerboseLogging: params.enableVerboseLogging, + setLogLevel: params.setLogLevel, forceX11Backend: (args: CliArgs) => params.forceX11Backend(args), enforceUnsupportedWaylandMode: (args: CliArgs) => params.enforceUnsupportedWaylandMode(args), diff --git a/src/types.ts b/src/types.ts index 07826a3..38c55a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -348,6 +348,9 @@ export interface Config { jimaku?: JimakuConfig; invisibleOverlay?: InvisibleOverlayConfig; youtubeSubgen?: YoutubeSubgenConfig; + logging?: { + level?: "debug" | "info" | "warn" | "error"; + }; } export type RawConfig = Config; @@ -445,6 +448,9 @@ export interface ResolvedConfig { whisperModel: string; primarySubLanguages: string[]; }; + logging: { + level: "debug" | "info" | "warn" | "error"; + }; } export interface ConfigValidationWarning {