Files
SubMiner/docs/architecture
sudacode 2924f04316 refactor(main): move domains into per-domain assemblies (#309)
* refactor(main): move the YouTube domain into an assembly

createYoutubeAssembly owns the YouTube flow, playback, media cache,
whisper subtitles, primary-subtitle notifications, and the in-app
browser, plus the browser quit-on-close state. main.ts builds it once
where the first YouTube runtime used to be and calls through the result.
Siblings built later are passed as getters or callbacks; tests pin that
construction calls none of them.

* docs(architecture): describe per-domain assemblies

* test(e2e): blank the line before mining the sub-delay cue

The previous test leaves the same cue on screen, so the overlay check
passed before the seek landed and --mine-sentence could read mpv's empty
mid-seek sub-text and skip the card.

* refactor(main): move the Jellyfin domain into an assembly

createJellyfinAssembly owns the Jellyfin composer call, its playback and
remote-progress state, the subtitle cache IO, mpv defaults, and the tray
discovery deps. main.ts builds it where the composer call used to be and
reads state through getActivePlayback/getQuitOnDisconnectArmed.

* refactor(main): move the AniList domain into an assembly

createAnilistAssembly owns the AniList setup and tracking composer calls,
the setup window, the state runtime, and the token, media-guess, in-flight
and attempted-update state they share. main.ts builds it where the setup
composer call used to be and keeps the rate limiter, which the stats
cover-art fetcher also uses.

* refactor(main): move config hot-reload, settings, and sync UI into assemblies

createSettingsAssembly owns config hot-reload and the settings window,
which share the hot-reload-applied handler; main.ts still supplies that
handler's cross-domain deps and the Yomitan deck-name helper.
createSyncAssembly owns the sync window, sync IPC runtime, and auto-sync
scheduler. Both are built where their blocks were; main.ts keeps the
registerHandlers()/start() calls at the same points.

* refactor(main): move dictionary runtimes into assemblies

createDictionariesAssembly owns the JLPT and frequency lookups and their
dictionary-root builders; main.ts still resolves the root paths at the
same point. createCharacterDictionaryAssembly owns the character
dictionary runtime, Yomitan auto-sync, and the portrait and name-candidate
lookups. Both are built where their blocks were; later siblings (Yomitan
profile policy, notifications, OSD, subtitle refresh) come in as getters
or callbacks.

* refactor(main): move overlay modal openers into an assembly

createOverlayModalsAssembly owns the shared open-with-notification helper
and the runtime options, Jimaku, TsukiHime, subtitle generation, session
help, changelog, character dictionary manager, controller, and playlist
browser openers. main.ts builds it where the openers used to live.

* refactor(logger): export the Logger type for assembly deps

The build emits declarations, and the Jellyfin assembly returns the logger
in its tray discovery deps, so tsc needs a nameable Logger type. Assemblies
now Pick from it directly.
2026-10-31 19:39:27 -07:00
..

Architecture Map

Status: active Last verified: 2026-05-23 Owner: Kyle Yasuda Read when: runtime ownership, composition boundaries, or layering questions

SubMiner runs as three cooperating runtimes:

  • Electron desktop app in src/
  • Launcher CLI in launcher/, with managed app-installed wrappers in src/main/runtime/managed-launcher.ts
  • mpv Lua plugin in plugin/subminer/

The desktop app keeps src/main.ts as composition root and pushes behavior into small runtime/domain modules.

Packaged apps include a private Bun runtime. Setup and release assets provide bootstrap wrappers generated from src/main/runtime/*-launcher-bootstrap.ts. macOS runs Bun and the CLI from app resources. Windows stages a versioned private Bun copy under %LOCALAPPDATA%\SubMiner\launcher-runtime/<version> so a running launcher does not lock the updater-owned app executable. Linux stages Bun, the matching CLI, and licenses under ${XDG_DATA_HOME:-~/.local/share}/SubMiner/launcher. Its steady-state path performs one app stat and starts the cache without Electron. A missing cache or changed app fingerprint runs launcher/prepare.cjs through Electron's Node mode to refresh it. Desktop startup migrates recognized writable legacy JavaScript launchers and refreshes managed payloads after app changes. Development commands still use system Bun.

Update checks and startup launcher migration share a serialized update-state store. Deferred launcher paths are acknowledged only after migration succeeds or the candidate is no longer eligible. Running Windows launchers and unreadable or unwritable candidates remain pending for a later startup.

Current Shape

  • src/main/ owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
  • src/main/boot/ owns boot-phase assembly seams so src/main.ts can stay focused on lifecycle coordination and startup-path selection.
  • src/main/runtime/linux-overlay-mode-runtime.ts owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; main.ts supplies window creation and subtitle refresh hooks.
  • src/core/services/ owns focused runtime services plus pure or side-effect-bounded logic.
  • src/core/services/subtitle-generation*.ts shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. src/main/runtime/subtitle-generation-runtime.ts owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (toJellyfinOriginalStreamUrl) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; src/shared/subtitle-generation*.ts owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
  • Subtitle model recommendations use bounded nvidia-smi and Whisper CUDA discovery probes in subtitle-generation-acceleration.ts. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
  • subtitle-generation-reference.ts ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
  • src/main/runtime/subtitle-selection.ts reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
  • src/shared/session-key-sequences.ts rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; src/main/runtime/session-bindings-runtime.ts adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through session-bindings:changed. mpv no-op ignore bindings do not reserve prefixes.
  • src/core/services/plugins/ owns user plugins: discovery from <config dir>/plugins/<id>/plugin.json for ids enabled in plugins, the per-plugin ctx, guarded callbacks, and exec (no shell, AppImage LD_LIBRARY_PATH dropped). main.ts supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the will-quit cleanup, before any teardown. Plugins run in the main process with no sandbox. src/plugin-api.ts is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare apiVersion, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. ctx.signal aborts as unloading starts; ctx.onDispose manages outside resources and late registrations are disposed immediately. Awaitable ctx.mpv.request uses the existing request/response transport. Plugin actions are the runPluginAction session action (keybinding command __plugin-action:<id>/<name>, CLI --plugin-action <id>/<name>); runAction waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets <config dir>/plugin-data/<id>/ as ctx.dataDir. ctx.anki carries the card-mined event with unique kept noteIds and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. AnkiIntegration keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by AnkiIntegration.setCardsReadyCallback: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); AnkiIntegration holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than recordCardsMined (stats counts at add time). Stats-dashboard mining does not emit it. ctx.anki.invoke sends one action to the effective ankiConnect.url with no retries. src/main/runtime/plugin-window-runtime.ts owns plugin windows (ctx.ui.openWindow): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. src/main/runtime/plugin-panel-runtime.ts owns the single plugin-panel modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by panelId, and the latest content is re-sent once the renderer acknowledges the open.
  • src/renderer/ owns overlay rendering and input behavior.
  • src/config/ owns config definitions, defaults, loading, and resolution.
  • src/types/ owns shared cross-runtime contracts via domain entrypoints; src/types.ts stays a compatibility barrel.
  • src/main/runtime/composers/ owns larger domain compositions.
  • src/main/assemblies/ owns per-domain assemblies that src/main.ts builds once (see Layering).
  • src/main.ts call sites invoke configured runtime handlers and runtime-object methods directly; do not add local pass-through wrappers around them.

Architecture Intent

The dictionary backend is selected once at startup by dictionaryBackend. Yomitan keeps its existing session and external-profile policy. Hachidori uses persist:hachidori; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.

setup-state.json records one backend's status at a time plus completedDictionaryBackends, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.

vendor/hachidori/ is a submodule of ksyasuda/hachidori, tracking the subminer branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with git submodule update --init --recursive; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. SOURCE.json records the upstream base and artifact checksums; the submodule commit identifies the integrated version. build:hachidori verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported userScripts permission, and sets a fixed manifest key so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (hachidori-linked-import): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise linked-import-v1 (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native hachidori-popup-shown and hachidori-popup-hidden attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.

  • Small units, explicit boundaries
  • Composition over monoliths
  • Pure helpers where possible
  • Stable user behavior while internals evolve

Startup resolves and creates the user-data directory in src/main-entry-runtime.ts before the entry process requests Electron's single-instance lock. Main-process config bootstrap then writes the default config only when no config file exists.