22 KiB
Architecture
SubMiner is split into three cooperating runtimes:
- Electron desktop app (
src/) for overlay/UI/runtime orchestration. - Launcher CLI (
launcher/) for mpv/app command workflows. - mpv Lua plugin (
plugin/subminer.lua) for player-side controls and IPC handoff.
Within the desktop app, src/main.ts is a composition root that wires small runtime/domain modules plus core services.
Goals
- Keep behavior stable while reducing coupling.
- Prefer small, single-purpose units that can be tested in isolation.
- Keep
main.tsfocused on wiring and state ownership, not implementation detail. - Follow Unix-style composability:
- each service does one job
- services compose through explicit inputs/outputs
- orchestration is separate from implementation
Project Structure
launcher/ # Standalone CLI launcher wrapper and mpv helpers
commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough)
config/ # Launcher config parsers + CLI parser builder
main.ts # Launcher entrypoint and command dispatch
plugin/
subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls)
src/
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
main.ts # Entry point — delegates to runtime composers/domain modules
preload.ts # Electron preload bridge
types.ts # Shared type definitions
main/ # Main-process composition/runtime adapters
app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
cli-runtime.ts # CLI command runtime service adapters
config-validation.ts # Startup/hot-reload config error formatting and fail-fast helpers
dependencies.ts # Shared dependency builders for IPC/runtime services
ipc-runtime.ts # IPC runtime registration wrappers
overlay-runtime.ts # Overlay modal routing + active-window selection
overlay-shortcuts-runtime.ts # Overlay keyboard shortcut handling
overlay-visibility-runtime.ts # Overlay visibility + tracker-driven bounds service
frequency-dictionary-runtime.ts # Frequency dictionary runtime adapter
jlpt-runtime.ts # JLPT dictionary runtime adapter
media-runtime.ts # Media path/title/subtitle-position runtime service
startup.ts # Startup bootstrap dependency builder
startup-lifecycle.ts # Lifecycle runtime runner adapter
state.ts # Application runtime state container + reducer transitions
subsync-runtime.ts # Subsync command runtime adapter
runtime/
composers/ # High-level composition clusters used by main.ts
domains/ # Domain barrel exports (startup/overlay/mpv/jellyfin/...)
registry.ts # Domain registry consumed by main.ts
core/
services/ # Focused runtime services (Electron adapters + pure logic)
anilist/ # AniList token store/update queue/update helpers
immersion-tracker/ # Immersion persistence/session/metadata modules
tokenizer/ # Tokenizer stage modules (selection/enrichment/annotation)
utils/ # Pure helpers and coercion/config utilities
cli/ # CLI parsing and help output
config/ # Config defaults/definitions, loading, parse, resolution pipeline
definitions/ # Domain-specific defaults + option registries
resolve/ # Domain-specific config resolution pipeline stages
shared/ipc/ # Cross-process IPC channel constants + payload validators
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse interaction modules
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
positioning/ # Invisible-layer layout + offset controllers
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
jimaku/ # Jimaku API integration helpers
subsync/ # Subtitle sync (alass/ffsubsync) helpers
subtitle/ # Subtitle processing utilities
tokenizers/ # Tokenizer implementations
token-mergers/ # Token merge strategies
translators/ # AI translation providers
Service Layer (src/core/services/)
- Overlay/window runtime:
overlay-manager.ts,overlay-window.ts,overlay-window-geometry.ts,overlay-visibility.ts,overlay-bridge.ts,overlay-runtime-init.ts,overlay-content-measurement.ts,overlay-drop.ts - Shortcuts/input:
shortcut.ts,overlay-shortcut.ts,overlay-shortcut-handler.ts,shortcut-fallback.ts,numeric-shortcut.ts - MPV runtime:
mpv.ts,mpv-transport.ts,mpv-protocol.ts,mpv-properties.ts,mpv-render-metrics.ts - Mining + Anki/Jimaku runtime:
mining.ts,field-grouping.ts,field-grouping-overlay.ts,anki-jimaku.ts,anki-jimaku-ipc.ts - Subtitle/token pipeline:
subtitle-processing-controller.ts,subtitle-position.ts,subtitle-ws.ts,tokenizer.ts+tokenizer/*stage modules - Integrations:
jimaku.ts,subsync.ts,subsync-runner.ts,texthooker.ts,jellyfin.ts,jellyfin-remote.ts,discord-presence.ts,yomitan-extension-loader.ts,yomitan-settings.ts - Config/runtime controls:
config-hot-reload.ts,runtime-options-ipc.ts,cli-command.ts,startup.ts - Domain submodules:
anilist/*(token/update queue/updater),immersion-tracker/*(storage/session/metadata/query/reducer)
Renderer Layer (src/renderer/)
The renderer keeps renderer.ts focused on orchestration. UI behavior is delegated to per-concern modules.
src/renderer/
renderer.ts # Entrypoint/orchestration only
context.ts # Shared runtime context contract
state.ts # Centralized renderer mutable state
error-recovery.ts # Global renderer error boundary + recovery actions
overlay-content-measurement.ts # Reports rendered bounds to main process
subtitle-render.ts # Primary/secondary subtitle rendering + style application
positioning.ts # Facade export for positioning controller
positioning/
controller.ts # Position controller orchestration
invisible-layout*.ts # Invisible layer layout computations
position-state.ts # Position state helpers
handlers/
keyboard.ts # Keybindings, chord handling, modal key routing
mouse.ts # Hover/drag behavior, selection + observer wiring
modals/
jimaku.ts # Jimaku modal flow
kiku.ts # Kiku field-grouping modal flow
runtime-options.ts # Runtime options modal flow
session-help.ts # Keyboard shortcuts/help modal flow
subsync.ts # Manual subsync modal flow
utils/
dom.ts # Required DOM lookups + typed handles
platform.ts # Layer/platform capability detection
Launcher + Plugin Runtimes
launcher/main.tsdispatches commands throughlauncher/commands/*and shared config readers inlauncher/config/*. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.plugin/subminer.luaruns inside mpv and handles IPC startup checks, overlay toggles, hover-token messages, and AniSkip intro-skip UX.
Flow Diagram
The main process has three layers: main.ts delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through preload.ts. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
flowchart LR
classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
classDef comp fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef svc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef bridge fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef rend fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
subgraph ExtRt["External Runtimes"]
Launcher["launcher/<br/>CLI dispatch"]:::extrt
Plugin["subminer.lua<br/>mpv plugin"]:::extrt
end
subgraph Ext["External Systems"]
mpvExt["mpv player"]:::ext
AnkiExt["AnkiConnect"]:::ext
JimakuExt["Jimaku API"]:::ext
TrackerExt["Window Tracker<br/>Hyprland · Sway<br/>X11 · macOS"]:::ext
AnilistExt["AniList API"]:::ext
JellyfinExt["Jellyfin"]:::ext
DiscordExt["Discord RPC"]:::ext
end
Main["main.ts<br/>composition root"]:::entry
subgraph Comp["Composition — src/main/"]
Startup["Startup & Lifecycle<br/>startup · app-lifecycle<br/>startup-lifecycle · state"]:::comp
Wiring["Runtime Wiring<br/>ipc-runtime · cli-runtime<br/>overlay-runtime"]:::comp
Composers["Composers<br/>mpv · anilist<br/>jellyfin"]:::comp
end
subgraph Svc["Services — src/core/services/"]
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
end
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
subgraph Rend["Renderer — src/renderer/"]
Visible["Visible window<br/>Yomitan lookups"]:::rend
Invisible["Invisible window<br/>mpv positioning"]:::rend
Secondary["Secondary window<br/>subtitle bar"]:::rend
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
end
Launcher -->|"CLI"| Main
Plugin -->|"IPC"| mpvExt
Main --> Comp
Comp --> Svc
mpvExt <-->|"JSON socket"| Mpv
AnkiExt <-->|"HTTP"| Mining
JimakuExt <-->|"HTTP"| Integrations
TrackerExt <-->|"platform"| Overlay
AnilistExt <-->|"HTTP"| Tracking
JellyfinExt <-->|"HTTP"| Tracking
DiscordExt <-->|"RPC"| Integrations
Overlay & Mining --> Bridge
Bridge --> Visible
Bridge --> Invisible
Bridge --> Secondary
Visible & Invisible & Secondary --> UI
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
style Rend fill:#363a4f,stroke:#494d64,color:#cad3f5
style Ext fill:#363a4f,stroke:#494d64,color:#cad3f5
style ExtRt fill:#363a4f,stroke:#494d64,color:#cad3f5
Composition Pattern
Most runtime code follows a dependency-injection pattern:
- Define a service interface in
src/core/services/*. - Keep core logic in pure or side-effect-bounded functions.
- Build runtime deps in
src/main/composition modules; extract an adapter/helper only when it adds meaningful behavior or reuse. - Call the service from lifecycle/command wiring points.
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 flowapp-lifecycle.ts— Electron lifecycle event registrationstartup-lifecycle.ts— app-ready initialization sequencestate.ts— centralized application runtime state containeripc-runtime.ts— IPC channel registration and handler wiringcli-runtime.ts— CLI command parsing and dispatchoverlay-runtime.ts— overlay window selection and modal state managementsubsync-runtime.ts— subsync command orchestrationruntime/composers/anilist-tracking-composer.ts— AniList media tracking/probe/retry wiringruntime/composers/jellyfin-runtime-composer.ts— Jellyfin config/client/playback/command/setup composition wiringruntime/composers/mpv-runtime-composer.ts— MPV event/factory/tokenizer/warmup wiring
Composer modules share contract conventions via src/main/runtime/composers/contracts.ts:
- composer input surfaces are declared with
ComposerInputs<T>so required dependencies cannot be omitted at compile time - composer outputs are declared with
ComposerOutputs<T>to keep result contracts explicit and stable - builder return payload extraction should use shared type helpers instead of inline ad-hoc inference
This keeps side effects explicit and makes behavior easy to unit-test with fakes.
Additional conventions in the current code:
main.tsusescreateMainRuntimeRegistry()(src/main/runtime/registry.ts) to access domain handlers (startup,overlay,mpv,ipc,shortcuts,anilist,jellyfin,mining) without importing every runtime module directly.- Domain barrels in
src/main/runtime/domains/*re-export runtime handlers + main-deps builders, while composers insrc/main/runtime/composers/*assemble larger runtime clusters. - Many runtime handlers accept
*MainDepsobjects generated bycreateBuild*MainDepsHandlerbuilders to isolate side effects and keep units testable.
IPC Contract + Validation Boundary
- Central channel constants live in
src/shared/ipc/contracts.tsand are consumed by both main (ipcMain) and renderer preload (ipcRenderer) wiring. - Runtime payload parsers/type guards live in
src/shared/ipc/validators.ts. - Rule: renderer-supplied payloads must be validated at IPC entry points (
src/core/services/ipc.ts,src/core/services/anki-jimaku-ipc.ts) before calling domain handlers. - Malformed invoke payloads return explicit structured errors (for example
{ ok: false, error: ... }) and malformed fire-and-forget payloads are ignored safely.
Runtime State Ownership (Migrated Domains)
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
- Composition/runtime modules own mutable state cells and expose narrow
get*/set*accessors. - Domain handlers do not mutate foreign state directly; they call explicit transition helpers that encode invariants.
- Transition helpers may sync derived counters/snapshots, but must preserve non-owned metadata unless the transition explicitly owns that metadata.
- Reducer boundary: when a domain has transition helpers in
src/main/state.ts, new callsites should route updates through those helpers instead of ad-hoc object mutation inmain.tsor composers. - Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
Program Lifecycle
- Module-level init: Before
app.ready, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection.runAndApplyStartupState()parses CLI args and detects the compositor backend. - Startup: If
--generate-configis passed, it writes the template and exits. Otherwiseapp-lifecycle.tsacquires the single-instance lock and registers Electron lifecycle hooks. - Critical-path init: Once
app.whenReady()fires,composeAppReadyRuntime()runs strict config reload, resolves keybindings, creates theMpvIpcClient(which immediately connects and subscribes to 26 properties), and initializes theRuntimeOptionsManager,SubtitleTimingTracker, andImmersionTrackerService. - Overlay runtime:
initializeOverlayRuntime()creates three overlay windows — visible (interactive Yomitan lookups), invisible (mpv-matched subtitle positioning), and secondary (secondary subtitle bar, top 20% viasplitOverlayGeometryForSecondaryBar) — then registers global shortcuts and sets initial bounds from the window tracker. - Background warmups: Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh.
- Runtime: Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through
SubtitlePipeline(normalize → tokenize → merge), and results broadcast to all overlay windows. - Shutdown:
onWillQuitCleanupdestroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state.
flowchart LR
classDef start fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
classDef phase fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef init fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef runtime fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef shutdown fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef warmup fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
CLI["CLI args &<br/>environment"]:::start
CLI --> Proto["Module-level init<br/>register protocols<br/>construct services<br/>wire deps"]:::phase
Proto --> Parse["startup.ts<br/>parse argv<br/>detect backend"]:::phase
Parse --> GenCheck{"--generate<br/>-config?"}:::decision
GenCheck -->|"yes"| GenExit["Write template<br/>& exit"]:::phase
GenCheck -->|"no"| Lock["app-lifecycle.ts<br/>single-instance lock<br/>lifecycle hooks"]:::phase
Lock -->|"app.whenReady()"| Ready["composeAppReady<br/>Runtime()"]:::phase
Ready --> Config["Config reload<br/>keybindings<br/>log level"]:::init
Ready --> MpvInit["MpvIpcClient<br/>connect socket<br/>subscribe 26 props"]:::init
Ready --> Platform["RuntimeOptions<br/>timing tracker<br/>immersion tracker"]:::init
Config --> OverlayInit
MpvInit --> OverlayInit
Platform --> OverlayInit
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
VisWin --> Warmups
InvWin --> Warmups
SecWin --> Warmups
Shortcuts --> Warmups
Warmups["Background<br/>warmups"]:::phase
subgraph WarmupGroup[" "]
direction TB
W1["MeCab"]:::warmup
W2["Yomitan"]:::warmup
W3["JLPT + freq<br/>dictionaries"]:::warmup
W4["Jellyfin"]:::warmup
W5["Discord"]:::warmup
W6["AniList"]:::warmup
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6
end
Warmups --> WarmupGroup
subgraph Loop["Runtime — event-driven"]
direction TB
MpvEvt["mpv events: subtitle · timing · metrics"]:::runtime
IpcEvt["IPC: renderer requests · CLI commands"]:::runtime
ExtEvt["Shortcuts · config hot-reload"]:::runtime
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime
end
WarmupGroup --> Loop
style WarmupGroup fill:transparent,stroke:none
Loop -->|"quit signal"| Quit["will-quit"]:::shutdown
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki · AniList"]:::shutdown
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
Why This Design
- Smaller blast radius: changing one feature usually touches one service.
- Better testability: most behavior can be tested without Electron windows/mpv.
- Better reviewability: PRs can be scoped to one subsystem.
- Backward compatibility: CLI flags and IPC channels can remain stable while internals evolve.
- Runtime registry + domain barrels:
src/main/runtime/registry.tsandsrc/main/runtime/domains/*reduce direct fan-in insidemain.tswhile keeping domain ownership explicit. - Extracted composition root:
main.tsdelegates to focused modules undersrc/main/andsrc/main/runtime/composers/for lifecycle, IPC, overlay, mpv, shortcut, and integration wiring. - Split MPV service layers: MPV internals are separated into transport (
mpv-transport.ts), protocol (mpv-protocol.ts), and properties/render metrics modules for maintainability. - Config by domain: defaults, option registries, and resolution are split by domain under
src/config/definitions/*andsrc/config/resolve/*, keeping config evolution localized.
Extension Rules
- Add behavior to an existing service in
src/core/services/*or create a focused runtime module undersrc/main/runtime/*; avoid ad-hoc logic inmain.ts. - Add new cross-process channels in
src/shared/ipc/contracts.tsfirst, validate payloads insrc/shared/ipc/validators.ts, then wire handlers in IPC runtime modules. - See also the contributor IPC onboarding page: IPC + Runtime Contracts.
- If change spans startup/overlay/mpv/integration wiring, prefer composing through
src/main/runtime/domains/*+src/main/runtime/composers/*rather than direct wiring inmain.ts. - Keep service APIs explicit and narrowly scoped, and preserve existing CLI flag / IPC channel behavior unless the change is intentionally breaking.
- Add or update focused tests (including malformed-payload IPC tests) when runtime boundaries or contracts change.