From 14acb09b89221004b8627d89b03ea0b37b733bca Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 14 Feb 2026 00:36:01 -0800 Subject: [PATCH] Apply remaining working-tree updates --- Makefile | 16 - README.md | 2 +- ...in-subminer-using-ani-cli-stream-source.md | 45 ++ ...ing-CLI-flag-plumbing-and-option-wiring.md | 33 ++ ...i-stream-resolution-logic-into-SubMiner.md | 33 ++ ...lection-and-primary-language-precedence.md | 33 ++ ...imaku-subtitle-fallback-for-stream-mode.md | 34 ++ ...-plan-tests-for-streaming-mode-behavior.md | 33 ++ ...to-Ani-cli-and-Jimaku-subtitle-fallback.md | 49 ++ ...t-Ani-cli-stream-mode-integration-later.md | 30 ++ .../task-52 - Streaming-mode-rework-todo.md | 47 ++ config.example.jsonc | 2 +- docs/.vitepress/config.ts | 11 +- docs/README.md | 16 +- docs/architecture.md | 4 +- docs/configuration.md | 4 +- docs/index.md | 144 +++++- docs/installation.md | 271 +++++------ docs/public/config.example.jsonc | 2 +- docs/usage.md | 6 +- src/config/definitions.ts | 2 +- src/core/services/anki-jimaku-ipc-service.ts | 12 +- .../field-grouping-overlay-service.ts | 8 + src/core/services/mpv-service.ts | 7 + src/core/services/overlay-bridge-service.ts | 23 +- .../services/overlay-shortcut-handler.test.ts | 35 ++ src/core/services/overlay-shortcut-handler.ts | 1 + .../services/shortcut-fallback-service.ts | 2 +- src/core/services/shortcut-service.ts | 31 ++ src/core/services/subsync-service.test.ts | 66 ++- src/core/services/subsync-service.ts | 81 +++- src/jimaku/utils.ts | 36 +- src/preload.ts | 2 +- src/renderer/modals/jimaku.ts | 4 + src/renderer/renderer.ts | 4 +- src/renderer/style.css | 4 + src/subsync/utils.test.ts | 36 +- src/subsync/utils.ts | 10 +- src/types.ts | 2 +- subminer | 427 ++++-------------- 40 files changed, 1001 insertions(+), 607 deletions(-) create mode 100644 backlog/archive/tasks/task-48 - Add-streaming-mode-integration-in-subminer-using-ani-cli-stream-source.md create mode 100644 backlog/archive/tasks/task-48.1 - Add-streaming-CLI-flag-plumbing-and-option-wiring.md create mode 100644 backlog/archive/tasks/task-48.2 - Port-ani-cli-stream-resolution-logic-into-SubMiner.md create mode 100644 backlog/archive/tasks/task-48.3 - Implement-stream-subtitle-selection-and-primary-language-precedence.md create mode 100644 backlog/archive/tasks/task-48.4 - Add-Jimaku-subtitle-fallback-for-stream-mode.md create mode 100644 backlog/archive/tasks/task-48.5 - Add-verification-plan-tests-for-streaming-mode-behavior.md create mode 100644 backlog/archive/tasks/task-48.6 - Wire-s-stream-mode-to-Ani-cli-and-Jimaku-subtitle-fallback.md create mode 100644 backlog/archive/tasks/task-51 - Revisit-Ani-cli-stream-mode-integration-later.md create mode 100644 backlog/tasks/task-52 - Streaming-mode-rework-todo.md diff --git a/Makefile b/Makefile index 2dde697..663ee32 100644 --- a/Makefile +++ b/Makefile @@ -166,13 +166,6 @@ install-linux: @printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts" @install -d "$(BINDIR)" @install -m 0755 "./$(APP_NAME)" "$(BINDIR)/$(APP_NAME)" - @if [ -f "vendor/ani-cli/ani-cli" ]; then \ - install -d "$(BINDIR)/ani-cli"; \ - install -m 0755 "vendor/ani-cli/ani-cli" "$(BINDIR)/ani-cli/ani-cli"; \ - printf '%s\n' "[INFO] Installed vendored ani-cli to $(BINDIR)/ani-cli"; \ - else \ - printf '%s\n' "[WARN] vendored ani-cli not found at vendor/ani-cli (stream mode will require system ani-cli)"; \ - fi @install -d "$(LINUX_DATA_DIR)/themes" @install -m 0644 "./$(THEME_FILE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @if [ -n "$(APPIMAGE_SRC)" ]; then \ @@ -187,13 +180,6 @@ install-macos: @printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts" @install -d "$(BINDIR)" @install -m 0755 "./$(APP_NAME)" "$(BINDIR)/$(APP_NAME)" - @if [ -f "vendor/ani-cli/ani-cli" ]; then \ - install -d "$(BINDIR)/ani-cli"; \ - install -m 0755 "vendor/ani-cli/ani-cli" "$(BINDIR)/ani-cli/ani-cli"; \ - printf '%s\n' "[INFO] Installed vendored ani-cli to $(BINDIR)/ani-cli"; \ - else \ - printf '%s\n' "[WARN] vendored ani-cli not found at vendor/ani-cli (stream mode will require system ani-cli)"; \ - fi @install -d "$(MACOS_DATA_DIR)/themes" @install -m 0644 "./$(THEME_FILE)" "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)" @install -d "$(MACOS_APP_DIR)" @@ -224,13 +210,11 @@ uninstall: uninstall-linux uninstall-linux: @rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage" - @rm -rf "$(BINDIR)/ani-cli" @rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" uninstall-macos: @rm -f "$(BINDIR)/subminer" - @rm -rf "$(BINDIR)/ani-cli" @rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)" @rm -rf "$(MACOS_APP_DEST)" @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)" diff --git a/README.md b/README.md index 189485d..99a8139 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ cp plugin/subminer.conf ~/.config/mpv/script-opts/ Requires mpv IPC: `--input-ipc-server=/tmp/subminer-socket` Default chord prefix: `y` (`y-y` menu, `y-s` start, `y-S` stop, `y-t` toggle visible layer). -Overlay Jimaku shortcut default: `Ctrl+Alt+J` (`shortcuts.openJimaku`). +Overlay Jimaku shortcut default: `Ctrl+Shift+J` (`shortcuts.openJimaku`). ## Documentation diff --git a/backlog/archive/tasks/task-48 - Add-streaming-mode-integration-in-subminer-using-ani-cli-stream-source.md b/backlog/archive/tasks/task-48 - Add-streaming-mode-integration-in-subminer-using-ani-cli-stream-source.md new file mode 100644 index 0000000..e2a6240 --- /dev/null +++ b/backlog/archive/tasks/task-48 - Add-streaming-mode-integration-in-subminer-using-ani-cli-stream-source.md @@ -0,0 +1,45 @@ +--- +id: TASK-48 +title: Add streaming mode integration in subminer using ani-cli stream source +status: To Do +assignee: [] +created_date: '2026-02-14 06:01' +updated_date: '2026-02-14 08:19' +labels: + - stream + - ani-cli + - jimaku +dependencies: [] +priority: high +--- + +## Description + + +Implement a new streaming mode so SubMiner can resolve and play episodes via ani-cli stream sources instead of existing file/download flow. The mode is enabled with a CLI flag (`-s` / `--stream`) and, when active, should prefer streamed playback and subtitle handling that keeps Japanese (or configured primary) subtitles as the main track. If a stream lacks Japanese subtitle tracks, fetch and inject subtitles from Jimaku and set them as primary subtitles according to SubMiner config. + + +## Acceptance Criteria + +- [ ] #1 Add a command-line option `-s`/`--stream` that enables streaming mode and is documented in help/config UX. +- [ ] #2 When streaming mode is enabled, resolve episode/video URLs using existing ani-cli stream selection logic (ported into SubMiner) and route playback to the resolved stream source. +- [ ] #3 If stream metadata contains a Japanese subtitle track, preserve that track as the primary subtitle stream path per current primary-subtitle selection behavior. +- [ ] #4 If no Japanese subtitle track is present in the stream metadata, fetch matching subtitles from Jimaku and load them into playback. +- [ ] #5 When loading Jimaku subtitles, replace/overwrite existing non-Japanese primary subtitle track behavior so the language configured as primary in SubMiner config is used instead. +- [ ] #6 Non-streaming mode continues to follow current behavior when `-s`/`--stream` is not set. + + +## Implementation Notes + + +Superseded by TASK-51. Streaming via ani-cli in subminer was removed due Cloudflare 403/unreliable source metadata; re-evaluate later if reintroduced behind a feature flag and redesigned resolver/metadata pipeline. + + +## Definition of Done + +- [ ] #1 CLI accepts both `-s` and `--stream` and enables streaming-specific behavior. +- [ ] #2 Streaming mode resolves streams through migrated ani-cli logic. +- [ ] #3 Japanese subs are preferred from stream metadata when available; Jimaku fallback is used only when absent. +- [ ] #4 Primary subtitle language in config determines which language is treated as default stream subtitle track after fallback. +- [ ] #5 Behavior is verified via test or manual checklist and documented in task notes. + diff --git a/backlog/archive/tasks/task-48.1 - Add-streaming-CLI-flag-plumbing-and-option-wiring.md b/backlog/archive/tasks/task-48.1 - Add-streaming-CLI-flag-plumbing-and-option-wiring.md new file mode 100644 index 0000000..fd10c35 --- /dev/null +++ b/backlog/archive/tasks/task-48.1 - Add-streaming-CLI-flag-plumbing-and-option-wiring.md @@ -0,0 +1,33 @@ +--- +id: TASK-48.1 +title: Add streaming CLI flag plumbing and option wiring +status: To Do +assignee: [] +created_date: '2026-02-14 06:03' +updated_date: '2026-02-14 08:19' +labels: + - stream + - cli +dependencies: [] +parent_task_id: TASK-48 +priority: medium +--- + +## Description + + +Add the `-s`/`--stream` option end-to-end in SubMiner CLI and configuration handling, including defaults, help text, parsing/validation, and explicit routing so streaming mode is only enabled when requested. + + +## Acceptance Criteria + +- [ ] #1 Introduce `-s` short option and `--stream` long option in CLI parsing without breaking existing flags. +- [ ] #2 When set, the resulting config state reflects streaming mode enabled and is propagated to playback/session startup. +- [ ] #3 When unset, behavior remains identical to current non-streaming flows. + + +## Implementation Notes + + +Superseded by TASK-51. CLI stream mode work deferred until streaming architecture is revisited. + diff --git a/backlog/archive/tasks/task-48.2 - Port-ani-cli-stream-resolution-logic-into-SubMiner.md b/backlog/archive/tasks/task-48.2 - Port-ani-cli-stream-resolution-logic-into-SubMiner.md new file mode 100644 index 0000000..02fd272 --- /dev/null +++ b/backlog/archive/tasks/task-48.2 - Port-ani-cli-stream-resolution-logic-into-SubMiner.md @@ -0,0 +1,33 @@ +--- +id: TASK-48.2 +title: Port ani-cli stream-resolution logic into SubMiner +status: To Do +assignee: [] +created_date: '2026-02-14 06:03' +updated_date: '2026-02-14 08:19' +labels: + - stream + - ani-cli +dependencies: [] +parent_task_id: TASK-48 +priority: high +--- + +## Description + + +Implement stream URL resolution by porting ani-cli logic for selecting providers/episodes and obtaining playable stream URLs so SubMiner can consume stream sources directly. + + +## Acceptance Criteria + +- [ ] #1 Encapsulate stream search/provider selection logic in a dedicated module in SubMiner. +- [ ] #2 Resolve episode query input into a canonical playable stream URL in streaming mode. +- [ ] #3 Preserve existing behavior for non-streaming flow and expose errors when stream resolution fails. + + +## Implementation Notes + + +Superseded by TASK-51. Stream URL resolution via ani-cli postponed; previous attempt exposed anti-bot/403 fragility and poor title-source reliability. + diff --git a/backlog/archive/tasks/task-48.3 - Implement-stream-subtitle-selection-and-primary-language-precedence.md b/backlog/archive/tasks/task-48.3 - Implement-stream-subtitle-selection-and-primary-language-precedence.md new file mode 100644 index 0000000..b9e2708 --- /dev/null +++ b/backlog/archive/tasks/task-48.3 - Implement-stream-subtitle-selection-and-primary-language-precedence.md @@ -0,0 +1,33 @@ +--- +id: TASK-48.3 +title: Implement stream subtitle selection and primary-language precedence +status: To Do +assignee: [] +created_date: '2026-02-14 06:03' +updated_date: '2026-02-14 08:19' +labels: + - stream + - subtitles +dependencies: [] +parent_task_id: TASK-48 +priority: high +--- + +## Description + + +Handle subtitle track selection for stream playback so Japanese (or configured primary language) subtitle behavior is correctly applied when stream metadata includes or omits JP tracks. + + +## Acceptance Criteria + +- [ ] #1 Use stream metadata to choose and mark the configured primary language subtitle as active when available. +- [ ] #2 If no matching primary language track exists in stream metadata, keep previous fallback behavior only for non-streaming mode. +- [ ] #3 When no Japanese track exists and config primary is different, explicitly set configured primary as primary track for streaming flow. + + +## Implementation Notes + + +Superseded by TASK-51. Stream subtitle language precedence in streaming mode deferred with full design revisit. + diff --git a/backlog/archive/tasks/task-48.4 - Add-Jimaku-subtitle-fallback-for-stream-mode.md b/backlog/archive/tasks/task-48.4 - Add-Jimaku-subtitle-fallback-for-stream-mode.md new file mode 100644 index 0000000..3a9be2d --- /dev/null +++ b/backlog/archive/tasks/task-48.4 - Add-Jimaku-subtitle-fallback-for-stream-mode.md @@ -0,0 +1,34 @@ +--- +id: TASK-48.4 +title: Add Jimaku subtitle fallback for stream mode +status: To Do +assignee: [] +created_date: '2026-02-14 06:03' +updated_date: '2026-02-14 08:19' +labels: + - stream + - jimaku + - subtitles +dependencies: [] +parent_task_id: TASK-48 +priority: medium +--- + +## Description + + +When a resolved stream lacks JP/primary-language tracks, fetch subtitles from Jimaku and inject them for playback, overriding non-primary subtitle defaults in streaming mode according to config. + + +## Acceptance Criteria + +- [ ] #1 Detect missing primary subtitle from stream metadata and trigger Jimaku lookup for matching episode. +- [ ] #2 Load fetched Jimaku subtitles into playback pipeline and mark them as the primary subtitle track. +- [ ] #3 Fallback is only used in streaming mode and should not alter subtitle behavior outside streaming. + + +## Implementation Notes + + +Superseded by TASK-51. Jimaku fallback for streams deferred along with entire streaming flow. + diff --git a/backlog/archive/tasks/task-48.5 - Add-verification-plan-tests-for-streaming-mode-behavior.md b/backlog/archive/tasks/task-48.5 - Add-verification-plan-tests-for-streaming-mode-behavior.md new file mode 100644 index 0000000..c9f5435 --- /dev/null +++ b/backlog/archive/tasks/task-48.5 - Add-verification-plan-tests-for-streaming-mode-behavior.md @@ -0,0 +1,33 @@ +--- +id: TASK-48.5 +title: Add verification plan/tests for streaming mode behavior +status: To Do +assignee: [] +created_date: '2026-02-14 06:03' +updated_date: '2026-02-14 08:19' +labels: + - stream + - qa +dependencies: [] +parent_task_id: TASK-48 +priority: low +--- + +## Description + + +Create a validation plan or tests for CLI flag behavior, stream resolution, and subtitle precedence/fallback rules so streaming mode changes are measurable and regressions are caught. + + +## Acceptance Criteria + +- [ ] #1 Document/manual checklist covers `-s` and `--stream` invocation and streaming-only behavior. +- [ ] #2 Include cases for (a) stream with JP subtitles, (b) no JP subtitles with Jimaku fallback, (c) primary-language not Japanese. +- [ ] #3 Run or provide reproducible checks to confirm non-streaming behavior unchanged. + + +## Implementation Notes + + +Superseded by TASK-51. Verification plan moved to deferred reimplementation context. + diff --git a/backlog/archive/tasks/task-48.6 - Wire-s-stream-mode-to-Ani-cli-and-Jimaku-subtitle-fallback.md b/backlog/archive/tasks/task-48.6 - Wire-s-stream-mode-to-Ani-cli-and-Jimaku-subtitle-fallback.md new file mode 100644 index 0000000..571ceca --- /dev/null +++ b/backlog/archive/tasks/task-48.6 - Wire-s-stream-mode-to-Ani-cli-and-Jimaku-subtitle-fallback.md @@ -0,0 +1,49 @@ +--- +id: TASK-48.6 +title: Wire -s/--stream mode to Ani-cli and Jimaku subtitle fallback +status: To Do +assignee: [] +created_date: '2026-02-14 06:06' +updated_date: '2026-02-14 08:19' +labels: [] +dependencies: [] +references: + - ani-cli/ani-cli + - src/jimaku/utils.ts + - src/core/services/anki-jimaku-service.ts +documentation: + - ani-cli/README.md + - subminer + - src/cli/help.ts +parent_task_id: TASK-48 +priority: high +--- + +## Description + + +Implement SubMiner streaming mode end-to-end behind a `-s`/`--stream` flag. In stream mode, use the vendored ani-cli resolution flow to get playable stream URLs instead of local file/YouTube URL handling. If resolved streams do not expose Japanese subtitles, fetch matching subtitles from Jimaku and load them into mpv as the active primary subtitle track, overwriting the current non-primary/non-Japanese default according to subminer primary-subtitle configuration. + + +## Acceptance Criteria + +- [ ] #1 When `subminer -s` is used, resolution should pass a search/query through ani-cli stream logic and play the resolved stream source. +- [ ] #2 If the stream includes a Japanese subtitle track, preserve and select the configured primary subtitle language behavior without Jimaku injection. +- [ ] #3 If no Japanese (or configured primary language) subtitle exists in stream metadata, fetch and inject Jimaku subtitles before playback starts. +- [ ] #4 Loaded Jimaku subtitles should become the selected primary subtitle track, replacing any existing default non-primary subtitle in that context. +- [ ] #5 When `-s` is not passed, non-streaming behavior remains unchanged. + + +## Implementation Notes + + +Superseded by TASK-51. End-to-end stream wiring to ani-cli is deferred. + + +## Definition of Done + +- [ ] #1 CLI exposes both `-s` and `--stream` in help/config and validation. +- [ ] #2 Implementation includes a clear fallback path when stream subtitles are absent and Jimaku search/download fails gracefully. +- [ ] #3 Subtitles loading path avoids temp-file leaks; temporary media/subtitle artifacts are cleaned up on exit. +- [ ] #4 At least one verification step (manual or test) confirms stream mode path works for an episode with and without Japanese stream subtitles. + diff --git a/backlog/archive/tasks/task-51 - Revisit-Ani-cli-stream-mode-integration-later.md b/backlog/archive/tasks/task-51 - Revisit-Ani-cli-stream-mode-integration-later.md new file mode 100644 index 0000000..5894647 --- /dev/null +++ b/backlog/archive/tasks/task-51 - Revisit-Ani-cli-stream-mode-integration-later.md @@ -0,0 +1,30 @@ +--- +id: TASK-51 +title: Revisit Ani-cli stream mode integration later +status: To Do +assignee: [] +created_date: '2026-02-14 08:19' +labels: + - someday + - streaming + - ani-cli + - feature-flag +dependencies: [] +--- + +## Description + + +Current codebase has removed ani-cli integration and stream-mode from subminer temporarily. Keep a deferred design task to reintroduce streaming mode in a future cycle. + +Findings from prior attempts: +- `subminer -s ` path relied on `ani-cli` resolving stream URLs, but returned stream URLs that are Cloudflare-protected (`tools.fast4speed.rsvp`) and often returned 403 from mpv/ytdl-hook (generic anti-bot/Forbidden). +- Even after passing `ytdl` extractor args, stream playback via subminer still failed because URL/anti-bot handling differed from direct ani-cli execution context. +- We also observed stream title resolution issues: selected titles from ani-cli menu were unreliable/random and broke downstream Jimaku matching behavior. +- ffsubsync failures were difficult to debug initially due to OSD-only error visibility; logging was added to mpv log path. +- Based on these findings and instability, stream mode should be explicitly deferred rather than partially reintroduced. + +Proposal: +- Reintroduce behind a feature flag / future milestone only. +- Re-design around a dedicated stream source resolver with robust URL acquisition and source metadata preservation (query/episode/title) before subtitle sync flows. + diff --git a/backlog/tasks/task-52 - Streaming-mode-rework-todo.md b/backlog/tasks/task-52 - Streaming-mode-rework-todo.md new file mode 100644 index 0000000..fd1d297 --- /dev/null +++ b/backlog/tasks/task-52 - Streaming-mode-rework-todo.md @@ -0,0 +1,47 @@ +--- +id: TASK-52 +title: >- + Consolidate stream-mode/ani-cli work into one deferred streaming integration + ticket +status: To Do +assignee: [] +created_date: '2026-02-14 08:21' +labels: + - someday + - streaming + - ani-cli + - feature-flag + - consolidation +dependencies: [] +--- + +## Description + + +Replace fragmented streaming-mode items with a single future-oriented task. + +Context: +- Previous attempts to support `subminer -s` via ani-cli were removed due instability and blockers. +- Stream URLs resolved through ani-cli often hit Cloudflare anti-bot/403 (`generic:impersonate`) in subminer/mpv/ytdl-hook path. +- URL/title handling from stream selection was inconsistent, breaking robust subtitle discovery/matching. +- Subsync logging/debugging required improvements before deeper stream validation. +- A single cohesive redesign is preferable over piecemeal reintroduction. + +Scope (one ticket): +- Reintroduce streaming mode behind a feature flag only. +- Evaluate and redesign stream source resolution architecture (not a direct ani-cli passthrough by default). +- Define robust metadata pipeline so source title/query/episode are reliable for Jimaku/subsync matching. +- Define a single end-to-end flow for stream subtitle source selection + fallback, including explicit failure handling. +- Add verification plan for playback and subtitle matching before implementation. + +Notes: +- This ticket supersedes and consolidates the old fragmented streaming tasks: + - TASK-48, TASK-48.1, TASK-48.2, TASK-48.3, TASK-48.4, TASK-48.5, TASK-48.6 + - TASK-51 + +Acceptance Criteria: +- One documented design + implementation plan exists for future reintroduction. +- A feature-flagged streaming mode path is specified with clear boundaries and testability. +- Stream source resolution and subtitle matching rules are deterministic and validated. +- No partial reintroduction until full path is implemented and tested. + diff --git a/config.example.jsonc b/config.example.jsonc index 686d99b..ce5f68c 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -116,7 +116,7 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", "markAudioCard": "CommandOrControl+Shift+A", "openRuntimeOptions": "CommandOrControl+Shift+O", - "openJimaku": "Ctrl+Alt+J" + "openJimaku": "Ctrl+Shift+J" }, // ========================================== diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index af1a589..b913428 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -3,7 +3,7 @@ const base = process.env.GITHUB_ACTIONS && repositoryName ? `/${repositoryName}/ export default { title: 'SubMiner Docs', - description: 'Documentation for SubMiner', + description: 'All-in-one sentence mining overlay for MPV with AnkiConnect and dictionary integration', base, appearance: 'dark', cleanUrls: true, @@ -21,12 +21,11 @@ export default { }, siteTitle: 'SubMiner Docs', nav: [ - { text: 'Docs', link: '/' }, - { text: 'Installation', link: '/installation' }, - { text: 'Usage', link: '/usage' }, + { text: 'Home', link: '/' }, + { text: 'Get Started', link: '/installation' }, { text: 'Mining', link: '/mining-workflow' }, { text: 'Configuration', link: '/configuration' }, - { text: 'Architecture', link: '/architecture' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, ], sidebar: [ { @@ -50,7 +49,7 @@ export default { { text: 'Development', items: [ - { text: 'Development', link: '/development' }, + { text: 'Building & Testing', link: '/development' }, { text: 'Architecture', link: '/architecture' }, ], }, diff --git a/docs/README.md b/docs/README.md index 0a7cf94..73d369a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,12 +12,20 @@ make docs-preview # Preview built site at http://localhost:4173 ## Pages -- [Installation](/installation) — Platform requirements, AppImage/macOS/source installs, mpv plugin -- [Usage](/usage) — Script vs plugin workflow, keybindings, YouTube playback +### Getting Started + +- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup +- [Usage](/usage) — `subminer` wrapper, mpv plugin, keybindings, YouTube playback - [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation + +### Reference + - [Configuration](/configuration) — Full config file reference and option details - [Anki Integration](/anki-integration) — AnkiConnect setup, field mapping, media generation, field grouping - [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages - [Troubleshooting](/troubleshooting) — Common issues and solutions by category -- [Development](/development) — Building, testing, contributing, environment variables -- [Architecture](/architecture) — Service-oriented design, composition model, renderer module layout, extension rules + +### Development + +- [Building & Testing](/development) — Build commands, test suites, contributor notes, environment variables +- [Architecture](/architecture) — Service-oriented design, composition model, renderer module layout diff --git a/docs/architecture.md b/docs/architecture.md index 8853c8b..a1ae55b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,12 +20,12 @@ src/ preload.ts # Electron preload bridge types.ts # Shared type definitions core/ - services/ # ~35 focused service modules (see below) + services/ # ~55 focused service modules (see below) utils/ # Pure helpers and coercion/config utilities cli/ # CLI parsing and help output config/ # Config schema, defaults, validation, template generation renderer/ # Overlay renderer (modularized UI/runtime) - window-trackers/ # Backend-specific tracker implementations (Hyprland, X11, macOS) + 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 diff --git a/docs/configuration.md b/docs/configuration.md index ffe404b..d68a811 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -416,7 +416,7 @@ See `config.example.jsonc` for detailed configuration options. "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", "openRuntimeOptions": "CommandOrControl+Shift+O", - "openJimaku": "Ctrl+Alt+J", + "openJimaku": "Ctrl+Shift+J", "multiCopyTimeoutMs": 3000 } } @@ -437,7 +437,7 @@ See `config.example.jsonc` for detailed configuration options. | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | -| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Alt+J"`) | +| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | **See `config.example.jsonc`** for the complete list of shortcut configuration options. diff --git a/docs/index.md b/docs/index.md index 4cf4cfc..c42279b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,18 +2,18 @@ layout: home title: SubMiner -tagline: All-in-one sentence mining overlay for MPV with AnkiConnect and dictionary integration +titleTemplate: Sentence Mining Overlay for MPV hero: name: SubMiner - text: Documentation - tagline: Install, configure, and use SubMiner for subtitle-driven mining workflows. + text: Sentence Mining for MPV + tagline: Click on subtitles. Look up words. Mine to Anki. All without leaving the video. image: src: /assets/SubMiner.png alt: SubMiner logo actions: - theme: brand - text: Installation + text: Get Started link: /installation - theme: alt text: Mining Workflow @@ -21,15 +21,133 @@ hero: - theme: alt text: Configuration link: /configuration - - theme: alt - text: Troubleshooting - link: /troubleshooting features: - - title: End-to-end workflow - details: Connect mpv subtitle capture, Yomitan lookup, and Anki card generation in one overlay. - - title: Practical configuration - details: Use the complete option reference to tune behavior for mining, media generation, and keybindings. - - title: Contributor docs - details: Build, test, and package SubMiner with the development notes in this docs set. + - icon: "🎯" + title: Click-to-Lookup Overlay + details: Subtitles are tokenized into clickable words. Click any word to open a Yomitan dictionary popup — right on top of the video. + - icon: "📇" + title: Automatic Anki Cards + details: Add a word from Yomitan and SubMiner fills in the sentence, audio clip, screenshot, and translation automatically. + - icon: "🪟" + title: Dual-Layer Subtitle System + details: Visible overlay with styled, interactive subtitles — plus an invisible layer that aligns with mpv's own subtitle rendering for seamless click-through lookup. + - icon: "🎬" + title: YouTube & Subtitle Sync + details: Play YouTube videos with auto-generated subtitles. Sync external subtitle files with alass or ffsubsync. Search and download anime subtitles from Jimaku. + - icon: "🔠" + title: Smart Tokenization + details: Japanese text is segmented using Yomitan's internal parser with MeCab fallback, enabling accurate word boundary detection for dictionary lookups. + - icon: "⌨️" + title: Keyboard-Driven Workflow + details: Mine sentences, copy subtitles, cycle display modes, and trigger field grouping — all from configurable keyboard shortcuts without touching the mouse. --- + + + +
+ +## See It in Action + +SubMiner sits as a transparent overlay on top of mpv. Subtitles appear as interactive, clickable text — click a word to look it up with Yomitan, then add it to Anki with one click. + + + +
+ +
+ +## How It Works + +
+
+
01
+
Watch
+
Play a video in mpv. SubMiner connects via IPC and captures subtitles in real time.
+
+
+
02
+
Look Up
+
Click any word in the subtitle overlay. Yomitan opens its dictionary popup instantly.
+
+
+
03
+
Mine
+
Add the word to Anki from Yomitan. SubMiner detects the new card automatically.
+
+
+
04
+
Enrich
+
SubMiner fills in the sentence, audio clip, screenshot, and translation — no extra steps.
+
+
+ +
diff --git a/docs/installation.md b/docs/installation.md index d73ebae..392903c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,32 +2,41 @@ ## Requirements -### Linux +### System Dependencies -- **Wayland/X11 compositor** (one of the following): - - Hyprland (uses `hyprctl`) - - X11 (uses `xdotool` and `xwininfo`) -- mpv (with IPC socket support) -- mecab and mecab-ipadic (optional fallback Japanese morphological analyzer) -- fuse2 (for AppImage support) +| Dependency | Required | Notes | +| --- | --- | --- | +| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) | +| ffmpeg | For media | Audio extraction and screenshot generation | +| MeCab + mecab-ipadic | No | Optional fallback tokenizer for Japanese | +| fuse2 | Linux only | Required for AppImage | +| yt-dlp | No | Recommended for YouTube playback and subtitle extraction | -### macOS +### Platform-Specific -- macOS 10.13 or later -- mpv (with IPC socket support) -- mecab and mecab-ipadic (optional fallback Japanese morphological analyzer) -- **Accessibility permission** required for window tracking (see [macOS Installation](#macos-installation)) +**Linux** — one of the following compositors: -**Optional:** +- Hyprland (uses `hyprctl`) +- Sway (uses `swaymsg`) +- X11 (uses `xdotool` and `xwininfo`) -- fzf (terminal-based video picker, default) -- rofi (GUI-based video picker) -- chafa (thumbnail previews in fzf) -- ffmpegthumbnailer (generate video thumbnails) -- yt-dlp (recommended for reliable YouTube playback/subtitles in mpv) -- bun (required to run the `subminer` wrapper script from source/local installs) +**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking. -### From AppImage (Recommended) +### Optional Tools + +| Tool | Purpose | +| --- | --- | +| fzf | Terminal-based video picker (default) | +| rofi | GUI-based video picker | +| chafa | Thumbnail previews in fzf | +| ffmpegthumbnailer | Generate video thumbnails for picker | +| alass | Subtitle sync engine (preferred) | +| ffsubsync | Subtitle sync engine (fallback) | +| Bun | Required for the `subminer` wrapper script | + +## Linux + +### AppImage (Recommended) Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): @@ -41,12 +50,26 @@ wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/subminer -O ~ chmod +x ~/.local/bin/subminer ``` -Note: the `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. +The `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. -### macOS Installation +### From Source -If you download a release, use the **DMG** artifact. Open it and drag `SubMiner.app` into `/Applications`. -If needed, you can use the **ZIP** artifact as a fallback by unzipping and dragging `SubMiner.app` into `/Applications`. +```bash +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner +make build + +# Install platform artifacts (wrapper + theme + AppImage) +make install +``` + +## macOS + +### DMG (Recommended) + +Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`. + +A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`. Install dependencies using Homebrew: @@ -54,7 +77,7 @@ Install dependencies using Homebrew: brew install mpv mecab mecab-ipadic ``` -Build from source: +### From Source (macOS) ```bash git clone https://github.com/ksyasuda/SubMiner.git @@ -64,58 +87,23 @@ cd vendor/texthooker-ui && pnpm install && pnpm build && cd ../.. pnpm run build:mac ``` -The built app will be available in the `release` directory (`.dmg` and `.zip` on macOS). +The built app will be available in the `release` directory (`.dmg` and `.zip`). -If you are building locally without Apple signing credentials, use: +For unsigned local builds: ```bash pnpm run build:mac:unsigned ``` -You can launch `SubMiner.app` directly (double-click or `open -a SubMiner`). -Use `--start` when you want SubMiner to begin MPV IPC connection/reconnect behavior. -Use `--texthooker` when you only want the texthooker page (no overlay window). +### Accessibility Permission -**Accessibility Permission:** - -After launching the app for the first time, grant accessibility permission: +After launching SubMiner for the first time, grant accessibility permission: 1. Open **System Preferences** → **Security & Privacy** → **Privacy** tab 2. Select **Accessibility** from the left sidebar 3. Add SubMiner to the list -Without this permission, window tracking will not work and the overlay won't follow the MPV window. - - - -### From Source - -```bash -git clone https://github.com/ksyasuda/SubMiner.git -cd SubMiner -make build - -# Install platform artifacts -# - Linux: wrapper + theme (+ AppImage if present) -# - macOS: wrapper + theme + SubMiner.app (from release/*.app or release/*.zip) -make install -``` - - - - - - - +Without this permission, window tracking will not work and the overlay won't follow the mpv window. ### macOS Usage Notes @@ -125,116 +113,26 @@ make install mpv --input-ipc-server=/tmp/subminer-socket video.mkv ``` -**Config Location:** +**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`). -Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`, same as Linux). - -**MeCab Installation Paths:** - -Common Homebrew install paths: +**MeCab paths (Homebrew):** - Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab` - Intel: `/usr/local/bin/mecab` -Ensure that `mecab` is available on your PATH when launching subminer (for example, by starting it from a terminal where `which mecab` works), otherwise MeCab may not be detected. +Ensure `mecab` is available on your PATH when launching SubMiner. -**Fullscreen Mode:** +**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted. -The overlay should appear correctly in fullscreen. If you encounter issues, check that macOS accessibility permissions are granted (see [macOS Installation](#macos-installation)). - -**mpv Plugin Binary Path (macOS):** - -Set `binary_path` to your app binary, for example: +**mpv plugin binary path:** ```ini binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer ``` -### MPV Plugin (Optional) +## Windows -The Lua plugin allows you to control the overlay directly from mpv using keybindings: - -::: warning Important -`mpv` must be launched with `--input-ipc-server=/tmp/subminer-socket` to allow communication with the application. -::: - -```bash -# Copy plugin files to mpv config -cp plugin/subminer.lua ~/.config/mpv/scripts/ -cp plugin/subminer.conf ~/.config/mpv/script-opts/ -``` - -#### Plugin Keybindings - -All keybindings use chord sequences starting with `y`: - -| Keybind | Action | -| ------- | ------------------------------------- | -| `y-y` | Open SubMiner menu (fuzzy-searchable) | -| `y-s` | Start overlay | -| `y-S` | Stop overlay | -| `y-t` | Toggle visible overlay | -| `y-i` | Toggle invisible overlay | -| `y-I` | Show invisible overlay | -| `y-u` | Hide invisible overlay | -| `y-o` | Open Yomitan settings | -| `y-r` | Restart overlay | -| `y-c` | Check overlay status | - -The menu provides options to start/stop/toggle the visible or invisible overlay layers and open settings. Type to filter or use arrow keys to navigate. - -Jimaku modal shortcut is configured separately in SubMiner overlay shortcuts (`shortcuts.openJimaku`), default `Ctrl+Alt+J`. - -#### Plugin Configuration - -Edit `~/.config/mpv/script-opts/subminer.conf`: - -```ini -# Path to SubMiner binary (leave empty for auto-detection) -binary_path= - -# Path to mpv IPC socket (must match input-ipc-server in mpv.conf) -socket_path=/tmp/subminer-socket - -# Enable texthooker WebSocket server -texthooker_enabled=yes - -# Texthooker WebSocket port -texthooker_port=5174 - -# Window manager backend: auto, hyprland, x11, macos -backend=auto - -# Automatically start overlay when a file is loaded -auto_start=no - -# Automatically show visible overlay when overlay starts -auto_start_visible_overlay=no - -# Automatically show invisible overlay when overlay starts -# Values: platform-default, visible, hidden -# platform-default => hidden on Linux, visible on macOS/Windows -auto_start_invisible_overlay=platform-default - -# Show OSD messages for overlay status -osd_messages=yes -``` - -The plugin auto-detects the binary location, searching: - -- `/Applications/SubMiner.app/Contents/MacOS/subminer` -- `~/Applications/SubMiner.app/Contents/MacOS/subminer` -- `C:\Program Files\subminer\subminer.exe` -- `C:\Program Files (x86)\subminer\subminer.exe` -- `C:\subminer\subminer.exe` -- `~/.local/bin/SubMiner.AppImage` -- `/opt/SubMiner/SubMiner.AppImage` -- `/usr/local/bin/subminer` -- `/usr/bin/subminer` - -**Windows Notes:** - -Set the binary and socket path like this: +Windows support is available through the mpv plugin. Set the binary and socket path in `subminer.conf`: ```ini binary_path=C:\\Program Files\\subminer\\subminer.exe @@ -246,3 +144,52 @@ Launch mpv with: ```bash mpv --input-ipc-server=\\\\.\\pipe\\subminer-socket video.mkv ``` + +## MPV Plugin (Optional) + +The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags. + +::: warning Important +mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect. +::: + +```bash +# Copy plugin files +cp plugin/subminer.lua ~/.config/mpv/scripts/ +cp plugin/subminer.conf ~/.config/mpv/script-opts/ +# or: make install-plugin +``` + +All keybindings use a `y` chord prefix — press `y`, then the second key: + +| Chord | Action | +| --- | --- | +| `y-y` | Open SubMiner menu (fuzzy-searchable) | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-i` | Toggle invisible overlay | +| `y-I` | Show invisible overlay | +| `y-u` | Hide invisible overlay | +| `y-o` | Open Yomitan settings | +| `y-r` | Restart overlay | +| `y-c` | Check overlay status | + +See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messages, and binary auto-detection details. + +## Verify Installation + +After installing, confirm SubMiner is working: + +```bash +# Start the overlay (connects to mpv IPC) +subminer video.mkv + +# Or with direct AppImage control +SubMiner.AppImage --start +SubMiner.AppImage --help # Show all CLI options +``` + +You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay. + +Next: [Usage](/usage) — learn about the `subminer` wrapper, keybindings, and YouTube playback. diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 686d99b..ce5f68c 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -116,7 +116,7 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", "markAudioCard": "CommandOrControl+Shift+A", "openRuntimeOptions": "CommandOrControl+Shift+O", - "openJimaku": "Ctrl+Alt+J" + "openJimaku": "Ctrl+Shift+J" }, // ========================================== diff --git a/docs/usage.md b/docs/usage.md index 6667e82..55b6e87 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ -# SubMiner Script vs MPV Plugin +# Usage -There are two ways to use SubMiner: +There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin: | Approach | Best For | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -11,7 +11,7 @@ You can use both together—install the plugin for on-demand control, but use `s `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`. -## Usage +## Commands ```bash # Browse and play videos diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 641c96d..a7ca02a 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -152,7 +152,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { toggleSecondarySub: "CommandOrControl+Shift+V", markAudioCard: "CommandOrControl+Shift+A", openRuntimeOptions: "CommandOrControl+Shift+O", - openJimaku: "Ctrl+Alt+J", + openJimaku: "Ctrl+Shift+J", }, secondarySub: { secondarySubLanguages: [], diff --git a/src/core/services/anki-jimaku-ipc-service.ts b/src/core/services/anki-jimaku-ipc-service.ts index e5b0ac2..2da2751 100644 --- a/src/core/services/anki-jimaku-ipc-service.ts +++ b/src/core/services/anki-jimaku-ipc-service.ts @@ -1,6 +1,7 @@ import { ipcMain, IpcMainEvent } from "electron"; import * as fs from "fs"; import * as path from "path"; +import * as os from "os"; import { JimakuApiResponse, JimakuDownloadQuery, @@ -115,14 +116,9 @@ export function registerAnkiJimakuIpcHandlers( return { ok: false, error: { error: "No media file loaded in MPV." } }; } - if (deps.isRemoteMediaPath(currentMediaPath)) { - return { - ok: false, - error: { error: "Cannot download subtitles for remote media paths." }, - }; - } - - const mediaDir = path.dirname(path.resolve(currentMediaPath)); + const mediaDir = deps.isRemoteMediaPath(currentMediaPath) + ? fs.mkdtempSync(path.join(os.tmpdir(), "subminer-jimaku-")) + : path.dirname(path.resolve(currentMediaPath)); const safeName = path.basename(query.name); if (!safeName) { return { ok: false, error: { error: "Invalid subtitle filename." } }; diff --git a/src/core/services/field-grouping-overlay-service.ts b/src/core/services/field-grouping-overlay-service.ts index 3c00fe8..c9224bc 100644 --- a/src/core/services/field-grouping-overlay-service.ts +++ b/src/core/services/field-grouping-overlay-service.ts @@ -25,6 +25,11 @@ export interface FieldGroupingOverlayRuntimeOptions { resolver: ((choice: KikuFieldGroupingChoice) => void) | null, ) => void; getRestoreVisibleOverlayOnModalClose: () => Set; + sendToVisibleOverlay?: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: T }, + ) => boolean; } export function createFieldGroupingOverlayRuntimeService( @@ -44,6 +49,9 @@ export function createFieldGroupingOverlayRuntimeService( payload?: unknown, runtimeOptions?: { restoreOnModalClose?: T }, ): boolean => { + if (options.sendToVisibleOverlay) { + return options.sendToVisibleOverlay(channel, payload, runtimeOptions); + } return sendToVisibleOverlayRuntimeService({ mainWindow: options.getMainWindow() as never, visibleOverlayVisible: options.getVisibleOverlayVisible(), diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index 9daece4..ed73773 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -68,6 +68,7 @@ export interface MpvIpcClientDeps { getPreviousSecondarySubVisibility: () => boolean | null; setPreviousSecondarySubVisibility: (value: boolean | null) => void; showMpvOsd: (text: string) => void; + updateCurrentMediaTitle?: (mediaTitle: unknown) => void; } export class MpvIpcClient implements MpvClient { @@ -285,6 +286,8 @@ export class MpvIpcClient implements MpvClient { this.pauseAtTime = null; this.send({ command: ["set_property", "pause", true] }); } + } else if (msg.name === "media-title") { + this.deps.updateCurrentMediaTitle?.(msg.data); } else if (msg.name === "path") { this.currentVideoPath = (msg.data as string) || ""; this.deps.updateCurrentMediaPath(msg.data); @@ -653,6 +656,7 @@ export class MpvIpcClient implements MpvClient { this.send({ command: ["observe_property", 22, "sub-shadow-offset"] }); this.send({ command: ["observe_property", 23, "sub-ass-override"] }); this.send({ command: ["observe_property", 24, "sub-use-margins"] }); + this.send({ command: ["observe_property", 25, "media-title"] }); } private getInitialState(): void { @@ -668,6 +672,9 @@ export class MpvIpcClient implements MpvClient { command: ["get_property", "path"], request_id: MPV_REQUEST_ID_PATH, }); + this.send({ + command: ["get_property", "media-title"], + }); this.send({ command: ["get_property", "secondary-sub-text"], request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, diff --git a/src/core/services/overlay-bridge-service.ts b/src/core/services/overlay-bridge-service.ts index a705921..ad31ec6 100644 --- a/src/core/services/overlay-bridge-service.ts +++ b/src/core/services/overlay-bridge-service.ts @@ -22,11 +22,26 @@ export function sendToVisibleOverlayRuntimeService(options: { if (!wasVisible && options.restoreOnModalClose) { options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); } - if (options.payload === undefined) { - options.mainWindow.webContents.send(options.channel); - } else { - options.mainWindow.webContents.send(options.channel, options.payload); + const sendNow = (): void => { + if (options.payload === undefined) { + options.mainWindow!.webContents.send(options.channel); + } else { + options.mainWindow!.webContents.send(options.channel, options.payload); + } + }; + if (options.mainWindow.webContents.isLoading()) { + options.mainWindow.webContents.once("did-finish-load", () => { + if ( + options.mainWindow && + !options.mainWindow.isDestroyed() && + !options.mainWindow.webContents.isLoading() + ) { + sendNow(); + } + }); + return true; } + sendNow(); return true; } diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index f80c994..69b37ae 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -204,6 +204,41 @@ test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]); }); +test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut", () => { + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + openJimaku: "Ctrl+J", + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === "Ctrl+J"; + }, + { + openRuntimeOptions: () => {}, + openJimaku: () => {}, + markAudioCard: () => {}, + copySubtitleMultiple: () => {}, + copySubtitle: () => {}, + toggleSecondarySub: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + }, + ); + + assert.equal(result, true); + assert.deepEqual(matched, [{ accelerator: "Ctrl+J", allowWhenRegistered: true }]); +}); + test("runOverlayShortcutLocalFallback returns false when no action matches", () => { const shortcuts = makeShortcuts({ copySubtitle: "Ctrl+C", diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index de3e740..912ac7e 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -142,6 +142,7 @@ export function runOverlayShortcutLocalFallback( run: () => { handlers.openJimaku(); }, + allowWhenRegistered: true, }, { accelerator: shortcuts.markAudioCard, diff --git a/src/core/services/shortcut-fallback-service.ts b/src/core/services/shortcut-fallback-service.ts index 898b697..1d6eb1b 100644 --- a/src/core/services/shortcut-fallback-service.ts +++ b/src/core/services/shortcut-fallback-service.ts @@ -66,7 +66,7 @@ export function shortcutMatchesInputForLocalFallback( } else { if (process.platform === "darwin") { if (input.meta || input.control) return false; - } else if (input.control) { + } else if (!expectedControl && input.control) { return false; } } diff --git a/src/core/services/shortcut-service.ts b/src/core/services/shortcut-service.ts index c7f17a0..c4ef5be 100644 --- a/src/core/services/shortcut-service.ts +++ b/src/core/services/shortcut-service.ts @@ -3,6 +3,7 @@ import { BrowserWindow, globalShortcut } from "electron"; export interface GlobalShortcutConfig { toggleVisibleOverlayGlobal: string | null | undefined; toggleInvisibleOverlayGlobal: string | null | undefined; + openJimaku?: string | null | undefined; } export interface RegisterGlobalShortcutsServiceOptions { @@ -10,6 +11,7 @@ export interface RegisterGlobalShortcutsServiceOptions { onToggleVisibleOverlay: () => void; onToggleInvisibleOverlay: () => void; onOpenYomitanSettings: () => void; + onOpenJimaku?: () => void; isDev: boolean; getMainWindow: () => BrowserWindow | null; } @@ -23,6 +25,10 @@ export function registerGlobalShortcutsService( const normalizedInvisible = invisibleShortcut ?.replace(/\s+/g, "") .toLowerCase(); + const normalizedJimaku = options.shortcuts.openJimaku + ?.replace(/\s+/g, "") + .toLowerCase(); + const normalizedSettings = "alt+shift+y"; if (visibleShortcut) { const toggleVisibleRegistered = globalShortcut.register( @@ -64,6 +70,31 @@ export function registerGlobalShortcutsService( ); } + if (options.shortcuts.openJimaku && options.onOpenJimaku) { + if ( + normalizedJimaku && + (normalizedJimaku === normalizedVisible || + normalizedJimaku === normalizedInvisible || + normalizedJimaku === normalizedSettings) + ) { + console.warn( + "Skipped registering openJimaku because it collides with another global shortcut", + ); + } else { + const openJimakuRegistered = globalShortcut.register( + options.shortcuts.openJimaku, + () => { + options.onOpenJimaku?.(); + }, + ); + if (!openJimakuRegistered) { + console.warn( + `Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`, + ); + } + } + } + const settingsRegistered = globalShortcut.register("Alt+Shift+Y", () => { options.onOpenYomitanSettings(); }); diff --git a/src/core/services/subsync-service.test.ts b/src/core/services/subsync-service.test.ts index c64f2b9..02f1eac 100644 --- a/src/core/services/subsync-service.test.ts +++ b/src/core/services/subsync-service.test.ts @@ -290,11 +290,69 @@ test("runSubsyncManualService constructs alass command and returns failure on no deps, ); - assert.deepEqual(result, { - ok: false, - message: "alass synchronization failed", - }); + assert.equal(result.ok, false); + assert.equal(typeof result.message, "string"); + assert.equal(result.message.startsWith("alass synchronization failed"), true); const alassArgs = fs.readFileSync(alassLogPath, "utf8").trim().split("\n"); assert.equal(alassArgs[0], sourcePath); assert.equal(alassArgs[1], primaryPath); }); + +test("runSubsyncManualService resolves string sid values from mpv stream properties", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-stream-sid-")); + const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh"); + const ffmpegPath = path.join(tmpDir, "ffmpeg.sh"); + const alassPath = path.join(tmpDir, "alass.sh"); + const videoPath = path.join(tmpDir, "video.mkv"); + const primaryPath = path.join(tmpDir, "primary.srt"); + const syncOutputPath = path.join(tmpDir, "synced.srt"); + + fs.writeFileSync(videoPath, "video"); + fs.writeFileSync(primaryPath, "subtitle"); + writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n"); + writeExecutableScript(alassPath, "#!/bin/sh\nexit 0\n"); + writeExecutableScript( + ffsubsyncPath, + `#!/bin/sh\nmkdir -p "${tmpDir}"\nprev=""; for arg in "$@"; do if [ "$prev" = "--reference-stream" ]; then :; fi; if [ "$prev" = "-o" ]; then echo "$arg" > "${syncOutputPath}"; fi; prev="$arg"; done`, + ); + + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === "path") return videoPath; + if (name === "sid") return "1"; + if (name === "secondary-sid") return "2"; + if (name === "track-list") { + return [ + { + id: "1", + type: "sub", + selected: true, + external: true, + "external-filename": primaryPath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: "manual", + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManualService( + { engine: "ffsubsync", sourceTrackId: null }, + deps, + ); + + assert.equal(result.ok, true); + assert.equal(result.message, "Subtitle synchronized with ffsubsync"); + assert.equal(fs.readFileSync(syncOutputPath, "utf8"), ""); +}); diff --git a/src/core/services/subsync-service.ts b/src/core/services/subsync-service.ts index e179b63..6316d5d 100644 --- a/src/core/services/subsync-service.ts +++ b/src/core/services/subsync-service.ts @@ -18,12 +18,27 @@ import { SubsyncContext, SubsyncResolvedConfig, } from "../../subsync/utils"; +import { isRemoteMediaPath } from "../../jimaku/utils"; interface FileExtractionResult { path: string; temporary: boolean; } +function summarizeCommandFailure(command: string, result: CommandResult): string { + const parts = [ + `code=${result.code ?? "n/a"}`, + result.stderr ? `stderr: ${result.stderr}` : "", + result.stdout ? `stdout: ${result.stdout}` : "", + result.error ? `error: ${result.error}` : "", + ] + .map((value) => value.trim()) + .filter(Boolean); + + if (parts.length === 0) return `command failed (${command})`; + return `command failed (${command}) ${parts.join(" | ")}`; +} + interface MpvClientLike { connected: boolean; currentAudioStreamIndex: number | null; @@ -36,6 +51,32 @@ interface SubsyncCoreDeps { getResolvedConfig: () => SubsyncResolvedConfig; } +function parseTrackId(value: unknown): number | null { + if (typeof value === "number") { + return Number.isInteger(value) ? value : null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed.length) return null; + const parsed = Number(trimmed); + return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null; + } + return null; +} + +function normalizeTrackIds(tracks: unknown[]): MpvTrack[] { + return tracks.map((track) => { + if (!track || typeof track !== "object") return track as MpvTrack; + const typed = track as MpvTrack & { id?: unknown }; + const parsedId = parseTrackId(typed.id); + if (parsedId === null) { + const { id: _ignored, ...rest } = typed; + return rest as MpvTrack; + } + return { ...typed, id: parsedId }; + }); +} + export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps { isSubsyncInProgress: () => boolean; setSubsyncInProgress: (inProgress: boolean) => void; @@ -69,12 +110,11 @@ async function gatherSubsyncContext( } const tracks = Array.isArray(trackListRaw) - ? (trackListRaw as MpvTrack[]) + ? normalizeTrackIds(trackListRaw as MpvTrack[]) : []; const subtitleTracks = tracks.filter((track) => track.type === "sub"); - const sid = typeof sidRaw === "number" ? sidRaw : null; - const secondarySid = - typeof secondarySidRaw === "number" ? secondarySidRaw : null; + const sid = parseTrackId(sidRaw); + const secondarySid = parseTrackId(secondarySidRaw); const primaryTrack = subtitleTracks.find((track) => track.id === sid); if (!primaryTrack) { @@ -147,7 +187,7 @@ async function extractSubtitleTrackToFile( "-nostdin", "-y", "-loglevel", - "quiet", + "error", "-an", "-vn", "-i", @@ -160,7 +200,12 @@ async function extractSubtitleTrackToFile( ]); if (!extraction.ok || !fileExists(outputPath)) { - throw new Error("Failed to extract internal subtitle track with ffmpeg"); + throw new Error( + `Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure( + "ffmpeg", + extraction, + )}`, + ); } return { path: outputPath, temporary: true }; @@ -264,9 +309,10 @@ async function subsyncToReference( } if (!result.ok || !fileExists(outputPath)) { + const details = summarizeCommandFailure(engine, result); return { ok: false, - message: `${engine} synchronization failed`, + message: `${engine} synchronization failed: ${details}`, }; } @@ -280,6 +326,14 @@ async function subsyncToReference( } } +function validateFfsubsyncReference(videoPath: string): void { + if (isRemoteMediaPath(videoPath)) { + throw new Error( + "FFsubsync cannot reliably sync stream URLs because it needs direct reference media access. Use Alass with a secondary subtitle source or play a local file.", + ); + } +} + async function runSubsyncAutoInternal( deps: SubsyncCoreDeps, ): Promise { @@ -325,6 +379,14 @@ async function runSubsyncAutoInternal( message: "No secondary subtitle for alass and ffsubsync not configured", }; } + try { + validateFfsubsyncReference(context.videoPath); + } catch (error) { + return { + ok: false, + message: `ffsubsync synchronization failed: ${(error as Error).message}`, + }; + } return subsyncToReference( "ffsubsync", context.videoPath, @@ -343,6 +405,11 @@ export async function runSubsyncManualService( const resolved = deps.getResolvedConfig(); if (request.engine === "ffsubsync") { + try { + validateFfsubsyncReference(context.videoPath); + } catch (error) { + return { ok: false, message: `ffsubsync synchronization failed: ${(error as Error).message}` }; + } return subsyncToReference( "ffsubsync", context.videoPath, diff --git a/src/jimaku/utils.ts b/src/jimaku/utils.ts index 6175e55..4d79708 100644 --- a/src/jimaku/utils.ts +++ b/src/jimaku/utils.ts @@ -223,7 +223,8 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { }; } - const filename = path.basename(mediaPath); + const normalizedMediaPath = normalizeMediaPathForJimaku(mediaPath); + const filename = path.basename(normalizedMediaPath); let name = filename.replace(/\.[^/.]+$/, ""); name = name.replace(/\[[^\]]*]/g, " "); name = name.replace(/\(\d{4}\)/g, " "); @@ -237,7 +238,7 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { titlePart = name.slice(0, parsed.index); } - const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath); + const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath); const title = cleanupTitle(titlePart || name); return { @@ -250,6 +251,37 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { }; } +function normalizeMediaPathForJimaku(mediaPath: string): string { + const trimmed = mediaPath.trim(); + if (!trimmed || !/^https?:\/\/.*/.test(trimmed)) { + return trimmed; + } + + try { + const parsedUrl = new URL(trimmed); + const titleParam = + parsedUrl.searchParams.get("title") || + parsedUrl.searchParams.get("name") || + parsedUrl.searchParams.get("q"); + if (titleParam && titleParam.trim()) return titleParam.trim(); + + const pathParts = parsedUrl.pathname.split("/").filter(Boolean).reverse(); + const candidate = pathParts.find((part) => { + const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, ""); + const lowered = decoded.toLowerCase(); + return ( + lowered.length > 2 && + !/^[0-9.]+$/.test(lowered) && + !/^[a-f0-9]{16,}$/i.test(lowered) + ); + }); + + return decodeURIComponent(candidate || parsedUrl.hostname.replace(/^www\./, "")); + } catch { + return trimmed; + } +} + function formatLangScore(name: string, pref: JimakuLanguagePreference): number { if (pref === "none") return 0; const upper = name.toUpperCase(); diff --git a/src/preload.ts b/src/preload.ts index 5855712..2e3b7aa 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -265,7 +265,7 @@ const electronAPI: ElectronAPI = { callback(); }); }, - notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => { + notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => { ipcRenderer.send("overlay:modal-closed", modal); }, reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index 4e1380f..9eb4002 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -11,6 +11,7 @@ export function createJimakuModal( ctx: RendererContext, options: { modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; }, ) { function setJimakuStatus(message: string, isError = false): void { @@ -252,6 +253,7 @@ export function createJimakuModal( if (ctx.state.jimakuModalOpen) return; ctx.state.jimakuModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add("interactive"); ctx.dom.jimakuModal.classList.remove("hidden"); ctx.dom.jimakuModal.setAttribute("aria-hidden", "false"); @@ -284,8 +286,10 @@ export function createJimakuModal( if (!ctx.state.jimakuModalOpen) return; ctx.state.jimakuModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); ctx.dom.jimakuModal.classList.add("hidden"); ctx.dom.jimakuModal.setAttribute("aria-hidden", "true"); + window.electronAPI.notifyOverlayModalClosed("jimaku"); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove("interactive"); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 99f141f..36b571a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -48,7 +48,8 @@ function isAnySettingsModalOpen(): boolean { return ( ctx.state.runtimeOptionsModalOpen || ctx.state.subsyncModalOpen || - ctx.state.kikuModalOpen + ctx.state.kikuModalOpen || + ctx.state.jimakuModalOpen ); } @@ -89,6 +90,7 @@ const kikuModal = createKikuModal(ctx, { }); const jimakuModal = createJimakuModal(ctx, { modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, }); const keyboardHandlers = createKeyboardHandlers(ctx, { handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, diff --git a/src/renderer/style.css b/src/renderer/style.css index ecf4458..5c699ac 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -59,6 +59,10 @@ body { z-index: 1000; } +#jimakuModal { + z-index: 1100; +} + .modal.hidden { display: none; } diff --git a/src/subsync/utils.test.ts b/src/subsync/utils.test.ts index e97879e..21cc375 100644 --- a/src/subsync/utils.test.ts +++ b/src/subsync/utils.test.ts @@ -1,34 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { getSubsyncConfig, runCommand } from "./utils"; +import { codecToExtension } from "./utils"; -test("getSubsyncConfig applies fallback executable paths for blank values", () => { - const config = getSubsyncConfig({ - defaultMode: "manual", - alass_path: " ", - ffsubsync_path: "", - ffmpeg_path: undefined, - }); - - assert.equal(config.defaultMode, "manual"); - assert.equal(config.alassPath, "/usr/bin/alass"); - assert.equal(config.ffsubsyncPath, "/usr/bin/ffsubsync"); - assert.equal(config.ffmpegPath, "/usr/bin/ffmpeg"); +test("codecToExtension maps stream/web formats to ffmpeg extractable extensions", () => { + assert.equal(codecToExtension("subrip"), "srt"); + assert.equal(codecToExtension("webvtt"), "vtt"); + assert.equal(codecToExtension("vtt"), "vtt"); + assert.equal(codecToExtension("ttml"), "ttml"); }); -test("runCommand returns failure on timeout", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-utils-")); - const sleeperPath = path.join(tmpDir, "sleeper.sh"); - fs.writeFileSync(sleeperPath, "#!/bin/sh\nsleep 2\n", { - encoding: "utf8", - mode: 0o755, - }); - fs.chmodSync(sleeperPath, 0o755); - - const result = await runCommand(sleeperPath, [], 50); - - assert.equal(result.ok, false); +test("codecToExtension returns null for unsupported codecs", () => { + assert.equal(codecToExtension("unsupported-codec"), null); }); diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index 2d99019..a4e17e1 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -101,8 +101,16 @@ export function getTrackById( export function codecToExtension(codec: string | undefined): string | null { if (!codec) return null; const normalized = codec.toLowerCase(); - if (normalized === "subrip" || normalized === "srt") return "srt"; + if ( + normalized === "subrip" || + normalized === "srt" || + normalized === "text" || + normalized === "mov_text" + ) + return "srt"; if (normalized === "ass" || normalized === "ssa") return "ass"; + if (normalized === "webvtt" || normalized === "vtt") return "vtt"; + if (normalized === "ttml") return "ttml"; return null; } diff --git a/src/types.ts b/src/types.ts index ada44b7..2eff3b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -629,7 +629,7 @@ export interface ElectronAPI { ) => void; onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; - notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void; + notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; } diff --git a/subminer b/subminer index d346e7f..02b6c2e 100755 --- a/subminer +++ b/subminer @@ -47,9 +47,14 @@ const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( "subminer", "youtube-subs", ); +const DEFAULT_MPV_LOG_FILE = path.join( + os.homedir(), + ".cache", + "SubMiner", + "mp.log", +); const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best"; const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc"; -const DEFAULT_STREAM_PRIMARY_SUB_LANGS = ["ja", "jpn"]; const DEFAULT_MPV_SUBMINER_ARGS = [ "--sub-auto=fuzzy", "--sub-file-paths=.;subs;subtitles", @@ -534,9 +539,6 @@ interface Args { logLevel: LogLevel; target: string; targetKind: "" | "file" | "url"; - streamMode: boolean; - aniCliPath: string | null; - streamPrimarySubLangs: string[]; jimakuApiKey: string; jimakuApiKeyCommand: string; jimakuApiBaseUrl: string; @@ -584,14 +586,8 @@ const state = { appPath: "" as string, overlayManagedByLauncher: false, stopRequested: false, - streamSubtitleFiles: [] as string[], }; -interface ResolvedStreamTarget { - streamUrl: string; - subtitleUrl?: string; -} - interface MpvTrack { type?: string; id?: number; @@ -609,7 +605,6 @@ Options: -d, --directory DIR Directory to browse for videos (default: current directory) -r, --recursive Search for videos recursively -p, --profile PROFILE MPV profile to use (default: subminer) - -s, --stream Resolve stream URL via ani-cli using the given query --start Explicitly start SubMiner overlay --yt-subgen-mode MODE YouTube subtitle generation mode: automatic, preprocess, off (default: automatic) @@ -646,7 +641,6 @@ Examples: ${scriptName} video.mkv # Play specific file ${scriptName} https://youtu.be/... # Play a YouTube URL ${scriptName} ytsearch:query # Play first YouTube search result - ${scriptName} -s \"blue lock\" # Resolve and play Blue Lock stream via ani-cli ${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/... ${scriptName} video.mkv # Play with subminer profile ${scriptName} -p gpu-hq video.mkv # Play with gpu-hq profile @@ -673,10 +667,32 @@ function log(level: LogLevel, configured: LogLevel, message: string): void { process.stdout.write( `${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`, ); + appendToMpvLog(`[${level.toUpperCase()}] ${message}`); +} + +function getMpvLogPath(): string { + const envPath = process.env.SUBMINER_MPV_LOG?.trim(); + if (envPath) return envPath; + return DEFAULT_MPV_LOG_FILE; +} + +function appendToMpvLog(message: string): void { + const logPath = getMpvLogPath(); + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync( + logPath, + `[${new Date().toISOString()}] ${message}\n`, + { encoding: "utf8" }, + ); + } catch { + // ignore logging failures + } } function fail(message: string): never { process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); + appendToMpvLog(`[ERROR] ${message}`); process.exit(1); } @@ -1678,119 +1694,44 @@ function findAppBinary(selfPath: string): string | null { return null; } -function resolveAniCliPath(scriptPath: string): string | null { - const envPath = process.env.SUBMINER_ANI_CLI_PATH; - if (envPath && isExecutable(envPath)) return envPath; +function normalizeJimakuSearchInput(mediaPath: string): string { + const trimmed = (mediaPath || "").trim(); + if (!trimmed) return ""; + if (!/^https?:\/\/.*/.test(trimmed)) return trimmed; - const candidates: string[] = []; - candidates.push(path.resolve(path.dirname(realpathMaybe(scriptPath)), "ani-cli/ani-cli")); - - for (const candidate of candidates) { - if (isExecutable(candidate)) return candidate; - } - - if (commandExists("ani-cli")) return "ani-cli"; - - return null; -} - -function buildAniCliRofiConfig( - scriptPath: string, - logLevel: LogLevel, -): { env: NodeJS.ProcessEnv; cleanup: () => void } | null { - const themePath = findRofiTheme(scriptPath); - if (!themePath) { - return null; - } - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-ani-cli-rofi-")); - const configPath = path.join(tempDir, "rofi", "config.rasi"); try { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, `@theme "${themePath}"\n`); - } catch (error) { - fs.rmSync(tempDir, { force: true, recursive: true }); - throw new Error( - `Failed to prepare temporary ani-cli rofi theme config: ${(error as Error).message}`, - ); - } + const url = new URL(trimmed); + const titleParam = + url.searchParams.get("title") || url.searchParams.get("name") || + url.searchParams.get("q"); + if (titleParam && titleParam.trim()) return titleParam.trim(); - log("debug", logLevel, `Using Subminer rofi theme for ani-cli via ${configPath}`); - - return { - env: { - XDG_CONFIG_HOME: tempDir, - }, - cleanup: () => { - fs.rmSync(tempDir, { force: true, recursive: true }); - }, - }; -} - -function parseAniCliOutput(output: string): ResolvedStreamTarget { - const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const selectedIndex = lines.findIndex((line) => - line.startsWith("Selected link:"), - ); - const selectedBlock = - selectedIndex >= 0 - ? lines.slice(selectedIndex + 1) - : lines.slice(); - const targetCandidate = selectedBlock - .flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []) - .find((value) => value.length > 0); - if (!targetCandidate) { - throw new Error("Could not parse ani-cli stream URL from output."); - } - - const subtitleCandidate = lines - .find((line) => line.startsWith("subtitle >") || line.includes("subtitle >")) - ?.match(/https?:\/\/\S+/)?.[0]; - - return { - streamUrl: targetCandidate, - subtitleUrl: subtitleCandidate, - }; -} - -async function resolveStreamTarget( - query: string, - args: Args, - scriptPath: string, -): Promise { - const aniCliThemeConfig = buildAniCliRofiConfig(scriptPath, args.logLevel); - try { - const result = await runExternalCommand(args.aniCliPath as string, [query], { - captureStdout: true, - logLevel: args.logLevel, - commandLabel: "ani-cli", - streamOutput: false, - env: { - ANI_CLI_PLAYER: "debug", - ...aniCliThemeConfig?.env, - }, + const pathParts = url.pathname.split("/").filter(Boolean).reverse(); + const candidate = pathParts.find((part) => { + const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, ""); + const lowered = decoded.toLowerCase(); + return ( + lowered.length > 2 && + !/^[0-9.]+$/.test(lowered) && + !/^[a-f0-9]{16,}$/i.test(lowered) + ); }); - const parsed = parseAniCliOutput(result.stdout); - if (!parsed.streamUrl.startsWith("http://") && !parsed.streamUrl.startsWith("https://")) { - throw new Error( - `Ani-cli output stream URL is invalid: ${parsed.streamUrl}`, - ); - } - log("info", args.logLevel, `Resolved stream target: ${parsed.streamUrl}`); - if (parsed.subtitleUrl) { - log( - "debug", - args.logLevel, - `Resolved stream subtitle URL: ${parsed.subtitleUrl}`, - ); - } - return parsed; - } finally { - aniCliThemeConfig?.cleanup(); + const fallback = candidate || url.hostname.replace(/^www\./, ""); + return sanitizeJimakuQueryInput(decodeURIComponent(fallback)); + } catch { + return trimmed; } } +function sanitizeJimakuQueryInput(value: string): string { + return value + .replace(/^\s*-\s*/, "") + .replace(/[^\w\s\-'".:(),]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + function buildJimakuConfig(args: Args): { apiKey: string; apiKeyCommand: string; @@ -1807,126 +1748,11 @@ function buildJimakuConfig(args: Args): { }; } -async function resolveJimakuSubtitle( - args: Args, - mediaQuery: string, -): Promise { - const config = buildJimakuConfig(args); - if (!config.apiKey && !config.apiKeyCommand) return null; - const mediaInfo = parseMediaInfo(`${mediaQuery}.mkv`); - const searchQuery = mediaInfo.title || mediaQuery || "anime episode"; - const apiKey = await resolveJimakuApiKey(config); - if (!apiKey) return null; - - const searchResponse = await jimakuFetchJson( - "/api/entries/search", - { - anime: true, - query: searchQuery, - limit: config.maxEntryResults, - }, - { - baseUrl: config.apiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, - apiKey, - }, - ); - - if (!searchResponse.ok || searchResponse.data.length === 0) return null; - - const filesResponse = await jimakuFetchJson( - `/api/entries/${searchResponse.data[0].id}/files`, - { - episode: mediaInfo.episode, - }, - { - baseUrl: config.apiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, - apiKey, - }, - ); - if (!filesResponse.ok || filesResponse.data.length === 0) return null; - - const sortedFiles = sortJimakuFiles( - filesResponse.data, - config.languagePreference, - ); - const selectedFile = - sortedFiles.find((entry) => isValidSubtitleCandidateFile(entry.name)) ?? - sortedFiles[0]; - if (!selectedFile) return null; - - const extension = path.extname(selectedFile.name).toLowerCase() || ".srt"; - const tempFile = path.join( - makeTempDir("subminer-jimaku-stream-"), - `${Date.now()}-stream-subtitle${extension}`, - ); - - const result = await downloadToFile( - selectedFile.url, - tempFile, - { Authorization: apiKey }, - ); - if (!result.ok) return null; - state.streamSubtitleFiles.push(result.path); - return result.path; -} - -async function ensurePrimaryStreamSubtitle( - socketPath: string, - args: Args, - mediaQuery: string, -): Promise { - const preferredLanguages = uniqueNormalizedLangCodes( - args.streamPrimarySubLangs.length > 0 - ? args.streamPrimarySubLangs - : mapPreferenceToLanguages(args.jimakuLanguagePreference), - ); - const tracks = await waitForSubtitleTrackList(socketPath, args.logLevel); - const preferredTrack = findPreferredSubtitleTrack(tracks, preferredLanguages); - - if (preferredTrack?.id !== undefined) { - await sendMpvCommand( - socketPath, - ["set_property", "sid", preferredTrack.id], - ); - log( - "info", - args.logLevel, - `Selected existing stream subtitle track: ${preferredTrack.lang || preferredTrack.title || preferredTrack.id}`, - ); - return; - } - - const jimakuPath = await resolveJimakuSubtitle(args, mediaQuery); - if (!jimakuPath) { - log( - "warn", - args.logLevel, - "No matching stream subtitle track found and no Jimaku fallback available.", - ); - return; - } - - try { - await loadSubtitleIntoMpv(socketPath, jimakuPath, true, args.logLevel); - log("info", args.logLevel, `Loaded Jimaku subtitle fallback: ${path.basename(jimakuPath)}`); - } catch (error) { - log( - "warn", - args.logLevel, - `Failed to load Jimaku fallback subtitle: ${(error as Error).message}`, - ); - } -} - function checkDependencies(args: Args): void { const missing: string[] = []; if (!commandExists("mpv")) missing.push("mpv"); - if (args.streamMode) { - if (!args.aniCliPath) missing.push("ani-cli"); - } - if ( args.targetKind === "url" && isYoutubeTarget(args.target) && @@ -2296,12 +2122,6 @@ function parseArgs( const configuredPrimaryLangs = uniqueNormalizedLangCodes( launcherConfig.primarySubLanguages ?? [], ); - const envStreamPrimaryLangs = uniqueNormalizedLangCodes( - (process.env.SUBMINER_STREAM_PRIMARY_SUB_LANGS || "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean), - ); const primarySubLangs = configuredPrimaryLangs.length > 0 ? configuredPrimaryLangs @@ -2328,12 +2148,6 @@ function parseArgs( process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || "m4a", youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === "1", - streamMode: false, - aniCliPath: "", - streamPrimarySubLangs: - envStreamPrimaryLangs.length > 0 - ? envStreamPrimaryLangs - : [...DEFAULT_STREAM_PRIMARY_SUB_LANGS], jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || "", jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || "", jimakuApiBaseUrl: @@ -2364,11 +2178,6 @@ function parseArgs( if (launcherConfig.jimakuMaxEntryResults !== undefined) parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; - parsed.streamPrimarySubLangs = uniqueNormalizedLangCodes([ - ...parsed.streamPrimarySubLangs, - ...mapPreferenceToLanguages(parsed.jimakuLanguagePreference), - ]); - const isValidLogLevel = (value: string): value is LogLevel => value === "debug" || value === "info" || @@ -2416,12 +2225,6 @@ function parseArgs( continue; } - if (arg === "-s" || arg === "--stream") { - parsed.streamMode = true; - i += 1; - continue; - } - if (arg === "--start") { parsed.startOverlay = true; i += 1; @@ -2599,10 +2402,7 @@ function parseArgs( const positional = argv.slice(i); if (positional.length > 0) { const target = positional[0]; - if (parsed.streamMode) { - parsed.target = target; - parsed.targetKind = "url"; - } else if (isUrlTarget(target)) { + if (isUrlTarget(target)) { parsed.target = target; parsed.targetKind = "url"; } else { @@ -2642,7 +2442,10 @@ function startOverlay( overlayArgs.push("--log-level", args.logLevel); if (args.useTexthooker) overlayArgs.push("--texthooker"); - state.overlayProc = spawn(appPath, overlayArgs, { stdio: "inherit" }); + state.overlayProc = spawn(appPath, overlayArgs, { + stdio: "inherit", + env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() }, + }); state.overlayManagedByLauncher = true; return new Promise((resolve) => { @@ -2703,18 +2506,6 @@ function stopOverlay(args: Args): void { } state.youtubeSubgenChildren.clear(); - for (const subtitleFile of state.streamSubtitleFiles) { - try { - fs.rmSync(subtitleFile, { force: true }); - fs.rmSync(path.dirname(subtitleFile), { - force: true, - recursive: true, - }); - } catch { - // ignore - } - } - state.streamSubtitleFiles = []; } function parseBoolLike(value: string): boolean | null { @@ -2849,30 +2640,33 @@ function startMpv( mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); if (targetKind === "url" && isYoutubeTarget(target)) { - const subtitleLangs = uniqueNormalizedLangCodes([ - ...args.youtubePrimarySubLangs, - ...args.youtubeSecondarySubLangs, - ]).join(","); - const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(","); - log("info", args.logLevel, "Applying YouTube playback options"); - log("debug", args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); - log("debug", args.logLevel, `YouTube audio langs: ${audioLangs}`); - mpvArgs.push( - "--ytdl=yes", - `--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, - "--ytdl-raw-options=", - `--alang=${audioLangs}`, - ); + log("info", args.logLevel, "Applying URL playback options"); + mpvArgs.push("--ytdl=yes", "--ytdl-raw-options="); - if (args.youtubeSubgenMode === "off") { + if (isYoutubeTarget(target)) { + const subtitleLangs = uniqueNormalizedLangCodes([ + ...args.youtubePrimarySubLangs, + ...args.youtubeSecondarySubLangs, + ]).join(","); + const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(","); + log("info", args.logLevel, "Applying YouTube playback options"); + log("debug", args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); + log("debug", args.logLevel, `YouTube audio langs: ${audioLangs}`); mpvArgs.push( - "--sub-auto=fuzzy", - `--slang=${subtitleLangs}`, - "--ytdl-raw-options-append=write-auto-subs=", - "--ytdl-raw-options-append=write-subs=", - "--ytdl-raw-options-append=sub-format=vtt/best", - `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, + `--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, + `--alang=${audioLangs}`, ); + + if (args.youtubeSubgenMode === "off") { + mpvArgs.push( + "--sub-auto=fuzzy", + `--slang=${subtitleLangs}`, + "--ytdl-raw-options-append=write-auto-subs=", + "--ytdl-raw-options-append=write-subs=", + "--ytdl-raw-options-append=sub-format=vtt/best", + `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, + ); + } } } @@ -2882,6 +2676,7 @@ function startMpv( if (preloadedSubtitles?.secondaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); } + mpvArgs.push(`--log-file=${getMpvLogPath()}`); try { fs.rmSync(socketPath, { force: true }); @@ -2966,24 +2761,15 @@ async function main(): Promise { } if (!args.target) { - if (args.streamMode) { - fail("Stream mode requires a search query argument."); - } checkPickerDependencies(args); } - const targetChoice = args.streamMode - ? null - : await chooseTarget(args, process.argv[1] || "subminer"); - if (!targetChoice && !args.streamMode) { + const targetChoice = await chooseTarget(args, process.argv[1] || "subminer"); + if (!targetChoice) { log("info", args.logLevel, "No video selected, exiting"); process.exit(0); } - if (args.streamMode) { - args.aniCliPath = resolveAniCliPath(scriptPath); - } - checkDependencies({ ...args, target: targetChoice ? targetChoice.target : args.target, @@ -2998,28 +2784,6 @@ async function main(): Promise { kind: targetChoice.kind as "file" | "url", } : { target: args.target, kind: "url" as const }; - let resolvedStreamTarget: ResolvedStreamTarget | null = null; - const streamSource = selectedTarget.target; - - if (args.streamMode) { - log("info", args.logLevel, `Resolving stream target via ani-cli for "${selectedTarget.target}"`); - resolvedStreamTarget = await resolveStreamTarget( - selectedTarget.target, - args, - scriptPath, - ); - selectedTarget = { - target: resolvedStreamTarget.streamUrl, - kind: "url", - }; - if (resolvedStreamTarget.subtitleUrl) { - log( - "debug", - args.logLevel, - `ani-cli provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`, - ); - } - } const isYoutubeUrl = selectedTarget.kind === "url" && isYoutubeTarget(selectedTarget.target); @@ -3085,17 +2849,6 @@ async function main(): Promise { } const ready = await waitForSocket(mpvSocketPath); - if (args.streamMode && ready) { - await ensurePrimaryStreamSubtitle(mpvSocketPath, args, streamSource).catch( - (error) => { - log( - "warn", - args.logLevel, - `Stream subtitle setup failed: ${(error as Error).message}`, - ); - }, - ); - } const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay; if (shouldStartOverlay) {