refactor(main): extract anilist/mpv runtime composers

This commit is contained in:
2026-02-20 21:01:24 -08:00
parent 4fc34ec787
commit f8db9e7119
12 changed files with 967 additions and 225 deletions

View File

@@ -1,10 +1,11 @@
--- ---
id: TASK-71 id: TASK-71
title: Split main.ts into domain runtime modules round 2 title: Split main.ts into domain runtime modules round 2
status: In Progress status: Done
assignee: [] assignee:
- codex
created_date: '2026-02-18 11:35' created_date: '2026-02-18 11:35'
updated_date: '2026-02-21 03:40' updated_date: '2026-02-21 04:57'
labels: labels:
- architecture - architecture
- refactor - refactor
@@ -45,14 +46,48 @@ priority: high
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `src/main.ts` responsibilities reduced to composition/wiring concerns - [x] #1 `src/main.ts` responsibilities reduced to composition/wiring concerns
- [ ] #2 Extracted runtime modules have focused interfaces and isolated tests - [x] #2 Extracted runtime modules have focused interfaces and isolated tests
- [ ] #3 No CLI/IPC regressions in existing test suite - [x] #3 No CLI/IPC regressions in existing test suite
- [ ] #4 Docs reflect new module boundaries - [x] #4 Docs reflect new module boundaries
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 `bun run test:fast` passes - [x] #1 `bun run test:fast` passes
- [ ] #2 Architecture docs updated - [x] #2 Architecture docs updated
<!-- DOD:END --> <!-- DOD:END -->

View File

@@ -16,7 +16,7 @@ SubMiner uses a service-oriented Electron architecture with a composition-orient
```text ```text
src/ 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 preload.ts # Electron preload bridge
types.ts # Shared type definitions types.ts # Shared type definitions
main/ # Composition root modules (extracted from main.ts) main/ # Composition root modules (extracted from main.ts)
@@ -31,6 +31,9 @@ src/
startup-lifecycle.ts # App-ready initialization sequence startup-lifecycle.ts # App-ready initialization sequence
state.ts # Application runtime state container state.ts # Application runtime state container
subsync-runtime.ts # Subsync command orchestration subsync-runtime.ts # Subsync command orchestration
runtime/
composers/ # Composition assembly clusters consumed by main.ts
domains/ # Domain barrel exports for runtime services
core/ core/
services/ # ~60 focused service modules (see below) services/ # ~60 focused service modules (see below)
utils/ # Pure helpers and coercion/config utilities 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. 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. 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 - `startup.ts` — argv/env processing and bootstrap flow
- `app-lifecycle.ts` — Electron lifecycle event registration - `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 - `cli-runtime.ts` — CLI command parsing and dispatch
- `overlay-runtime.ts` — overlay window selection and modal state management - `overlay-runtime.ts` — overlay window selection and modal state management
- `subsync-runtime.ts` — subsync command orchestration - `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. This keeps side effects explicit and makes behavior easy to unit-test with fakes.
@@ -228,7 +233,7 @@ flowchart TD
## Extension Rules ## 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. - Keep service APIs explicit and narrowly scoped.
- Prefer additive changes that preserve existing CLI flags and IPC channel behavior. - Prefer additive changes that preserve existing CLI flags and IPC channel behavior.
- Add/update unit tests for each service extraction or behavior change. - Add/update unit tests for each service extraction or behavior change.

View File

@@ -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. - 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`. - 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`. - 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`. - 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/`. - MPV service has been split into transport, protocol, state, and properties layers in `src/core/services/`.

View File

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

View File

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

View File

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

View File

