diff --git a/backlog/tasks/task-71 - Split-main.ts-into-domain-runtime-modules-round-2.md b/backlog/tasks/task-71 - Split-main.ts-into-domain-runtime-modules-round-2.md index 08ab5d3..c690da6 100644 --- a/backlog/tasks/task-71 - Split-main.ts-into-domain-runtime-modules-round-2.md +++ b/backlog/tasks/task-71 - Split-main.ts-into-domain-runtime-modules-round-2.md @@ -1,10 +1,11 @@ --- id: TASK-71 title: Split main.ts into domain runtime modules round 2 -status: In Progress -assignee: [] +status: Done +assignee: + - codex created_date: '2026-02-18 11:35' -updated_date: '2026-02-21 03:40' +updated_date: '2026-02-21 04:57' labels: - architecture - refactor @@ -45,14 +46,48 @@ priority: high ## Acceptance Criteria -- [ ] #1 `src/main.ts` responsibilities reduced to composition/wiring concerns -- [ ] #2 Extracted runtime modules have focused interfaces and isolated tests -- [ ] #3 No CLI/IPC regressions in existing test suite -- [ ] #4 Docs reflect new module boundaries +- [x] #1 `src/main.ts` responsibilities reduced to composition/wiring concerns +- [x] #2 Extracted runtime modules have focused interfaces and isolated tests +- [x] #3 No CLI/IPC regressions in existing test suite +- [x] #4 Docs reflect new module boundaries +## Implementation Plan + + +1) Extract AniList tracking composition from src/main.ts (refresh/guess/probe/retry/update handler assembly) into src/main/runtime/composers/anilist-tracking-composer.ts with focused seam tests. +2) Extract MPV runtime composition from src/main.ts (bind event handlers, MPV client factory, subtitle render metrics, tokenizer warmups) into src/main/runtime/composers/mpv-runtime-composer.ts with focused seam tests. +3) Rewire src/main.ts to consume both composers while preserving existing local helper contracts and downstream behavior. +4) Update architecture docs to reflect composition boundary under src/main/runtime/composers/*. +5) Run verification gate (build + check:main-fanin + test:core:dist), then record TASK-71 notes/AC/DoD and finalize if green. + + +## Implementation Notes + + +2026-02-21: extracted two remaining high-noise main-process composition clusters from `src/main.ts` into dedicated runtime composers: `src/main/runtime/composers/anilist-tracking-composer.ts` (AniList media tracking/probe/retry/update assembly) and `src/main/runtime/composers/mpv-runtime-composer.ts` (MPV event binding/factory/metrics/tokenizer/warmups assembly). + +Rewired `src/main.ts` to consume composer outputs while preserving existing helper contracts/call sites (`refreshAnilistClientSecretState`, `processNextAnilistRetryUpdate`, `maybeRunAnilistPostWatchUpdate`, `createMpvClientRuntimeService`, `updateMpvSubtitleRenderMetrics`, `tokenizeSubtitle`, `startBackgroundWarmups`). + +Added focused seam tests: `src/main/runtime/composers/anilist-tracking-composer.test.ts` and `src/main/runtime/composers/mpv-runtime-composer.test.ts`. + +Added `src/main/runtime/composers/index.ts` barrel and updated main imports to keep runtime fan-in strict gate green. + +Docs updated for new boundaries in `docs/architecture.md` and `docs/development.md`. + +Verification: `bun run build`, `bun test src/main/runtime/composers/anilist-tracking-composer.test.ts src/main/runtime/composers/mpv-runtime-composer.test.ts`, `bun run check:main-fanin:strict` (85 import lines / 10 unique runtime paths, pass), and `bun run test:fast` (pass). + +`src/main.ts` LOC reduced from 2956 to 2875 in this pass. + + +## Final Summary + + +Completed TASK-71 round 2 by extracting AniList tracking and MPV runtime assembly clusters out of `src/main.ts` into dedicated composer modules with focused seam tests, then rewiring the composition root to consume those modules without changing runtime behavior. Added a composers barrel to reduce runtime import fan-in and updated architecture/development docs to reflect composer-first boundaries; verification gates (`build`, `check:main-fanin:strict`, composer tests, `test:fast`) all pass. + + ## Definition of Done -- [ ] #1 `bun run test:fast` passes -- [ ] #2 Architecture docs updated +- [x] #1 `bun run test:fast` passes +- [x] #2 Architecture docs updated diff --git a/docs/architecture.md b/docs/architecture.md index a20fd2c..dd22606 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ SubMiner uses a service-oriented Electron architecture with a composition-orient ```text src/ - main.ts # Entry point — delegates to src/main/ composition modules + main.ts # Entry point — delegates to runtime composers/domain modules preload.ts # Electron preload bridge types.ts # Shared type definitions main/ # Composition root modules (extracted from main.ts) @@ -31,6 +31,9 @@ src/ startup-lifecycle.ts # App-ready initialization sequence state.ts # Application runtime state container subsync-runtime.ts # Subsync command orchestration + runtime/ + composers/ # Composition assembly clusters consumed by main.ts + domains/ # Domain barrel exports for runtime services core/ services/ # ~60 focused service modules (see below) utils/ # Pure helpers and coercion/config utilities @@ -154,7 +157,7 @@ Most runtime code follows a dependency-injection pattern: 3. Build runtime deps in `src/main/` composition modules; extract an adapter/helper only when it adds meaningful behavior or reuse. 4. Call the service from lifecycle/command wiring points. -The composition root (`src/main.ts`) delegates to focused modules in `src/main/`: +The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`: - `startup.ts` — argv/env processing and bootstrap flow - `app-lifecycle.ts` — Electron lifecycle event registration @@ -164,6 +167,8 @@ The composition root (`src/main.ts`) delegates to focused modules in `src/main/` - `cli-runtime.ts` — CLI command parsing and dispatch - `overlay-runtime.ts` — overlay window selection and modal state management - `subsync-runtime.ts` — subsync command orchestration +- `runtime/composers/anilist-tracking-composer.ts` — AniList media tracking/probe/retry wiring +- `runtime/composers/mpv-runtime-composer.ts` — MPV event/factory/tokenizer/warmup wiring This keeps side effects explicit and makes behavior easy to unit-test with fakes. @@ -228,7 +233,7 @@ flowchart TD ## Extension Rules -- Add behavior to an existing service in `src/core/services/*` or create a new focused module in `src/main/` for composition-level logic — not as ad-hoc logic in `main.ts`. +- Add behavior to an existing service in `src/core/services/*` or create a focused composition module in `src/main/` / `src/main/runtime/composers/` — not as ad-hoc logic in `main.ts`. - Keep service APIs explicit and narrowly scoped. - Prefer additive changes that preserve existing CLI flags and IPC channel behavior. - Add/update unit tests for each service extraction or behavior change. diff --git a/docs/development.md b/docs/development.md index 2993129..7f3af99 100644 --- a/docs/development.md +++ b/docs/development.md @@ -115,7 +115,7 @@ Run `make help` for a full list of targets. Key ones: - To add or change a config option, update `src/config/definitions.ts` first. Defaults, runtime-option metadata, and generated `config.example.jsonc` are derived from this centralized source. - Overlay window/visibility state is owned by `src/core/services/overlay-manager.ts`. -- Main process composition is now split across `src/main/` modules (`startup.ts`, `app-lifecycle.ts`, `startup-lifecycle.ts`, `state.ts`, `ipc-runtime.ts`, `cli-runtime.ts`, `overlay-runtime.ts`, `subsync-runtime.ts`). +- Main process composition is split across `src/main/` modules plus focused runtime composers under `src/main/runtime/composers/*` (for example AniList tracking and MPV runtime assembly clusters). - Runtime domain imports for `src/main.ts` should route through `src/main/runtime/domains/*`; shared domain access point is `src/main/runtime/registry.ts`. - Linux packaged desktop launches pass `--background` using electron-builder `build.linux.executableArgs` in `package.json`. - MPV service has been split into transport, protocol, state, and properties layers in `src/core/services/`. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index b867998..3a42862 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -31,3 +31,4 @@ Read first. Keep concise. | `opencode-task95-config-20260221T031843Z-m4k9` | `opencode-task95-config` | `Implement TASK-95 config extraction for src/config/service.ts` | `done` | `docs/subagents/agents/opencode-task95-config-20260221T031843Z-m4k9.md` | `2026-02-21T03:26:57Z` | | `codex-task95-anki-20260221T031836Z-6f3e` | `codex-task95-anki` | `Implement TASK-95 anki-integration extraction for field-grouping merge collaborator` | `done` | `docs/subagents/agents/codex-task95-anki-20260221T031836Z-6f3e.md` | `2026-02-21T03:26:55Z` | | `opencode-task-94-20260221T033647Z-7ou2` | `opencode-task-94` | `Finish TASK-94 thin composition root refactor and close acceptance criteria` | `done` | `docs/subagents/agents/opencode-task-94-20260221T033647Z-7ou2.md` | `2026-02-21T04:12:45Z` | +| `codex-task71-round2-20260221T043541Z-k9t3` | `codex-task71-round2` | `Execute TASK-71 round 2 split of main.ts into domain runtime modules` | `done` | `docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md` | `2026-02-21T04:57:00Z` | diff --git a/docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md b/docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md new file mode 100644 index 0000000..67190e3 --- /dev/null +++ b/docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md @@ -0,0 +1,46 @@ +# Agent: codex-task71-round2-20260221T043541Z-k9t3 + +- alias: codex-task71-round2 +- mission: Execute TASK-71 round 2 by further splitting main.ts wiring into domain runtime modules with focused tests/docs updates +- status: done +- branch: main +- started_at: 2026-02-21T04:35:41Z +- heartbeat_minutes: 5 + +## Current Work (newest first) + +- [2026-02-21T04:57:00Z] completed: TASK-71 moved to Done in Backlog MCP; AC + DoD checked; notes/final summary recorded. +- [2026-02-21T04:55:00Z] verify: `bun run build`, `bun run check:main-fanin:strict`, `bun run test:fast` all passing. +- [2026-02-21T04:52:00Z] progress: added `src/main/runtime/composers/index.ts` barrel and rewired `src/main.ts` composer imports to recover strict fan-in gate (85 lines / 10 unique paths). +- [2026-02-21T04:49:00Z] progress: rewired `src/main.ts` AniList + MPV assembly clusters to `composeAnilistTrackingHandlers` + `composeMpvRuntimeHandlers`; added architecture/development docs updates. +- [2026-02-21T04:44:00Z] progress: parallel subagents delivered new composers + seam tests for AniList tracking and MPV runtime clusters. +- [2026-02-21T04:35:41Z] intent: load TASK-71 context from Backlog MCP, create plan via writing-plans, execute via executing-plans, no commit. +- [2026-02-21T04:35:41Z] planned files: `src/main.ts`, `src/main/runtime/composers/*`, `src/main/runtime/domains/*`, focused tests, `docs/architecture.md`, Backlog TASK-71 metadata via MCP tools only. +- [2026-02-21T04:35:41Z] assumptions: existing dirty tree includes prior TASK-94/TASK-95 work; preserve unrelated edits. + +## Files Touched + +- `docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md` +- `docs/subagents/INDEX.md` +- `docs/subagents/collaboration.md` +- `docs/plans/2026-02-21-task-71-round2-main-runtime-modules.md` +- `src/main.ts` +- `src/main/runtime/composers/anilist-tracking-composer.ts` +- `src/main/runtime/composers/anilist-tracking-composer.test.ts` +- `src/main/runtime/composers/mpv-runtime-composer.ts` +- `src/main/runtime/composers/mpv-runtime-composer.test.ts` +- `src/main/runtime/composers/index.ts` +- `docs/architecture.md` +- `docs/development.md` + +## Assumptions + +- User requested direct plan+execution flow; no extra approval gate needed. + +## Open Questions / Blockers + +- none + +## Next Step + +- Handoff complete; await user direction (optional commit/push). diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 2b0b1ba..6e5958d 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -29,3 +29,5 @@ Shared notes. Append-only. - [2026-02-21T03:36:58Z] [opencode-task-94-20260221T033647Z-7ou2|opencode-task-94] starting TASK-94 finish pass: pull backlog context, write+execute plan via writing-plans/executing-plans, and close remaining AC without commit. - [2026-02-21T04:11:57Z] [opencode-task-94-20260221T033647Z-7ou2|opencode-task-94] extracted IPC/shortcuts/startup-lifecycle/app-ready clusters behind composer modules, rewired `src/main.ts`, added focused composer tests, and revalidated build + `check:main-fanin` + `test:core:dist`. - [2026-02-21T04:12:45Z] [opencode-task-94-20260221T033647Z-7ou2|opencode-task-94] TASK-94 finalized in Backlog MCP: AC checklist complete, notes+final summary recorded, status moved to Done. +- [2026-02-21T04:35:41Z] [codex-task71-round2-20260221T043541Z-k9t3|codex-task71-round2] overlap note: starting TASK-71 round 2 follow-up on `src/main.ts` + `src/main/runtime/composers/*` + docs; preserving prior TASK-94/TASK-95 edits. +- [2026-02-21T04:57:00Z] [codex-task71-round2-20260221T043541Z-k9t3|codex-task71-round2] completed TASK-71: extracted AniList tracking + MPV runtime composition into new composers, added seam tests, rewired `main.ts` + composer barrel, strict fan-in green, and finalized Backlog task as Done. diff --git a/src/main.ts b/src/main.ts index f2bfe59..f3ce71a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -425,12 +425,16 @@ import { } from './config'; import { resolveConfigDir } from './config/path-resolution'; import { createMainRuntimeRegistry } from './main/runtime/registry'; -import { composeAnilistSetupHandlers } from './main/runtime/composers/anilist-setup-composer'; -import { composeJellyfinRemoteHandlers } from './main/runtime/composers/jellyfin-remote-composer'; -import { composeIpcRuntimeHandlers } from './main/runtime/composers/ipc-runtime-composer'; -import { composeShortcutRuntimes } from './main/runtime/composers/shortcuts-runtime-composer'; -import { composeStartupLifecycleHandlers } from './main/runtime/composers/startup-lifecycle-composer'; -import { composeAppReadyRuntime } from './main/runtime/composers/app-ready-composer'; +import { + composeAnilistSetupHandlers, + composeAnilistTrackingHandlers, + composeAppReadyRuntime, + composeIpcRuntimeHandlers, + composeJellyfinRemoteHandlers, + composeMpvRuntimeHandlers, + composeShortcutRuntimes, + composeStartupLifecycleHandlers, +} from './main/runtime/composers'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -1496,8 +1500,19 @@ function openJellyfinSetupWindow(): void { createOpenJellyfinSetupWindowHandler(buildOpenJellyfinSetupWindowMainDepsHandler())(); } -const buildRefreshAnilistClientSecretStateMainDepsHandler = - createBuildRefreshAnilistClientSecretStateMainDepsHandler({ +const { + refreshAnilistClientSecretState, + getCurrentAnilistMediaKey, + resetAnilistMediaTracking, + getAnilistMediaGuessRuntimeState, + setAnilistMediaGuessRuntimeState, + resetAnilistMediaGuessState, + maybeProbeAnilistDuration, + ensureAnilistMediaGuess, + processNextAnilistRetryUpdate, + maybeRunAnilistPostWatchUpdate, +} = composeAnilistTrackingHandlers({ + refreshClientSecretMainDeps: { getResolvedConfig: () => getResolvedConfig(), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), getCachedAccessToken: () => anilistCachedAccessToken, @@ -1519,24 +1534,11 @@ const buildRefreshAnilistClientSecretStateMainDepsHandler = openAnilistSetupWindow(); }, now: () => Date.now(), - }); -const refreshAnilistClientSecretStateMainDeps = - buildRefreshAnilistClientSecretStateMainDepsHandler(); -const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler( - refreshAnilistClientSecretStateMainDeps, -); - -const buildGetCurrentAnilistMediaKeyMainDepsHandler = - createBuildGetCurrentAnilistMediaKeyMainDepsHandler({ + }, + getCurrentMediaKeyMainDeps: { getCurrentMediaPath: () => appState.currentMediaPath, - }); -const getCurrentAnilistMediaKeyMainDeps = buildGetCurrentAnilistMediaKeyMainDepsHandler(); -const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler( - getCurrentAnilistMediaKeyMainDeps, -); - -const buildResetAnilistMediaTrackingMainDepsHandler = - createBuildResetAnilistMediaTrackingMainDepsHandler({ + }, + resetMediaTrackingMainDeps: { setMediaKey: (value) => { anilistCurrentMediaKey = value; }, @@ -1552,28 +1554,15 @@ const buildResetAnilistMediaTrackingMainDepsHandler = setLastDurationProbeAtMs: (value) => { anilistLastDurationProbeAtMs = value; }, - }); -const resetAnilistMediaTrackingMainDeps = buildResetAnilistMediaTrackingMainDepsHandler(); -const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler( - resetAnilistMediaTrackingMainDeps, -); - -const buildGetAnilistMediaGuessRuntimeStateMainDepsHandler = - createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({ + }, + getMediaGuessRuntimeStateMainDeps: { getMediaKey: () => anilistCurrentMediaKey, getMediaDurationSec: () => anilistCurrentMediaDurationSec, getMediaGuess: () => anilistCurrentMediaGuess, getMediaGuessPromise: () => anilistCurrentMediaGuessPromise, getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs, - }); -const getAnilistMediaGuessRuntimeStateMainDeps = - buildGetAnilistMediaGuessRuntimeStateMainDepsHandler(); -const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler( - getAnilistMediaGuessRuntimeStateMainDeps, -); - -const buildSetAnilistMediaGuessRuntimeStateMainDepsHandler = - createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({ + }, + setMediaGuessRuntimeStateMainDeps: { setMediaKey: (value) => { anilistCurrentMediaKey = value; }, @@ -1589,29 +1578,16 @@ const buildSetAnilistMediaGuessRuntimeStateMainDepsHandler = setLastDurationProbeAtMs: (value) => { anilistLastDurationProbeAtMs = value; }, - }); -const setAnilistMediaGuessRuntimeStateMainDeps = - buildSetAnilistMediaGuessRuntimeStateMainDepsHandler(); -const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler( - setAnilistMediaGuessRuntimeStateMainDeps, -); - -const buildResetAnilistMediaGuessStateMainDepsHandler = - createBuildResetAnilistMediaGuessStateMainDepsHandler({ + }, + resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { anilistCurrentMediaGuess = value; }, setMediaGuessPromise: (value) => { anilistCurrentMediaGuessPromise = value; }, - }); -const resetAnilistMediaGuessStateMainDeps = buildResetAnilistMediaGuessStateMainDepsHandler(); -const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( - resetAnilistMediaGuessStateMainDeps, -); - -const buildMaybeProbeAnilistDurationMainDepsHandler = - createBuildMaybeProbeAnilistDurationMainDepsHandler({ + }, + maybeProbeDurationMainDeps: { getState: () => getAnilistMediaGuessRuntimeState(), setState: (state) => { setAnilistMediaGuessRuntimeState(state); @@ -1620,14 +1596,8 @@ const buildMaybeProbeAnilistDurationMainDepsHandler = now: () => Date.now(), requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), logWarn: (message, error) => logger.warn(message, error), - }); -const maybeProbeAnilistDurationMainDeps = buildMaybeProbeAnilistDurationMainDepsHandler(); -const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler( - maybeProbeAnilistDurationMainDeps, -); - -const buildEnsureAnilistMediaGuessMainDepsHandler = - createBuildEnsureAnilistMediaGuessMainDepsHandler({ + }, + ensureMediaGuessMainDeps: { getState: () => getAnilistMediaGuessRuntimeState(), setState: (state) => { setAnilistMediaGuessRuntimeState(state); @@ -1637,22 +1607,8 @@ const buildEnsureAnilistMediaGuessMainDepsHandler = getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaTitle: () => appState.currentMediaTitle, guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), - }); -const ensureAnilistMediaGuessMainDeps = buildEnsureAnilistMediaGuessMainDepsHandler(); -const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler( - ensureAnilistMediaGuessMainDeps, -); - -const rememberAnilistAttemptedUpdate = (key: string): void => { - rememberAnilistAttemptedUpdateKey( - anilistAttemptedUpdateKeys, - key, - ANILIST_MAX_ATTEMPTED_UPDATE_KEYS, - ); -}; - -const buildProcessNextAnilistRetryUpdateMainDepsHandler = - createBuildProcessNextAnilistRetryUpdateMainDepsHandler({ + }, + processNextRetryUpdateMainDeps: { nextReady: () => anilistUpdateQueue.nextReady(), refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), setLastAttemptAt: (value) => { @@ -1675,14 +1631,8 @@ const buildProcessNextAnilistRetryUpdateMainDepsHandler = }, logInfo: (message) => logger.info(message), now: () => Date.now(), - }); -const processNextAnilistRetryUpdateMainDeps = buildProcessNextAnilistRetryUpdateMainDepsHandler(); -const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler( - processNextAnilistRetryUpdateMainDeps, -); - -const buildMaybeRunAnilistPostWatchUpdateMainDepsHandler = - createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({ + }, + maybeRunPostWatchUpdateMainDeps: { getInFlight: () => anilistUpdateInFlight, setInFlight: (value) => { anilistUpdateInFlight = value; @@ -1721,11 +1671,16 @@ const buildMaybeRunAnilistPostWatchUpdateMainDepsHandler = logWarn: (message) => logger.warn(message), minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, minWatchRatio: ANILIST_UPDATE_MIN_WATCH_RATIO, - }); -const maybeRunAnilistPostWatchUpdateMainDeps = buildMaybeRunAnilistPostWatchUpdateMainDepsHandler(); -const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler( - maybeRunAnilistPostWatchUpdateMainDeps, -); + }, +}); + +const rememberAnilistAttemptedUpdate = (key: string): void => { + rememberAnilistAttemptedUpdateKey( + anilistAttemptedUpdateKeys, + key, + ANILIST_MAX_ATTEMPTED_UPDATE_KEYS, + ); +}; const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePositionMainDepsHandler({ loadSubtitlePositionCore: () => @@ -2043,8 +1998,16 @@ function handleInitialArgs(): void { handleInitialArgsRuntimeHandler(); } -const buildBindMpvMainEventHandlersMainDepsHandler = - createBuildBindMpvMainEventHandlersMainDepsHandler({ +const { + bindMpvClientEventHandlers, + createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, + updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, + tokenizeSubtitle, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startBackgroundWarmups, +} = composeMpvRuntimeHandlers, SubtitleData>({ + bindMpvMainEventHandlersMainDeps: { appState, getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, scheduleQuitCheck: (callback) => { @@ -2090,15 +2053,9 @@ const buildBindMpvMainEventHandlersMainDepsHandler = updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); }, - }); -const bindMpvMainEventHandlersMainDeps = buildBindMpvMainEventHandlersMainDepsHandler(); -const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( - bindMpvMainEventHandlersMainDeps, -); - -const buildMpvClientRuntimeServiceFactoryMainDepsHandler = - createBuildMpvClientRuntimeServiceFactoryDepsHandler({ - createClient: MpvIpcClient, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: MpvIpcClient as unknown as new (socketPath: string, options: unknown) => unknown, getSocketPath: () => appState.mpvSocketPath, getResolvedConfig: () => getResolvedConfig(), isAutoStartOverlayEnabled: () => appState.autoStartOverlay, @@ -2110,17 +2067,8 @@ const buildMpvClientRuntimeServiceFactoryMainDepsHandler = setReconnectTimer: (timer: ReturnType | null) => { appState.reconnectTimer = timer; }, - bindEventHandlers: (client) => bindMpvClientEventHandlers(client), - }); - -function createMpvClientRuntimeService(): MpvIpcClient { - return createMpvClientRuntimeServiceFactory( - buildMpvClientRuntimeServiceFactoryMainDepsHandler(), - )(); -} - -const buildUpdateMpvSubtitleRenderMetricsMainDepsHandler = - createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler({ + }, + updateMpvSubtitleRenderMetricsMainDeps: { getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, setCurrentMetrics: (metrics) => { appState.mpvSubtitleRenderMetrics = metrics; @@ -2129,109 +2077,83 @@ const buildUpdateMpvSubtitleRenderMetricsMainDepsHandler = broadcastMetrics: (metrics) => { broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); }, - }); -const updateMpvSubtitleRenderMetricsMainDeps = buildUpdateMpvSubtitleRenderMetricsMainDepsHandler(); -const updateMpvSubtitleRenderMetricsRuntime = createUpdateMpvSubtitleRenderMetricsHandler( - updateMpvSubtitleRenderMetricsMainDeps, -); - -function updateMpvSubtitleRenderMetrics(patch: Partial): void { - updateMpvSubtitleRenderMetricsRuntime(patch); -} - -const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({ - getYomitanExt: () => appState.yomitanExt, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window as BrowserWindow | null; }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => appState.yomitanExt, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), + recordLookup: (hit) => { + appState.immersionTracker?.recordLookup(hit); + }, + getKnownWordMatchMode: () => + appState.ankiIntegration?.getKnownWordMatchMode() ?? + getResolvedConfig().ankiConnect.nPlusOne.matchMode, + getMinSentenceWordsForNPlusOne: () => + getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, + getJlptLevel: (text) => appState.jlptLevelLookup(text), + getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + getFrequencyDictionaryEnabled: () => + getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getFrequencyRank: (text) => appState.frequencyRankLookup(text), + getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => appState.mecabTokenizer, + }, + createTokenizerRuntimeDeps: (deps) => + createTokenizerDepsRuntime(deps as Parameters[0]), + tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + appState.mecabTokenizer = tokenizer as MecabTokenizer | null; + }, + createMecabTokenizer: () => new MecabTokenizer(), + checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => + frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), + }, }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => Date.now(), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), + shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect, + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + }, }, - isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), - recordLookup: (hit) => { - appState.immersionTracker?.recordLookup(hit); - }, - getKnownWordMatchMode: () => - appState.ankiIntegration?.getKnownWordMatchMode() ?? - getResolvedConfig().ankiConnect.nPlusOne.matchMode, - getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, - getJlptLevel: (text) => appState.jlptLevelLookup(text), - getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, - getFrequencyDictionaryEnabled: () => - getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - getFrequencyRank: (text) => appState.frequencyRankLookup(text), - getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, - getMecabTokenizer: () => appState.mecabTokenizer, }); -const buildCreateMecabTokenizerAndCheckMainDepsHandler = - createCreateMecabTokenizerAndCheckMainHandler({ - getMecabTokenizer: () => appState.mecabTokenizer, - setMecabTokenizer: (tokenizer) => { - appState.mecabTokenizer = tokenizer; - }, - createMecabTokenizer: () => new MecabTokenizer(), - checkAvailability: async (tokenizer) => tokenizer.checkAvailability(), - }); -const createMecabTokenizerAndCheckHandler = buildCreateMecabTokenizerAndCheckMainDepsHandler; - -const buildPrewarmSubtitleDictionariesMainDepsHandler = - createPrewarmSubtitleDictionariesMainHandler({ - ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), - ensureFrequencyDictionaryLookup: () => - frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), - }); -const prewarmSubtitleDictionariesHandler = buildPrewarmSubtitleDictionariesMainDepsHandler; - -async function tokenizeSubtitle(text: string): Promise { - await jlptDictionaryRuntime.ensureJlptDictionaryLookup(); - await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(); - return tokenizeSubtitleCore(text, createTokenizerDepsRuntime(buildTokenizerDepsHandler())); +function createMpvClientRuntimeService(): MpvIpcClient { + return createMpvClientRuntimeServiceHandler() as MpvIpcClient; } -async function createMecabTokenizerAndCheck(): Promise { - await createMecabTokenizerAndCheckHandler(); +function updateMpvSubtitleRenderMetrics(patch: Partial): void { + updateMpvSubtitleRenderMetricsHandler(patch); } -async function prewarmSubtitleDictionaries(): Promise { - await prewarmSubtitleDictionariesHandler(); -} - -const buildLaunchBackgroundWarmupTaskMainDepsHandler = - createBuildLaunchBackgroundWarmupTaskMainDepsHandler({ - now: () => Date.now(), - logDebug: (message) => logger.debug(message), - logWarn: (message) => logger.warn(message), - }); -const launchBackgroundWarmupTaskMainDeps = buildLaunchBackgroundWarmupTaskMainDepsHandler(); -const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler( - launchBackgroundWarmupTaskMainDeps, -); - -const buildStartBackgroundWarmupsMainDepsHandler = createBuildStartBackgroundWarmupsMainDepsHandler( - { - getStarted: () => backgroundWarmupsStarted, - setStarted: (started) => { - backgroundWarmupsStarted = started; - }, - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - launchTask: (label, task) => launchBackgroundWarmupTask(label, task), - createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), - prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), - shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect, - startJellyfinRemoteSession: () => startJellyfinRemoteSession(), - }, -); -const startBackgroundWarmupsMainDeps = buildStartBackgroundWarmupsMainDepsHandler(); -const startBackgroundWarmups = createStartBackgroundWarmupsHandler(startBackgroundWarmupsMainDeps); - const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ setOverlayWindowBounds: (layer, geometry) => diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts new file mode 100644 index 0000000..c21925a --- /dev/null +++ b/src/main/runtime/composers/anilist-tracking-composer.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater'; +import { composeAnilistTrackingHandlers } from './anilist-tracking-composer'; + +test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => { + const refreshSavedTokens: string[] = []; + let refreshCachedToken: string | null = null; + + let mediaKeyState: string | null = 'media-key'; + let mediaDurationSecState: number | null = null; + let mediaGuessState: AnilistMediaGuess | null = null; + let mediaGuessPromiseState: Promise | null = null; + let lastDurationProbeAtMsState = 0; + let requestMpvDurationCalls = 0; + let guessAnilistMediaInfoCalls = 0; + + let retryUpdateCalls = 0; + let maybeRunUpdateCalls = 0; + + const composed = composeAnilistTrackingHandlers({ + refreshClientSecretMainDeps: { + getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => refreshCachedToken, + setCachedAccessToken: (token) => { + refreshCachedToken = token; + }, + saveStoredToken: (token) => { + refreshSavedTokens.push(token); + }, + loadStoredToken: () => null, + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 100, + }, + getCurrentMediaKeyMainDeps: { + getCurrentMediaPath: () => ' media-key ', + }, + resetMediaTrackingMainDeps: { + setMediaKey: (value) => { + mediaKeyState = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSecState = value; + }, + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMsState = value; + }, + }, + getMediaGuessRuntimeStateMainDeps: { + getMediaKey: () => mediaKeyState, + getMediaDurationSec: () => mediaDurationSecState, + getMediaGuess: () => mediaGuessState, + getMediaGuessPromise: () => mediaGuessPromiseState, + getLastDurationProbeAtMs: () => lastDurationProbeAtMsState, + }, + setMediaGuessRuntimeStateMainDeps: { + setMediaKey: (value) => { + mediaKeyState = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSecState = value; + }, + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMsState = value; + }, + }, + resetMediaGuessStateMainDeps: { + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + }, + maybeProbeDurationMainDeps: { + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + durationRetryIntervalMs: 0, + now: () => 1000, + requestMpvDuration: async () => { + requestMpvDurationCalls += 1; + return 120; + }, + logWarn: () => {}, + }, + ensureMediaGuessMainDeps: { + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => '/tmp/media.mkv', + getCurrentMediaTitle: () => 'Episode title', + guessAnilistMediaInfo: async () => { + guessAnilistMediaInfoCalls += 1; + return { title: 'Episode title', episode: 7, source: 'guessit' }; + }, + }, + processNextRetryUpdateMainDeps: { + nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }), + refreshRetryQueueState: () => {}, + setLastAttemptAt: () => {}, + setLastError: () => {}, + refreshAnilistClientSecretState: async () => 'retry-token', + updateAnilistPostWatchProgress: async () => { + retryUpdateCalls += 1; + return { status: 'updated', message: 'ok' }; + }, + markSuccess: () => {}, + rememberAttemptedUpdateKey: () => {}, + markFailure: () => {}, + logInfo: () => {}, + now: () => 1, + }, + maybeRunPostWatchUpdateMainDeps: { + getInFlight: () => false, + setInFlight: () => {}, + getResolvedConfig: () => ({ tracking: true }), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => 'media-key', + hasMpvClient: () => true, + getTrackedMediaKey: () => 'media-key', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 500, + maybeProbeAnilistDuration: async () => 600, + ensureAnilistMediaGuess: async () => ({ + title: 'Episode title', + episode: 2, + source: 'guessit', + }), + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + refreshAnilistClientSecretState: async () => 'run-token', + enqueueRetry: () => {}, + markRetryFailure: () => {}, + markRetrySuccess: () => {}, + refreshRetryQueueState: () => {}, + updateAnilistPostWatchProgress: async () => { + maybeRunUpdateCalls += 1; + return { status: 'updated', message: 'updated from maybeRun' }; + }, + rememberAttemptedUpdateKey: () => {}, + showMpvOsd: () => {}, + logInfo: () => {}, + logWarn: () => {}, + minWatchSeconds: 10, + minWatchRatio: 0.5, + }, + }); + + assert.equal(typeof composed.refreshAnilistClientSecretState, 'function'); + assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function'); + assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); + assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); + assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); + assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); + assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function'); + assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function'); + + const refreshed = await composed.refreshAnilistClientSecretState({ force: true }); + assert.equal(refreshed, 'refresh-token'); + assert.deepEqual(refreshSavedTokens, ['refresh-token']); + + assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key'); + composed.resetAnilistMediaTracking('next-key'); + assert.equal(mediaKeyState, 'next-key'); + assert.equal(mediaDurationSecState, null); + + composed.setAnilistMediaGuessRuntimeState({ + mediaKey: 'media-key', + mediaDurationSec: 90, + mediaGuess: { title: 'Known', episode: 3, source: 'fallback' }, + mediaGuessPromise: null, + lastDurationProbeAtMs: 11, + }); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); + + composed.resetAnilistMediaGuessState(); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); + + mediaKeyState = 'media-key'; + mediaDurationSecState = null; + const probedDuration = await composed.maybeProbeAnilistDuration('media-key'); + assert.equal(probedDuration, 120); + assert.equal(requestMpvDurationCalls, 1); + + mediaGuessState = null; + await composed.ensureAnilistMediaGuess('media-key'); + assert.equal(guessAnilistMediaInfoCalls, 1); + + const retryResult = await composed.processNextAnilistRetryUpdate(); + assert.deepEqual(retryResult, { ok: true, message: 'ok' }); + assert.equal(retryUpdateCalls, 1); + + await composed.maybeRunAnilistPostWatchUpdate(); + assert.equal(maybeRunUpdateCalls, 1); +}); diff --git a/src/main/runtime/composers/anilist-tracking-composer.ts b/src/main/runtime/composers/anilist-tracking-composer.ts new file mode 100644 index 0000000..e7c0c56 --- /dev/null +++ b/src/main/runtime/composers/anilist-tracking-composer.ts @@ -0,0 +1,128 @@ +import { + createBuildEnsureAnilistMediaGuessMainDepsHandler, + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, + createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildMaybeProbeAnilistDurationMainDepsHandler, + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, + createBuildProcessNextAnilistRetryUpdateMainDepsHandler, + createBuildRefreshAnilistClientSecretStateMainDepsHandler, + createBuildResetAnilistMediaGuessStateMainDepsHandler, + createBuildResetAnilistMediaTrackingMainDepsHandler, + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, + createEnsureAnilistMediaGuessHandler, + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createMaybeProbeAnilistDurationHandler, + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, + createRefreshAnilistClientSecretStateHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from '../domains/anilist'; + +export type AnilistTrackingComposerOptions = { + refreshClientSecretMainDeps: Parameters< + typeof createBuildRefreshAnilistClientSecretStateMainDepsHandler + >[0]; + getCurrentMediaKeyMainDeps: Parameters< + typeof createBuildGetCurrentAnilistMediaKeyMainDepsHandler + >[0]; + resetMediaTrackingMainDeps: Parameters< + typeof createBuildResetAnilistMediaTrackingMainDepsHandler + >[0]; + getMediaGuessRuntimeStateMainDeps: Parameters< + typeof createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler + >[0]; + setMediaGuessRuntimeStateMainDeps: Parameters< + typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler + >[0]; + resetMediaGuessStateMainDeps: Parameters< + typeof createBuildResetAnilistMediaGuessStateMainDepsHandler + >[0]; + maybeProbeDurationMainDeps: Parameters< + typeof createBuildMaybeProbeAnilistDurationMainDepsHandler + >[0]; + ensureMediaGuessMainDeps: Parameters[0]; + processNextRetryUpdateMainDeps: Parameters< + typeof createBuildProcessNextAnilistRetryUpdateMainDepsHandler + >[0]; + maybeRunPostWatchUpdateMainDeps: Parameters< + typeof createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler + >[0]; +}; + +export type AnilistTrackingComposerResult = { + refreshAnilistClientSecretState: ReturnType; + getCurrentAnilistMediaKey: ReturnType; + resetAnilistMediaTracking: ReturnType; + getAnilistMediaGuessRuntimeState: ReturnType< + typeof createGetAnilistMediaGuessRuntimeStateHandler + >; + setAnilistMediaGuessRuntimeState: ReturnType< + typeof createSetAnilistMediaGuessRuntimeStateHandler + >; + resetAnilistMediaGuessState: ReturnType; + maybeProbeAnilistDuration: ReturnType; + ensureAnilistMediaGuess: ReturnType; + processNextAnilistRetryUpdate: ReturnType; + maybeRunAnilistPostWatchUpdate: ReturnType; +}; + +export function composeAnilistTrackingHandlers( + options: AnilistTrackingComposerOptions, +): AnilistTrackingComposerResult { + const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler( + createBuildRefreshAnilistClientSecretStateMainDepsHandler( + options.refreshClientSecretMainDeps, + )(), + ); + const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler( + createBuildGetCurrentAnilistMediaKeyMainDepsHandler(options.getCurrentMediaKeyMainDeps)(), + ); + const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler( + createBuildResetAnilistMediaTrackingMainDepsHandler(options.resetMediaTrackingMainDeps)(), + ); + const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler( + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler( + options.getMediaGuessRuntimeStateMainDeps, + )(), + ); + const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler( + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler( + options.setMediaGuessRuntimeStateMainDeps, + )(), + ); + const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( + createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(), + ); + const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler( + createBuildMaybeProbeAnilistDurationMainDepsHandler(options.maybeProbeDurationMainDeps)(), + ); + const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler( + createBuildEnsureAnilistMediaGuessMainDepsHandler(options.ensureMediaGuessMainDeps)(), + ); + const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler( + createBuildProcessNextAnilistRetryUpdateMainDepsHandler( + options.processNextRetryUpdateMainDeps, + )(), + ); + const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler( + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler( + options.maybeRunPostWatchUpdateMainDeps, + )(), + ); + + return { + refreshAnilistClientSecretState, + getCurrentAnilistMediaKey, + resetAnilistMediaTracking, + getAnilistMediaGuessRuntimeState, + setAnilistMediaGuessRuntimeState, + resetAnilistMediaGuessState, + maybeProbeAnilistDuration, + ensureAnilistMediaGuess, + processNextAnilistRetryUpdate, + maybeRunAnilistPostWatchUpdate, + }; +} diff --git a/src/main/runtime/composers/index.ts b/src/main/runtime/composers/index.ts new file mode 100644 index 0000000..e9df575 --- /dev/null +++ b/src/main/runtime/composers/index.ts @@ -0,0 +1,8 @@ +export * from './anilist-setup-composer'; +export * from './anilist-tracking-composer'; +export * from './app-ready-composer'; +export * from './ipc-runtime-composer'; +export * from './jellyfin-remote-composer'; +export * from './mpv-runtime-composer'; +export * from './shortcuts-runtime-composer'; +export * from './startup-lifecycle-composer'; diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts new file mode 100644 index 0000000..a6f6706 --- /dev/null +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { MpvSubtitleRenderMetrics } from '../../../types'; +import { composeMpvRuntimeHandlers } from './mpv-runtime-composer'; + +const BASE_METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, +}; + +test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { + const calls: string[] = []; + let started = false; + let metrics = BASE_METRICS; + + class FakeMpvClient { + connected = false; + + constructor( + public socketPath: string, + public options: unknown, + ) { + const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay; + calls.push(`create-client:${socketPath}`); + calls.push(`auto-start:${String(autoStartOverlay)}`); + } + + on(): void {} + + connect(): void { + this.connected = true; + calls.push('client-connect'); + } + } + + const composed = composeMpvRuntimeHandlers< + { isKnownWord: (text: string) => boolean }, + { text: string } + >({ + bindMpvMainEventHandlersMainDeps: { + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + previousSecondarySubVisibility: null, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + updateCurrentMediaPath: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: FakeMpvClient, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({}) as never, + isAutoStartOverlayEnabled: () => true, + setOverlayVisible: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => metrics, + setCurrentMetrics: (next) => { + metrics = next; + calls.push('set-metrics'); + }, + applyPatch: (current, patch) => { + calls.push('apply-metrics-patch'); + return { next: { ...current, ...patch }, changed: true }; + }, + broadcastMetrics: () => { + calls.push('broadcast-metrics'); + }, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: (text) => text === 'known', + recordLookup: () => {}, + getKnownWordMatchMode: () => 'exact', + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => null, + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: () => null, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => null, + }, + createTokenizerRuntimeDeps: (deps) => { + calls.push('create-tokenizer-runtime-deps'); + return { isKnownWord: (text: string) => deps.isKnownWord(text) }; + }, + tokenizeSubtitle: async (text, deps) => { + calls.push(`tokenize:${text}`); + deps.isKnownWord('known'); + return { text }; + }, + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => ({ id: 'mecab' }), + setMecabTokenizer: () => {}, + createMecabTokenizer: () => ({ id: 'mecab' }), + checkAvailability: async () => { + calls.push('check-mecab'); + }, + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: async () => { + calls.push('prewarm-jlpt'); + }, + ensureFrequencyDictionaryLookup: async () => { + calls.push('prewarm-frequency'); + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => 100, + logDebug: () => { + calls.push('warmup-debug'); + }, + logWarn: () => { + calls.push('warmup-warn'); + }, + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => started, + setStarted: (next) => { + started = next; + calls.push(`set-started:${String(next)}`); + }, + isTexthookerOnlyMode: () => false, + ensureYomitanExtensionLoaded: async () => { + calls.push('warmup-yomitan'); + }, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => { + calls.push('warmup-jellyfin'); + }, + }, + }, + }); + + assert.equal(typeof composed.bindMpvClientEventHandlers, 'function'); + assert.equal(typeof composed.createMpvClientRuntimeService, 'function'); + assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function'); + assert.equal(typeof composed.tokenizeSubtitle, 'function'); + assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function'); + assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function'); + assert.equal(typeof composed.launchBackgroundWarmupTask, 'function'); + assert.equal(typeof composed.startBackgroundWarmups, 'function'); + + const client = composed.createMpvClientRuntimeService() as FakeMpvClient; + assert.equal(client.connected, true); + + composed.updateMpvSubtitleRenderMetrics({ subPos: 90 }); + const tokenized = await composed.tokenizeSubtitle('subtitle text'); + await composed.createMecabTokenizerAndCheck(); + await composed.prewarmSubtitleDictionaries(); + composed.startBackgroundWarmups(); + + assert.deepEqual(tokenized, { text: 'subtitle text' }); + assert.equal(metrics.subPos, 90); + assert.ok(calls.includes('create-client:/tmp/mpv.sock')); + assert.ok(calls.includes('auto-start:true')); + assert.ok(calls.includes('client-connect')); + assert.ok(calls.includes('apply-metrics-patch')); + assert.ok(calls.includes('set-metrics')); + assert.ok(calls.includes('broadcast-metrics')); + assert.ok(calls.includes('create-tokenizer-runtime-deps')); + assert.ok(calls.includes('tokenize:subtitle text')); + assert.ok(calls.includes('check-mecab')); + assert.ok(calls.includes('prewarm-jlpt')); + assert.ok(calls.includes('prewarm-frequency')); + assert.ok(calls.includes('set-started:true')); + assert.ok(calls.includes('warmup-yomitan')); +}); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts new file mode 100644 index 0000000..e0a8b66 --- /dev/null +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -0,0 +1,142 @@ +import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings'; +import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps'; +import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps'; +import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service'; +import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from '../mpv-subtitle-render-metrics-main-deps'; +import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics'; +import { + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, +} from '../subtitle-tokenization-main-deps'; +import { + createBuildLaunchBackgroundWarmupTaskMainDepsHandler, + createBuildStartBackgroundWarmupsMainDepsHandler, +} from '../startup-warmups-main-deps'; +import { + createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup, + createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup, +} from '../startup-warmups'; + +type BindMpvMainEventHandlersMainDeps = Parameters< + typeof createBuildBindMpvMainEventHandlersMainDepsHandler +>[0]; +type MpvClientRuntimeServiceFactoryMainDeps = Omit< + Parameters[0], + 'bindEventHandlers' +>; +type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters< + typeof createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler +>[0]; +type BuildTokenizerDepsMainDeps = Parameters[0]; +type TokenizerMainDeps = ReturnType>; +type CreateMecabTokenizerAndCheckMainDeps = Parameters< + typeof createCreateMecabTokenizerAndCheckMainHandler +>[0]; +type PrewarmSubtitleDictionariesMainDeps = Parameters< + typeof createPrewarmSubtitleDictionariesMainHandler +>[0]; +type LaunchBackgroundWarmupTaskMainDeps = Parameters< + typeof createBuildLaunchBackgroundWarmupTaskMainDepsHandler +>[0]; +type StartBackgroundWarmupsMainDeps = Omit< + Parameters[0], + 'launchTask' | 'createMecabTokenizerAndCheck' | 'prewarmSubtitleDictionaries' +>; + +export type MpvRuntimeComposerOptions = { + bindMpvMainEventHandlersMainDeps: BindMpvMainEventHandlersMainDeps; + mpvClientRuntimeServiceFactoryMainDeps: MpvClientRuntimeServiceFactoryMainDeps; + updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps; + tokenizer: { + buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps; + createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps; + tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise; + createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps; + prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps; + }; + warmups: { + launchBackgroundWarmupTaskMainDeps: LaunchBackgroundWarmupTaskMainDeps; + startBackgroundWarmupsMainDeps: StartBackgroundWarmupsMainDeps; + }; +}; + +export type MpvRuntimeComposerResult = { + bindMpvClientEventHandlers: ReturnType; + createMpvClientRuntimeService: () => unknown; + updateMpvSubtitleRenderMetrics: ReturnType; + tokenizeSubtitle: (text: string) => Promise; + createMecabTokenizerAndCheck: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + launchBackgroundWarmupTask: ReturnType; + startBackgroundWarmups: ReturnType; +}; + +export function composeMpvRuntimeHandlers( + options: MpvRuntimeComposerOptions, +): MpvRuntimeComposerResult { + const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler( + options.bindMpvMainEventHandlersMainDeps, + )(); + const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( + bindMpvMainEventHandlersMainDeps, + ); + + const buildMpvClientRuntimeServiceFactoryMainDepsHandler = + createBuildMpvClientRuntimeServiceFactoryDepsHandler({ + ...options.mpvClientRuntimeServiceFactoryMainDeps, + bindEventHandlers: (client) => bindMpvClientEventHandlers(client as never), + }); + const createMpvClientRuntimeService = (): unknown => + createMpvClientRuntimeServiceFactory( + buildMpvClientRuntimeServiceFactoryMainDepsHandler() as never, + )(); + + const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler( + createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler( + options.updateMpvSubtitleRenderMetricsMainDeps, + )(), + ); + + const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler( + options.tokenizer.buildTokenizerDepsMainDeps, + ); + const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler( + options.tokenizer.createMecabTokenizerAndCheckMainDeps, + ); + const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( + options.tokenizer.prewarmSubtitleDictionariesMainDeps, + ); + const tokenizeSubtitle = async (text: string): Promise => { + await prewarmSubtitleDictionaries(); + return options.tokenizer.tokenizeSubtitle( + text, + options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), + ); + }; + + const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup( + createBuildLaunchBackgroundWarmupTaskMainDepsHandler( + options.warmups.launchBackgroundWarmupTaskMainDeps, + )(), + ); + const startBackgroundWarmups = createStartBackgroundWarmupsFromStartup( + createBuildStartBackgroundWarmupsMainDepsHandler({ + ...options.warmups.startBackgroundWarmupsMainDeps, + launchTask: (label, task) => launchBackgroundWarmupTask(label, task), + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + })(), + ); + + return { + bindMpvClientEventHandlers: (client) => bindMpvClientEventHandlers(client), + createMpvClientRuntimeService, + updateMpvSubtitleRenderMetrics: (patch) => updateMpvSubtitleRenderMetrics(patch), + tokenizeSubtitle, + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task), + startBackgroundWarmups: () => startBackgroundWarmups(), + }; +}