@@ -425,12 +425,16 @@ import {
} from './config'; } from './config';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createMainRuntimeRegistry } from './main/runtime/registry';
import { composeAnilistSetupHandlers } from './main/runtime/composers/anilist-setup-composer'; import {
import { composeJellyfinRemoteHandlers } from './main/runtime/composers/jellyfin-remote-composer'; composeAnilistSetupHandlers,
import { composeIpcRuntimeHandlers } from './main/runtime/composers/ipc-runtime-composer'; composeAnilistTrackingHandlers,
import { composeShortcutRuntimes } from './main/runtime/composers/shortcuts-runtime-composer'; composeAppReadyRuntime,
import { composeStartupLifecycleHandlers } from './main/runtime/composers/startup-lifecycle-composer'; composeIpcRuntimeHandlers,
import { composeAppReadyRuntime } from './main/runtime/composers/app-ready-composer'; composeJellyfinRemoteHandlers,
composeMpvRuntimeHandlers,
composeShortcutRuntimes,
composeStartupLifecycleHandlers,
} from './main/runtime/composers';
if (process.platform === 'linux') { if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1496,8 +1500,19 @@ function openJellyfinSetupWindow(): void {
createOpenJellyfinSetupWindowHandler(buildOpenJellyfinSetupWindowMainDepsHandler())(); createOpenJellyfinSetupWindowHandler(buildOpenJellyfinSetupWindowMainDepsHandler())();
} }
const buildRefreshAnilistClientSecretStateMainDepsHandler = const {
createBuildRefreshAnilistClientSecretStateMainDepsHandler({ refreshAnilistClientSecretState,
getCurrentAnilistMediaKey,
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate,
} = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
getCachedAccessToken: () => anilistCachedAccessToken, getCachedAccessToken: () => anilistCachedAccessToken,
@@ -1519,24 +1534,11 @@ const buildRefreshAnilistClientSecretStateMainDepsHandler =
openAnilistSetupWindow(); openAnilistSetupWindow();
}, },
now: () => Date.now(), now: () => Date.now(),
}); },
const refreshAnilistClientSecretStateMainDeps = getCurrentMediaKeyMainDeps: {
buildRefreshAnilistClientSecretStateMainDepsHandler();
const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler(
refreshAnilistClientSecretStateMainDeps,
);
const buildGetCurrentAnilistMediaKeyMainDepsHandler =
createBuildGetCurrentAnilistMediaKeyMainDepsHandler({
getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaPath: () => appState.currentMediaPath,
}); },
const getCurrentAnilistMediaKeyMainDeps = buildGetCurrentAnilistMediaKeyMainDepsHandler(); resetMediaTrackingMainDeps: {
const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler(
getCurrentAnilistMediaKeyMainDeps,
);
const buildResetAnilistMediaTrackingMainDepsHandler =
createBuildResetAnilistMediaTrackingMainDepsHandler({
setMediaKey: (value) => { setMediaKey: (value) => {
anilistCurrentMediaKey = value; anilistCurrentMediaKey = value;
}, },
@@ -1552,28 +1554,15 @@ const buildResetAnilistMediaTrackingMainDepsHandler =
setLastDurationProbeAtMs: (value) => { setLastDurationProbeAtMs: (value) => {
anilistLastDurationProbeAtMs = value; anilistLastDurationProbeAtMs = value;
}, },
}); },
const resetAnilistMediaTrackingMainDeps = buildResetAnilistMediaTrackingMainDepsHandler(); getMediaGuessRuntimeStateMainDeps: {
const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler(
resetAnilistMediaTrackingMainDeps,
);
const buildGetAnilistMediaGuessRuntimeStateMainDepsHandler =
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({
getMediaKey: () => anilistCurrentMediaKey, getMediaKey: () => anilistCurrentMediaKey,
getMediaDurationSec: () => anilistCurrentMediaDurationSec, getMediaDurationSec: () => anilistCurrentMediaDurationSec,
getMediaGuess: () => anilistCurrentMediaGuess, getMediaGuess: () => anilistCurrentMediaGuess,
getMediaGuessPromise: () => anilistCurrentMediaGuessPromise, getMediaGuessPromise: () => anilistCurrentMediaGuessPromise,
getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs, getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs,
}); },
const getAnilistMediaGuessRuntimeStateMainDeps = setMediaGuessRuntimeStateMainDeps: {
buildGetAnilistMediaGuessRuntimeStateMainDepsHandler();
const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler(
getAnilistMediaGuessRuntimeStateMainDeps,
);
const buildSetAnilistMediaGuessRuntimeStateMainDepsHandler =
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({
setMediaKey: (value) => { setMediaKey: (value) => {
anilistCurrentMediaKey = value; anilistCurrentMediaKey = value;
}, },
@@ -1589,29 +1578,16 @@ const buildSetAnilistMediaGuessRuntimeStateMainDepsHandler =
setLastDurationProbeAtMs: (value) => { setLastDurationProbeAtMs: (value) => {
anilistLastDurationProbeAtMs = value; anilistLastDurationProbeAtMs = value;
}, },
}); },
const setAnilistMediaGuessRuntimeStateMainDeps = resetMediaGuessStateMainDeps: {
buildSetAnilistMediaGuessRuntimeStateMainDepsHandler();
const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler(
setAnilistMediaGuessRuntimeStateMainDeps,
);
const buildResetAnilistMediaGuessStateMainDepsHandler =
createBuildResetAnilistMediaGuessStateMainDepsHandler({
setMediaGuess: (value) => { setMediaGuess: (value) => {
anilistCurrentMediaGuess = value; anilistCurrentMediaGuess = value;
}, },
setMediaGuessPromise: (value) => { setMediaGuessPromise: (value) => {
anilistCurrentMediaGuessPromise = value; anilistCurrentMediaGuessPromise = value;
}, },
}); },
const resetAnilistMediaGuessStateMainDeps = buildResetAnilistMediaGuessStateMainDepsHandler(); maybeProbeDurationMainDeps: {
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
resetAnilistMediaGuessStateMainDeps,
);
const buildMaybeProbeAnilistDurationMainDepsHandler =
createBuildMaybeProbeAnilistDurationMainDepsHandler({
getState: () => getAnilistMediaGuessRuntimeState(), getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => { setState: (state) => {
setAnilistMediaGuessRuntimeState(state); setAnilistMediaGuessRuntimeState(state);
@@ -1620,14 +1596,8 @@ const buildMaybeProbeAnilistDurationMainDepsHandler =
now: () => Date.now(), now: () => Date.now(),
requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'),
logWarn: (message, error) => logger.warn(message, error), logWarn: (message, error) => logger.warn(message, error),
}); },
const maybeProbeAnilistDurationMainDeps = buildMaybeProbeAnilistDurationMainDepsHandler(); ensureMediaGuessMainDeps: {
const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler(
maybeProbeAnilistDurationMainDeps,
);
const buildEnsureAnilistMediaGuessMainDepsHandler =
createBuildEnsureAnilistMediaGuessMainDepsHandler({
getState: () => getAnilistMediaGuessRuntimeState(), getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => { setState: (state) => {
setAnilistMediaGuessRuntimeState(state); setAnilistMediaGuessRuntimeState(state);
@@ -1637,22 +1607,8 @@ const buildEnsureAnilistMediaGuessMainDepsHandler =
getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle, getCurrentMediaTitle: () => appState.currentMediaTitle,
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
}); },
const ensureAnilistMediaGuessMainDeps = buildEnsureAnilistMediaGuessMainDepsHandler(); processNextRetryUpdateMainDeps: {
const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler(
ensureAnilistMediaGuessMainDeps,
);
const rememberAnilistAttemptedUpdate = (key: string): void => {
rememberAnilistAttemptedUpdateKey(
anilistAttemptedUpdateKeys,
key,
ANILIST_MAX_ATTEMPTED_UPDATE_KEYS,
);
};
const buildProcessNextAnilistRetryUpdateMainDepsHandler =
createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
nextReady: () => anilistUpdateQueue.nextReady(), nextReady: () => anilistUpdateQueue.nextReady(),
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
setLastAttemptAt: (value) => { setLastAttemptAt: (value) => {
@@ -1675,14 +1631,8 @@ const buildProcessNextAnilistRetryUpdateMainDepsHandler =
}, },
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
now: () => Date.now(), now: () => Date.now(),
}); },
const processNextAnilistRetryUpdateMainDeps = buildProcessNextAnilistRetryUpdateMainDepsHandler(); maybeRunPostWatchUpdateMainDeps: {
const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler(
processNextAnilistRetryUpdateMainDeps,
);
const buildMaybeRunAnilistPostWatchUpdateMainDepsHandler =
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({
getInFlight: () => anilistUpdateInFlight, getInFlight: () => anilistUpdateInFlight,
setInFlight: (value) => { setInFlight: (value) => {
anilistUpdateInFlight = value; anilistUpdateInFlight = value;
@@ -1721,11 +1671,16 @@ const buildMaybeRunAnilistPostWatchUpdateMainDepsHandler =
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS,
minWatchRatio: ANILIST_UPDATE_MIN_WATCH_RATIO, minWatchRatio: ANILIST_UPDATE_MIN_WATCH_RATIO,
},
}); });
const maybeRunAnilistPostWatchUpdateMainDeps = buildMaybeRunAnilistPostWatchUpdateMainDepsHandler();
const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler( const rememberAnilistAttemptedUpdate = (key: string): void => {
maybeRunAnilistPostWatchUpdateMainDeps, rememberAnilistAttemptedUpdateKey(
anilistAttemptedUpdateKeys,
key,
ANILIST_MAX_ATTEMPTED_UPDATE_KEYS,
); );
};
const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePositionMainDepsHandler({ const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePositionMainDepsHandler({
loadSubtitlePositionCore: () => loadSubtitlePositionCore: () =>
@@ -2043,8 +1998,16 @@ function handleInitialArgs(): void {
handleInitialArgsRuntimeHandler(); handleInitialArgsRuntimeHandler();
} }
const buildBindMpvMainEventHandlersMainDepsHandler = const {
createBuildBindMpvMainEventHandlersMainDepsHandler({ bindMpvClientEventHandlers,
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
tokenizeSubtitle,
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
} = composeMpvRuntimeHandlers<ReturnType<typeof createTokenizerDepsRuntime>, SubtitleData>({
bindMpvMainEventHandlersMainDeps: {
appState, appState,
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
scheduleQuitCheck: (callback) => { scheduleQuitCheck: (callback) => {
@@ -2090,15 +2053,9 @@ const buildBindMpvMainEventHandlersMainDepsHandler =
updateSubtitleRenderMetrics: (patch) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
}, },
}); },
const bindMpvMainEventHandlersMainDeps = buildBindMpvMainEventHandlersMainDepsHandler(); mpvClientRuntimeServiceFactoryMainDeps: {
const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( createClient: MpvIpcClient as unknown as new (socketPath: string, options: unknown) => unknown,
bindMpvMainEventHandlersMainDeps,
);
const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
createBuildMpvClientRuntimeServiceFactoryDepsHandler({
createClient: MpvIpcClient,
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
isAutoStartOverlayEnabled: () => appState.autoStartOverlay, isAutoStartOverlayEnabled: () => appState.autoStartOverlay,
@@ -2110,17 +2067,8 @@ const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer; appState.reconnectTimer = timer;
}, },
bindEventHandlers: (client) => bindMpvClientEventHandlers(client), },
}); updateMpvSubtitleRenderMetricsMainDeps: {
function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceFactory(
buildMpvClientRuntimeServiceFactoryMainDepsHandler(),
)();
}
const buildUpdateMpvSubtitleRenderMetricsMainDepsHandler =
createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler({
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
setCurrentMetrics: (metrics) => { setCurrentMetrics: (metrics) => {
appState.mpvSubtitleRenderMetrics = metrics; appState.mpvSubtitleRenderMetrics = metrics;
@@ -2129,17 +2077,9 @@ const buildUpdateMpvSubtitleRenderMetricsMainDepsHandler =
broadcastMetrics: (metrics) => { broadcastMetrics: (metrics) => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics);
}, },
}); },
const updateMpvSubtitleRenderMetricsMainDeps = buildUpdateMpvSubtitleRenderMetricsMainDepsHandler(); tokenizer: {
const updateMpvSubtitleRenderMetricsRuntime = createUpdateMpvSubtitleRenderMetricsHandler( buildTokenizerDepsMainDeps: {
updateMpvSubtitleRenderMetricsMainDeps,
);
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
updateMpvSubtitleRenderMetricsRuntime(patch);
}
const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => { setYomitanParserWindow: (window) => {
@@ -2160,7 +2100,8 @@ const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({
getKnownWordMatchMode: () => getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ?? appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode, getResolvedConfig().ankiConnect.nPlusOne.matchMode,
getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text), getJlptLevel: (text) => appState.jlptLevelLookup(text),
getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
getFrequencyDictionaryEnabled: () => getFrequencyDictionaryEnabled: () =>
@@ -2168,69 +2109,50 @@ const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({
getFrequencyRank: (text) => appState.frequencyRankLookup(text), getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
}); },
createTokenizerRuntimeDeps: (deps) =>
const buildCreateMecabTokenizerAndCheckMainDepsHandler = createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
createCreateMecabTokenizerAndCheckMainHandler({ tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
setMecabTokenizer: (tokenizer) => { setMecabTokenizer: (tokenizer) => {
appState.mecabTokenizer = tokenizer; appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
}, },
createMecabTokenizer: () => new MecabTokenizer(), createMecabTokenizer: () => new MecabTokenizer(),
checkAvailability: async (tokenizer) => tokenizer.checkAvailability(), checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
}); },
const createMecabTokenizerAndCheckHandler = buildCreateMecabTokenizerAndCheckMainDepsHandler; prewarmSubtitleDictionariesMainDeps: {
const buildPrewarmSubtitleDictionariesMainDepsHandler =
createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup: () =>
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
}); },
const prewarmSubtitleDictionariesHandler = buildPrewarmSubtitleDictionariesMainDepsHandler; },
warmups: {
async function tokenizeSubtitle(text: string): Promise<SubtitleData> { launchBackgroundWarmupTaskMainDeps: {
await jlptDictionaryRuntime.ensureJlptDictionaryLookup();
await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup();
return tokenizeSubtitleCore(text, createTokenizerDepsRuntime(buildTokenizerDepsHandler()));
}
async function createMecabTokenizerAndCheck(): Promise<void> {
await createMecabTokenizerAndCheckHandler();
}
async function prewarmSubtitleDictionaries(): Promise<void> {
await prewarmSubtitleDictionariesHandler();
}
const buildLaunchBackgroundWarmupTaskMainDepsHandler =
createBuildLaunchBackgroundWarmupTaskMainDepsHandler({
now: () => Date.now(), now: () => Date.now(),
logDebug: (message) => logger.debug(message), logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
}); },
const launchBackgroundWarmupTaskMainDeps = buildLaunchBackgroundWarmupTaskMainDepsHandler(); startBackgroundWarmupsMainDeps: {
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler(
launchBackgroundWarmupTaskMainDeps,
);
const buildStartBackgroundWarmupsMainDepsHandler = createBuildStartBackgroundWarmupsMainDepsHandler(
{
getStarted: () => backgroundWarmupsStarted, getStarted: () => backgroundWarmupsStarted,
setStarted: (started) => { setStarted: (started) => {
backgroundWarmupsStarted = started; backgroundWarmupsStarted = started;
}, },
isTexthookerOnlyMode: () => appState.texthookerOnlyMode, isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}),
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect, shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(), startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
}, },
); },
const startBackgroundWarmupsMainDeps = buildStartBackgroundWarmupsMainDepsHandler(); });
const startBackgroundWarmups = createStartBackgroundWarmupsHandler(startBackgroundWarmupsMainDeps);
function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
}
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
updateMpvSubtitleRenderMetricsHandler(patch);
}
const buildUpdateVisibleOverlayBoundsMainDepsHandler = const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ createBuildUpdateVisibleOverlayBoundsMainDepsHandler({

View File

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

View File

@@ -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<typeof createBuildEnsureAnilistMediaGuessMainDepsHandler>[0];
processNextRetryUpdateMainDeps: Parameters<
typeof createBuildProcessNextAnilistRetryUpdateMainDepsHandler
>[0];
maybeRunPostWatchUpdateMainDeps: Parameters<
typeof createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler
>[0];
};
export type AnilistTrackingComposerResult = {
refreshAnilistClientSecretState: ReturnType<typeof createRefreshAnilistClientSecretStateHandler>;
getCurrentAnilistMediaKey: ReturnType<typeof createGetCurrentAnilistMediaKeyHandler>;
resetAnilistMediaTracking: ReturnType<typeof createResetAnilistMediaTrackingHandler>;
getAnilistMediaGuessRuntimeState: ReturnType<
typeof createGetAnilistMediaGuessRuntimeStateHandler
>;
setAnilistMediaGuessRuntimeState: ReturnType<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>;
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
processNextAnilistRetryUpdate: ReturnType<typeof createProcessNextAnilistRetryUpdateHandler>;
maybeRunAnilistPostWatchUpdate: ReturnType<typeof createMaybeRunAnilistPostWatchUpdateHandler>;
};
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,
};
}

View File

@@ -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';

View File

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

View File

@@ -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<typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler>[0],
'bindEventHandlers'
>;
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<
typeof createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler
>[0];
type BuildTokenizerDepsMainDeps = Parameters<typeof createBuildTokenizerDepsMainHandler>[0];
type TokenizerMainDeps = ReturnType<ReturnType<typeof createBuildTokenizerDepsMainHandler>>;
type CreateMecabTokenizerAndCheckMainDeps = Parameters<
typeof createCreateMecabTokenizerAndCheckMainHandler
>[0];
type PrewarmSubtitleDictionariesMainDeps = Parameters<
typeof createPrewarmSubtitleDictionariesMainHandler
>[0];
type LaunchBackgroundWarmupTaskMainDeps = Parameters<
typeof createBuildLaunchBackgroundWarmupTaskMainDepsHandler
>[0];
type StartBackgroundWarmupsMainDeps = Omit<
Parameters<typeof createBuildStartBackgroundWarmupsMainDepsHandler>[0],
'launchTask' | 'createMecabTokenizerAndCheck' | 'prewarmSubtitleDictionaries'
>;
export type MpvRuntimeComposerOptions<TTokenizerRuntimeDeps, TTokenizedSubtitle> = {
bindMpvMainEventHandlersMainDeps: BindMpvMainEventHandlersMainDeps;
mpvClientRuntimeServiceFactoryMainDeps: MpvClientRuntimeServiceFactoryMainDeps;
updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps;
tokenizer: {
buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps;
createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps;
tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps;
prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps;
};
warmups: {
launchBackgroundWarmupTaskMainDeps: LaunchBackgroundWarmupTaskMainDeps;
startBackgroundWarmupsMainDeps: StartBackgroundWarmupsMainDeps;
};
};
export type MpvRuntimeComposerResult<TTokenizedSubtitle> = {
bindMpvClientEventHandlers: ReturnType<typeof createBindMpvMainEventHandlersHandler>;
createMpvClientRuntimeService: () => unknown;
updateMpvSubtitleRenderMetrics: ReturnType<typeof createUpdateMpvSubtitleRenderMetricsHandler>;
tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>;
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
};
export function composeMpvRuntimeHandlers<TTokenizerRuntimeDeps, TTokenizedSubtitle>(
options: MpvRuntimeComposerOptions<TTokenizerRuntimeDeps, TTokenizedSubtitle>,
): MpvRuntimeComposerResult<TTokenizedSubtitle> {
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<TTokenizedSubtitle> => {
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(),
};
}