4 Commits

Author SHA1 Message Date
f9a4039ad2 chore: add backlog task records 2026-03-30 00:30:18 -07:00
8e5c21b443 fix: restore integrated texthooker startup 2026-03-30 00:25:30 -07:00
55b350c3a2 Fix AniList token persistence and AVIF timing
- Defer AniList setup prompts until app-ready and reuse stored tokens
- Add AVIF lead-in padding so motion stays aligned with sentence audio
2026-03-29 22:07:15 -07:00
54324df3be fix(release): make AUR publish best-effort 2026-03-29 16:51:22 -07:00
35 changed files with 793 additions and 86 deletions

View File

@@ -409,33 +409,64 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Validate AUR SSH secret - name: Check AUR publish prerequisites
id: aur_prereqs
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "Missing required secret: AUR_SSH_PRIVATE_KEY" echo "::warning::Missing AUR_SSH_PRIVATE_KEY; skipping automated AUR publish."
exit 1 echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Configure SSH for AUR - name: Configure SSH for AUR
id: aur_ssh
if: steps.aur_prereqs.outputs.skip != 'true'
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: | run: |
set -euo pipefail set -euo pipefail
install -dm700 ~/.ssh if install -dm700 ~/.ssh \
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur && printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur \
chmod 600 ~/.ssh/aur && chmod 600 ~/.ssh/aur \
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts && ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts \
chmod 644 ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::Unable to configure SSH for AUR; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Clone AUR repo - name: Clone AUR repo
id: aur_clone
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true'
env: env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin run: |
set -euo pipefail
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
rm -rf aur-subminer-bin
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to clone subminer-bin from AUR after ${attempts} attempts; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Download release assets for AUR - name: Download release assets for AUR
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -449,6 +480,7 @@ jobs:
--pattern "subminer-assets.tar.gz" --pattern "subminer-assets.tar.gz"
- name: Update AUR packaging metadata - name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
run: | run: |
set -euo pipefail set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}" version_no_v="${{ steps.version.outputs.VERSION }}"
@@ -463,6 +495,7 @@ jobs:
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz" --assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
- name: Commit and push AUR update - name: Commit and push AUR update
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
working-directory: aur-subminer-bin working-directory: aur-subminer-bin
env: env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
@@ -476,4 +509,16 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}" git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
git push origin HEAD:master
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git push origin HEAD:master; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to push the AUR update after ${attempts} attempts; GitHub release is published, but subminer-bin needs manual follow-up."

View File

@@ -0,0 +1,35 @@
---
id: TASK-252
title: Harden AUR publish release step against transient SSH failures
status: Done
assignee: []
created_date: '2026-03-29 23:46'
updated_date: '2026-03-29 23:49'
labels:
- release
- ci
- aur
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make tagged releases resilient when the automated AUR update hits transient SSH disconnects from GitHub-hosted runners. The GitHub Release should still complete successfully, while AUR publish should retry a few times and downgrade persistent AUR failures to warnings instead of failing the entire release workflow.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tagged release workflow retries the AUR clone/push path with bounded backoff when AUR SSH disconnects transiently.
- [x] #2 Persistent AUR publish failure does not fail the overall tagged release workflow or block GitHub Release publication.
- [x] #3 Release documentation notes that AUR publish is best-effort and may need manual follow-up when retries are exhausted.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated .github/workflows/release.yml so AUR secret/configure/clone/push failures downgrade to warnings, clone/push retry three times with linear backoff, and the GitHub Release path remains green.
Documented AUR publish as best-effort in docs/RELEASING.md and added changes/253-aur-release-best-effort.md for PR changelog compliance.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,68 @@
---
id: TASK-253
title: Fix animated AVIF lead-in alignment with sentence audio
status: Done
assignee:
- codex
created_date: '2026-03-30 01:59'
updated_date: '2026-03-30 02:03'
labels: []
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/anki-integration/animated-image-sync.ts
- /Users/sudacode/projects/japanese/SubMiner/src/anki-integration.ts
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
- /Users/sudacode/projects/japanese/SubMiner/src/media-generator.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Animated AVIF cards currently freeze only for the existing word-audio duration. Because generated sentence audio starts with configured audio padding before the spoken subtitle begins, animation motion can begin early instead of lining up with the spoken sentence. Update the shared lead-in calculation so animated motion begins when sentence speech begins after the chosen word audio finishes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Animated AVIF lead-in calculation includes both the chosen word-audio duration and the generated sentence-audio start offset so motion begins with spoken sentence audio
- [x] #2 Shared animated-image sync behavior is applied consistently across the Anki note update, card creation, and stats server media-generation paths
- [x] #3 Regression tests cover the corrected lead-in timing calculation and fail before the fix
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Approved plan:
1. Add a failing unit test proving animated-image lead-in must include sentence-audio start offset in addition to chosen word-audio duration.
2. Update shared animated-image lead-in resolution to add the configured sentence-audio offset used by generated sentence audio.
3. Thread the shared calculation through note update, card creation, and stats-server generation paths without duplicating timing logic.
4. Run targeted tests first, then the relevant fast verification lane for touched files.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved implementation on 2026-03-29 local time. Root cause: lead-in omitted sentence-audio padding offset, so AVIF motion began before spoken sentence audio.
Implemented shared animated-image lead-in fix in src/anki-integration/animated-image-sync.ts by adding the same sentence-audio start offset used by generated audio (`audioPadding`) after summing the chosen word-audio durations.
Added regression coverage in src/anki-integration/animated-image-sync.test.ts for explicit `audioPadding` lead-in alignment and kept the zero-padding case covered.
Verification passed: `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed animated AVIF lead-in alignment so motion starts when the spoken sentence starts, not at the padded beginning of the generated sentence-audio clip. The shared resolver in `src/anki-integration/animated-image-sync.ts` now adds the configured/default `audioPadding` offset after summing the selected word-audio durations, which keeps note update, card creation, and stats-server generation paths aligned through the same logic.
Added regression coverage in `src/anki-integration/animated-image-sync.test.ts` for both zero-padding and explicit padding cases to prove the lead-in math matches sentence-audio timing.
Verification:
- `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-254
title: Fix AniList token persistence when safe storage is unavailable
status: Done
assignee:
- codex
created_date: '2026-03-30 02:10'
updated_date: '2026-03-30 02:20'
labels:
- bug
- anilist
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-token-store.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-setup.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-token-refresh.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList login currently appears to succeed during setup, but some environments cannot persist the token because Electron safeStorage is unavailable or unusable. On the next app start, AniList tracking cannot load the token and re-prompts the user to set up AniList again. Align AniList token persistence with the intended login UX so a token the user already saved is reused on later sessions.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Saved encrypted AniList token is reused on app-ready startup without reopening setup.
- [x] #2 AniList startup no longer attempts to open the setup BrowserWindow before Electron is ready.
- [x] #3 AniList auth/runtime tests cover stored-token reuse and the missing-token startup path that previously triggered pre-ready setup attempts.
- [x] #4 AniList token storage remains encrypted-only; no plaintext fallback is introduced.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests for AniList startup auth refresh so a stored encrypted token is reused without opening setup, and for the missing-token path so setup opening is deferred safely until the app can actually show a window.
2. Update AniList startup/auth runtime to separate token resolution from setup-window prompting, and gate prompting on app readiness instead of attempting BrowserWindow creation during early startup.
3. Preserve encrypted-only storage semantics in anilist-token-store; do not add plaintext fallback. If stored-token load fails, keep logging/diagnostics intact.
4. Run targeted AniList runtime/token tests, then summarize root cause and verification results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigated AniList auth persistence flow. Current setup path treats callback token as saved even when anilist-token-store refuses persistence because safeStorage is unavailable. Jellyfin token store already uses plaintext fallback in this environment class, which is a likely model for the AniList fix.
Confirmed from local logs that safeStorage was explicitly unavailable on 2026-03-23 due macOS Keychain lookup failure with NSOSStatusErrorDomain Code=-128 userCanceledErr. Current environment also has an encrypted AniList token file at /Users/sudacode/.config/SubMiner/anilist-token-store.json updated 2026-03-29 18:49, so safeStorage did work recently for save. Repeated AniList setup prompts on 2026-03-29/30 correlate more strongly with startup auth flow deciding no token is available and opening setup immediately; logs show repeated 'Loaded AniList manual token entry page' and several 'Failed to refresh AniList client secret state during startup' errors with 'Cannot create BrowserWindow before app is ready'. No recent log lines indicate safeStorage.isEncryptionAvailable() false after 2026-03-23.
Implemented encrypted-only startup fix by adding an allowSetupPrompt control to AniList token refresh and disabling setup-window prompting for the early pre-ready startup refresh in main.ts. App-ready reloadConfig still performs the normal prompt-capable refresh after Electron is ready. Added regression tests for stored-token reuse and prompt suppression when startup explicitly disables prompting.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Root cause was a redundant early AniList auth refresh during startup. The app refreshed AniList auth once before Electron was ready and again during app-ready config reload. When the early refresh could not resolve a token, it tried to open the AniList setup window immediately, which produced the observed 'Cannot create BrowserWindow before app is ready' failures and repeated setup prompts. The fix keeps token storage encrypted-only, teaches AniList auth refresh to optionally suppress setup-window prompting, and uses that suppression for the early startup refresh. App-ready startup still performs the normal prompt-capable refresh once Electron is ready, so saved encrypted tokens are reused without reopening setup and missing-token setup only happens at a safe point. Verified with targeted AniList auth tests, typecheck, test:fast, test:env, build, and test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,29 @@
---
id: TASK-255
title: Add overlay playlist browser modal for sibling video files and mpv queue
status: To Do
assignee: []
created_date: '2026-03-30 05:46'
labels:
- feature
- overlay
- mpv
- launcher
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add an in-session overlay modal that opens from a keybinding during active playback and lets the user browse video files from the current file's parent directory alongside the active mpv playlist. The modal should sort local files in best-effort episode order, highlight the current item, and allow keyboard/mouse interaction to add files into the mpv queue, remove queued items, and reorder queued items without leaving playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 An overlay modal can be opened during active playback from a dedicated keybinding and closed without disrupting existing modal behavior.
- [ ] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present.
- [ ] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order.
- [ ] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions.
- [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
<!-- AC:END -->

View File

@@ -0,0 +1,56 @@
---
id: TASK-256
title: Fix texthooker page live websocket connect/send regression
status: Done
assignee:
- codex
created_date: '2026-03-30 06:04'
updated_date: '2026-03-30 06:12'
labels:
- bug
- texthooker
- websocket
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate why the bundled texthooker page loads at the local HTTP endpoint but does not reliably connect to the configured websocket feed or receive/display live subtitle lines. Identify the regression in the SubMiner startup/bootstrap or vendored texthooker client path, restore live line delivery, and cover the fix with focused regression tests and any required docs updates.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Bundled texthooker connects to the intended websocket endpoint on launch using the configured/default SubMiner startup path.
- [x] #2 Incoming subtitle or annotation websocket messages are accepted by the bundled texthooker and rendered as live lines.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the identified breakage.
- [x] #4 Relevant docs/config notes are updated if user-facing behavior or troubleshooting guidance changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused CLI regression test covering `--texthooker` startup when the runtime has a resolved websocket URL, proving the handler currently starts texthooker without that URL.
2. Extend CLI texthooker dependencies/runtime wiring so the handler can retrieve the resolved texthooker websocket URL from current config/runtime state.
3. Update the CLI texthooker flow to pass the resolved websocket URL into texthooker startup instead of starting the HTTP server with only a port.
4. Run focused tests for CLI command handling and texthooker bootstrap behavior; update task notes/final summary with the verified root cause and fix.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: the CLI `--texthooker` path started the HTTP texthooker server with only the port, so the served page never received `bannou-texthooker-websocketUrl` and fell back to the vendored default `ws://localhost:6677`. In environments where the regular websocket was skipped or the annotation websocket should have been used, the page stayed on `Connecting...` and never received lines.
Fix: added a shared `resolveTexthookerWebsocketUrl(...)` helper for websocket selection, reused it in both app-ready startup and CLI texthooker context wiring, and threaded the resolved websocket URL through `handleCliCommand` into `Texthooker.start(...)`.
Verification: `bun run typecheck`; focused Bun tests for texthooker bootstrap, startup, CLI command handling, and CLI context wiring; browser-level repro against a throwaway source-backed texthooker server confirmed the page bootstraps `ws://127.0.0.1:6678`, connects successfully, and renders live sample lines (`テスト一`, `テスト二`).
Docs: no user-facing behavior change beyond restoring the intended existing behavior, so no docs update was required.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored texthooker live line delivery for the CLI/startup path that launched the page without a resolved websocket URL. Shared websocket URL resolution between app-ready startup and CLI texthooker context, forwarded that URL into `Texthooker.start(...)`, added regression coverage for the CLI path, and verified both by focused tests and a browser-level throwaway server that connected on `ws://127.0.0.1:6678` and rendered live sample lines.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-257
title: Fix texthooker-only mode startup to initialize websocket pipeline
status: Done
assignee:
- codex
created_date: '2026-03-30 06:15'
updated_date: '2026-03-30 06:17'
labels:
- bug
- texthooker
- websocket
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix `--texthooker` / `subminer texthooker` startup so it launches the texthooker page without the overlay window but still initializes the runtime pieces required for live subtitle delivery. Today texthooker-only mode serves the page yet skips mpv client and websocket startup, leaving the page pointed at `ws://127.0.0.1:6678` with no listener behind it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--texthooker` mode starts the texthooker page without opening the overlay window and still initializes the websocket path needed for live subtitle delivery.
- [x] #2 Texthooker-only startup creates the mpv/websocket runtime needed for the configured annotation or subtitle websocket feed.
- [x] #3 Regression coverage fails before the fix and passes after the fix for texthooker-only startup.
- [x] #4 Docs/help text remain accurate for texthooker-only behavior; update docs only if wording needs correction.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Replace the existing texthooker-only startup regression test so it asserts websocket/mpv startup still happens while overlay window initialization stays skipped.
2. Remove or narrow the early texthooker-only short-circuit in app-ready startup so runtime config, mpv client, subtitle websocket, and annotation websocket still initialize.
3. Run focused tests plus a local process check proving `--texthooker` now opens the websocket listener expected by the served page.
4. Update task notes/final summary with the live-process root cause (`--texthooker` serving HTML on 5174 with no 6678 listener).
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Live-process repro on the user's machine: `ps` showed the active process as `/tmp/.mount_SubMin.../SubMiner --texthooker --port 5174`. `lsof` showed 5174 listening but no listener on 6678/6677, while `curl http://127.0.0.1:5174/` confirmed the served page was correctly bootstrapped to `ws://127.0.0.1:6678`. That proved the remaining failure was startup mode, not page injection.
Root cause: `runAppReadyRuntime(...)` had an early `texthookerOnlyMode` return that reloaded config and handled initial args, but skipped `createMpvClient()`, subtitle websocket startup, annotation websocket startup, subtitle timing tracker creation, and the later texthooker-only branch that only skips the overlay window.
Fix: removed the early texthooker-only short-circuit so texthooker-only mode now runs the normal startup pipeline, then falls through to the existing `Texthooker-only mode enabled; skipping overlay window.` branch.
Verification: `bun run typecheck`; focused Bun tests for app-ready startup, startup bootstrap, CLI texthooker startup, and CLI context wiring. Existing local live-binary repro still reflects the old mounted AppImage until rebuilt/restarted. Current-binary workaround is to launch normal startup / `--start --texthooker` instead of plain `--texthooker`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the second texthooker regression: plain `--texthooker` mode was serving the page but skipping mpv/websocket initialization, so the page pointed at `ws://127.0.0.1:6678` with no listener. Removed the early texthooker-only startup return, kept the later overlay-skip behavior, updated the startup regression test to require websocket/mpv initialization in texthooker-only mode, and re-verified with typecheck plus focused test coverage.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-258
title: Stop plugin auto-start from spawning separate texthooker helper
status: Done
assignee:
- codex
created_date: '2026-03-30 06:25'
updated_date: '2026-03-30 06:26'
labels:
- bug
- texthooker
- launcher
- plugin
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Change the mpv/plugin auto-start path so normal SubMiner startup owns texthooker and websocket startup inside the main `--start` app instance. Keep standalone `subminer texthooker` / plain `--texthooker` available for explicit external use, but stop the plugin from spawning a second helper subprocess during regular auto-start.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Plugin auto-start includes texthooker on the main `--start` command when texthooker is enabled.
- [x] #2 Plugin auto-start no longer spawns a separate standalone `--texthooker` helper subprocess during normal startup.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the plugin auto-start path.
- [x] #4 Standalone external `subminer texthooker` / plain `--texthooker` entrypoints remain available for explicit helper use.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Flip the mpv/plugin start-gate regression so enabled texthooker is folded into the main `--start` command and standalone helper subprocesses are rejected.
2. Update plugin process command construction so `start` includes `--texthooker` when enabled and the separate helper-launch path becomes a no-op for normal auto-start.
3. Run plugin Lua regressions, adjacent launcher tests, and typecheck to verify behavior and preserve explicit standalone `--texthooker` entrypoints.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Design approved by user: normal in-app startup should own texthooker/websocket; `texthookerOnlyMode` should stay explicit external-only.
Root cause path: mpv/plugin auto-start in `plugin/subminer/process.lua` launched `binary_path --start ...` and then separately spawned `binary_path --texthooker --port ...`. That created the standalone helper process observed live (`SubMiner --texthooker --port 5174`) instead of relying on the normal app instance.
Fix: `build_command_args('start', overrides)` now appends `--texthooker` when texthooker is enabled, and the old helper-launch path is reduced to a no-op so normal auto-start remains single-process.
Verification: `lua scripts/test-plugin-start-gate.lua`, `lua scripts/test-plugin-process-start-retries.lua`, `bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts launcher/config/args-normalizer.test.ts`, and `bun run typecheck`. Standalone launcher/app entrypoints for explicit `subminer texthooker` / plain `--texthooker` were left untouched.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stopped the mpv/plugin auto-start path from spawning a second standalone texthooker helper. Texthooker now rides on the main `--start` app instance for normal startup, with Lua regressions updated to require `--texthooker` on the main start command and reject separate helper subprocesses. Explicit standalone `subminer texthooker` / plain `--texthooker` entrypoints remain available.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,33 @@
---
id: TASK-259
title: Fix integrated --start --texthooker startup skipping texthooker server
status: Done
assignee: []
created_date: '2026-03-30 06:48'
updated_date: '2026-03-30 06:56'
labels:
- bug
- texthooker
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Integrated overlay startup with `--start --texthooker` currently takes the minimal-startup path because startup mode flags treat any `args.texthooker` as texthooker-only. That skips app-ready texthooker service startup, so no server binds on port 5174 during normal SubMiner playback launches.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--start --texthooker` uses full app-ready startup instead of minimal texthooker-only startup
- [x] #2 Integrated playback launch starts the texthooker server on the configured/default port
- [x] #3 Regression tests cover the startup-mode classification and integrated startup behavior
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Narrowed texthooker-only startup classification so integrated `--start --texthooker` no longer takes the minimal-startup path. Added CLI arg regression coverage, rebuilt the AppImage, installed it to `~/.local/bin/SubMiner.AppImage` with a timestamped backup, restarted against `/tmp/subminer-socket`, and verified listeners on 5174/6677/6678 plus browser connection state `Connected with ws://127.0.0.1:6678`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Retried AUR clone and push operations in the tagged release workflow.
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.

View File

@@ -0,0 +1,5 @@
type: fixed
area: main
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.

View File

@@ -34,4 +34,5 @@ Notes:
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -191,6 +191,14 @@ function M.create(ctx)
else else
table.insert(args, "--hide-visible-overlay") table.insert(args, "--hide-visible-overlay")
end end
local texthooker_enabled = overrides.texthooker_enabled
if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled
end
if texthooker_enabled then
table.insert(args, "--texthooker")
end
end end
return args return args
@@ -242,50 +250,10 @@ function M.create(ctx)
return overrides return overrides
end end
local function build_texthooker_args()
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
local log_level = normalize_log_level(opts.log_level)
if log_level ~= "info" then
table.insert(args, "--log-level")
table.insert(args, log_level)
end
return args
end
local function ensure_texthooker_running(callback) local function ensure_texthooker_running(callback)
if not opts.texthooker_enabled then if callback then
callback() callback()
return
end end
if state.texthooker_running then
callback()
return
end
local args = build_texthooker_args()
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
state.texthooker_running = true
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.texthooker_running = false
subminer_log(
"warn",
"texthooker",
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
)
end
end)
-- Start overlay immediately; overlay start path retries on readiness failures.
callback()
end end
local function start_overlay(overrides) local function start_overlay(overrides)

View File

@@ -664,8 +664,8 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
local texthooker_call = find_texthooker_call(recorded.async_calls) assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled") assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
assert_true( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should include --show-visible-overlay on --start" "auto-start with visible overlay enabled should include --show-visible-overlay on --start"
@@ -678,10 +678,6 @@ do
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" "auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
) )
assert_true(
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
"auto-start should launch --start before separate --texthooker helper startup"
)
assert_true( assert_true(
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option" "auto-start visible overlay should not force pause without explicit pause-until-ready option"

View File

@@ -19,6 +19,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
media: { media: {
imageType: 'avif', imageType: 'avif',
syncAnimatedImageToWordAudio: true, syncAnimatedImageToWordAudio: true,
audioPadding: 0,
}, },
}, },
noteInfo: { noteInfo: {
@@ -49,6 +50,46 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25); assert.equal(leadInSeconds, 1.25);
}); });
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.75);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => { test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: { config: {

View File

@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false; return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
} }
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
const configuredPadding = config.media?.audioPadding;
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
return configuredPadding;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
}
export async function probeAudioDurationSeconds( export async function probeAudioDurationSeconds(
buffer: Buffer, buffer: Buffer,
filename: string, filename: string,
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds; totalLeadInSeconds += durationSeconds;
} }
return totalLeadInSeconds; return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
} }

View File

@@ -5,6 +5,7 @@ import {
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
hasExplicitCommand, hasExplicitCommand,
isHeadlessInitialCommand, isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
@@ -79,6 +80,14 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
assert.equal(commandNeedsOverlayStartupPrereqs(args), true); assert.equal(commandNeedsOverlayStartupPrereqs(args), true);
}); });
test('standalone texthooker classification excludes integrated start flow', () => {
const standalone = parseArgs(['--texthooker']);
const integrated = parseArgs(['--start', '--texthooker']);
assert.equal(isStandaloneTexthookerCommand(standalone), true);
assert.equal(isStandaloneTexthookerCommand(integrated), false);
});
test('parseArgs handles jellyfin item listing controls', () => { test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([ const args = parseArgs([
'--jellyfin-items', '--jellyfin-items',

View File

@@ -397,6 +397,54 @@ export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords; return args.refreshKnownWords;
} }
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
return (
args.texthooker &&
!args.background &&
!args.start &&
!Boolean(args.youtubePlay) &&
!args.launchMpv &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.settings &&
!args.setup &&
!args.show &&
!args.hide &&
!args.showVisibleOverlay &&
!args.hideVisibleOverlay &&
!args.copySubtitle &&
!args.copySubtitleMultiple &&
!args.mineSentence &&
!args.mineSentenceMultiple &&
!args.updateLastCardFromClipboard &&
!args.refreshKnownWords &&
!args.toggleSecondarySub &&
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.openRuntimeOptions &&
!args.anilistStatus &&
!args.anilistLogout &&
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
!args.jellyfinLogout &&
!args.jellyfinLibraries &&
!args.jellyfinItems &&
!args.jellyfinSubtitles &&
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
);
}
export function shouldStartApp(args: CliArgs): boolean { export function shouldStartApp(args: CliArgs): boolean {
if (args.stop && !args.start) return false; if (args.stop && !args.start) return false;
if ( if (

View File

@@ -176,7 +176,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
}); });
test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async () => { test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
texthookerOnlyMode: true, texthookerOnlyMode: true,
reloadConfig: () => calls.push('reloadConfig'), reloadConfig: () => calls.push('reloadConfig'),
@@ -185,7 +185,16 @@ test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async (
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']); assert.ok(calls.includes('reloadConfig'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('log:Texthooker-only mode enabled; skipping overlay window.'));
assert.equal(calls.includes('initializeOverlayRuntime'), false);
assert.equal(calls.includes('setVisibleOverlayVisible:true'), false);
}); });
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -62,6 +62,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
let mpvSocketPath = '/tmp/subminer.sock'; let mpvSocketPath = '/tmp/subminer.sock';
let texthookerPort = 5174; let texthookerPort = 5174;
const osd: string[] = []; const osd: string[] = [];
let texthookerWebsocketUrl: string | undefined;
const deps: CliCommandServiceDeps = { const deps: CliCommandServiceDeps = {
getMpvSocketPath: () => mpvSocketPath, getMpvSocketPath: () => mpvSocketPath,
@@ -82,9 +83,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push(`setTexthookerPort:${port}`); calls.push(`setTexthookerPort:${port}`);
}, },
getTexthookerPort: () => texthookerPort, getTexthookerPort: () => texthookerPort,
getTexthookerWebsocketUrl: () => texthookerWebsocketUrl,
shouldOpenTexthookerBrowser: () => true, shouldOpenTexthookerBrowser: () => true,
ensureTexthookerRunning: (port) => { ensureTexthookerRunning: (port, websocketUrl) => {
calls.push(`ensureTexthookerRunning:${port}`); calls.push(`ensureTexthookerRunning:${port}:${websocketUrl ?? ''}`);
}, },
openTexthookerInBrowser: (url) => { openTexthookerInBrowser: (url) => {
calls.push(`openTexthookerInBrowser:${url}`); calls.push(`openTexthookerInBrowser:${url}`);
@@ -354,10 +356,20 @@ test('handleCliCommand runs texthooker flow with browser open', () => {
handleCliCommand(args, 'initial', deps); handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174')); assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
}); });
test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
const { deps, calls } = createDeps({
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
});
handleCliCommand(makeArgs({ texthooker: true }), 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174:ws://127.0.0.1:6678'));
});
test('handleCliCommand reports async mine errors to OSD', async () => { test('handleCliCommand reports async mine errors to OSD', async () => {
const { deps, calls, osd } = createDeps({ const { deps, calls, osd } = createDeps({
mineSentenceCard: async () => { mineSentenceCard: async () => {

View File

@@ -10,8 +10,9 @@ export interface CliCommandServiceDeps {
isTexthookerRunning: () => boolean; isTexthookerRunning: () => boolean;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerPort: () => number; getTexthookerPort: () => number;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenTexthookerBrowser: () => boolean; shouldOpenTexthookerBrowser: () => boolean;
ensureTexthookerRunning: (port: number) => void; ensureTexthookerRunning: (port: number, websocketUrl?: string) => void;
openTexthookerInBrowser: (url: string) => void; openTexthookerInBrowser: (url: string) => void;
stopApp: () => void; stopApp: () => void;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
@@ -84,7 +85,7 @@ interface MpvClientLike {
interface TexthookerServiceLike { interface TexthookerServiceLike {
isRunning: () => boolean; isRunning: () => boolean;
start: (port: number) => void; start: (port: number, websocketUrl?: string) => void;
} }
interface MpvCliRuntime { interface MpvCliRuntime {
@@ -98,6 +99,7 @@ interface TexthookerCliRuntime {
service: TexthookerServiceLike; service: TexthookerServiceLike;
getPort: () => number; getPort: () => number;
setPort: (port: number) => void; setPort: (port: number) => void;
getWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void; openInBrowser: (url: string) => void;
} }
@@ -194,10 +196,11 @@ export function createCliCommandDepsRuntime(
isTexthookerRunning: () => options.texthooker.service.isRunning(), isTexthookerRunning: () => options.texthooker.service.isRunning(),
setTexthookerPort: options.texthooker.setPort, setTexthookerPort: options.texthooker.setPort,
getTexthookerPort: options.texthooker.getPort, getTexthookerPort: options.texthooker.getPort,
getTexthookerWebsocketUrl: options.texthooker.getWebsocketUrl,
shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser,
ensureTexthookerRunning: (port) => { ensureTexthookerRunning: (port, websocketUrl) => {
if (!options.texthooker.service.isRunning()) { if (!options.texthooker.service.isRunning()) {
options.texthooker.service.start(port); options.texthooker.service.start(port, websocketUrl);
} }
}, },
openTexthookerInBrowser: options.texthooker.openInBrowser, openTexthookerInBrowser: options.texthooker.openInBrowser,
@@ -473,7 +476,7 @@ export function handleCliCommand(
); );
} else if (args.texthooker) { } else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort(); const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort); deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
if (deps.shouldOpenTexthookerBrowser()) { if (deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`); deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
} }

View File

@@ -98,6 +98,13 @@ interface AppReadyConfigLike {
}; };
} }
type TexthookerWebsocketConfigLike = Pick<AppReadyConfigLike, 'annotationWebsocket' | 'websocket'>;
type TexthookerWebsocketDefaults = {
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
};
export interface AppReadyRuntimeDeps { export interface AppReadyRuntimeDeps {
ensureDefaultConfigBootstrap: () => void; ensureDefaultConfigBootstrap: () => void;
loadSubtitlePosition: () => void; loadSubtitlePosition: () => void;
@@ -169,6 +176,29 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
return errors; return errors;
} }
export function resolveTexthookerWebsocketUrl(
config: TexthookerWebsocketConfigLike,
defaults: TexthookerWebsocketDefaults,
hasMpvWebsocketPlugin: boolean,
): string | undefined {
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || defaults.defaultWebsocketPort;
const annotationWsConfig = config.annotationWebsocket || {};
const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || defaults.defaultAnnotationWebsocketPort;
if (annotationWsEnabled) {
return `ws://127.0.0.1:${annotationWsPort}`;
}
if (wsEnabled === true || (wsEnabled === 'auto' && !hasMpvWebsocketPlugin)) {
return `ws://127.0.0.1:${wsPort}`;
}
return undefined;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean { export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
return config.auto_start_overlay === true; return config.auto_start_overlay === true;
} }
@@ -201,12 +231,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return; return;
} }
if (deps.texthookerOnlyMode) {
deps.reloadConfig();
deps.handleInitialArgs();
return;
}
if (deps.shouldUseMinimalStartup?.()) { if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig(); deps.reloadConfig();
deps.handleInitialArgs(); deps.handleInitialArgs();
@@ -262,7 +286,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const annotationWsEnabled = annotationWsConfig.enabled !== false; const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort; const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
const texthookerPort = deps.defaultTexthookerPort; const texthookerPort = deps.defaultTexthookerPort;
let texthookerWebsocketUrl: string | undefined; const texthookerWebsocketUrl = resolveTexthookerWebsocketUrl(
config,
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
);
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) { if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort); deps.startSubtitleWebsocket(wsPort);
@@ -272,9 +303,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (annotationWsEnabled) { if (annotationWsEnabled) {
deps.startAnnotationWebsocket(annotationWsPort); deps.startAnnotationWebsocket(annotationWsPort);
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
} }
if (config.texthooker?.launchAtStartup !== false) { if (config.texthooker?.launchAtStartup !== false) {

View File

@@ -75,7 +75,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
} { } {
return { return {
shouldUseMinimalStartup: Boolean( shouldUseMinimalStartup: Boolean(
initialArgs?.texthooker || (initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats && (initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
), ),
@@ -128,6 +128,7 @@ import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
isHeadlessInitialCommand, isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
@@ -2591,6 +2592,7 @@ const {
function refreshAnilistClientSecretStateIfEnabled(options?: { function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean; force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> { }): Promise<string | null> {
if (!isAnilistTrackingEnabled(getResolvedConfig())) { if (!isAnilistTrackingEnabled(getResolvedConfig())) {
return Promise.resolve(null); return Promise.resolve(null);
@@ -4333,6 +4335,9 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
setLogLevel: (level) => setLogLevel(level, 'cli'), setLogLevel: (level) => setLogLevel(level, 'cli'),
texthookerService, texthookerService,
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
openExternal: (url: string) => shell.openExternal(url), openExternal: (url: string) => shell.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error), logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
@@ -4480,7 +4485,10 @@ const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup; const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) { if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) {
if (isAnilistTrackingEnabled(getResolvedConfig())) { if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true }).catch((error) => { void refreshAnilistClientSecretStateIfEnabled({
force: true,
allowSetupPrompt: false,
}).catch((error) => {
logger.error('Failed to refresh AniList client secret state during startup', error); logger.error('Failed to refresh AniList client secret state during startup', error);
}); });
anilistStateRuntime.refreshRetryQueueState(); anilistStateRuntime.refreshRetryQueueState();

View File

@@ -13,6 +13,7 @@ export interface CliCommandRuntimeServiceContext {
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd']; showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void; openInBrowser: (url: string) => void;
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
@@ -71,6 +72,7 @@ function createCliCommandDepsFromContext(
service: context.texthookerService, service: context.texthookerService,
getPort: context.getTexthookerPort, getPort: context.getTexthookerPort,
setPort: context.setTexthookerPort, setPort: context.setTexthookerPort,
getWebsocketUrl: context.getTexthookerWebsocketUrl,
shouldOpenBrowser: context.shouldOpenBrowser, shouldOpenBrowser: context.shouldOpenBrowser,
openInBrowser: context.openInBrowser, openInBrowser: context.openInBrowser,
}, },

View File

@@ -132,6 +132,7 @@ export interface CliCommandRuntimeServiceDepsParams {
service: CliCommandDepsRuntimeOptions['texthooker']['service']; service: CliCommandDepsRuntimeOptions['texthooker']['service'];
getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort']; getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort'];
setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort']; setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort'];
getWebsocketUrl: CliCommandDepsRuntimeOptions['texthooker']['getWebsocketUrl'];
shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser']; shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser'];
openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser']; openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser'];
}; };
@@ -293,6 +294,7 @@ export function createCliCommandRuntimeServiceDeps(
service: params.texthooker.service, service: params.texthooker.service,
getPort: params.texthooker.getPort, getPort: params.texthooker.getPort,
setPort: params.texthooker.setPort, setPort: params.texthooker.setPort,
getWebsocketUrl: params.texthooker.getWebsocketUrl,
shouldOpenBrowser: params.texthooker.shouldOpenBrowser, shouldOpenBrowser: params.texthooker.shouldOpenBrowser,
openInBrowser: params.texthooker.openInBrowser, openInBrowser: params.texthooker.openInBrowser,
}, },

View File

@@ -82,7 +82,42 @@ test('refresh handler prefers cached token when not forced', async () => {
assert.equal(loadCalls, 0); assert.equal(loadCalls, 0);
}); });
test('refresh handler falls back to stored token then opens setup when missing', async () => { test('refresh handler falls back to stored token without opening setup', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => ' stored-token ',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 400,
});
const token = await refresh({ force: true });
assert.equal(token, 'stored-token');
assert.equal(cached, 'stored-token');
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [{ status: 'resolved', source: 'stored' }]);
});
test('refresh handler opens setup when missing token and prompting allowed', async () => {
let cached: string | null = null; let cached: string | null = null;
let opened = false; let opened = false;
let openCalls = 0; let openCalls = 0;
@@ -111,3 +146,44 @@ test('refresh handler falls back to stored token then opens setup when missing',
assert.equal(cached, null); assert.equal(cached, null);
assert.equal(openCalls, 1); assert.equal(openCalls, 1);
}); });
test('refresh handler skips setup open when missing token and prompting disabled', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string; message: string | null }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source, message: state.message });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 500,
});
const token = await refresh({ force: true, allowSetupPrompt: false });
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [
{
status: 'error',
source: 'none',
message: 'cannot authenticate without anilist.accessToken',
},
]);
});

View File

@@ -27,7 +27,10 @@ export function createRefreshAnilistClientSecretStateHandler<
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
now: () => number; now: () => number;
}) { }) {
return async (options?: { force?: boolean }): Promise<string | null> => { return async (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> => {
const resolved = deps.getResolvedConfig(); const resolved = deps.getResolvedConfig();
const now = deps.now(); const now = deps.now();
if (!deps.isAnilistTrackingEnabled(resolved)) { if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -87,7 +90,11 @@ export function createRefreshAnilistClientSecretStateHandler<
resolvedAt: null, resolvedAt: null,
errorAt: now, errorAt: now,
}); });
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) { if (
options?.allowSetupPrompt !== false &&
deps.isAnilistTrackingEnabled(resolved) &&
!deps.getAnilistSetupPageOpened()
) {
deps.openAnilistSetupWindow(); deps.openAnilistSetupWindow();
} }
return null; return null;

View File

@@ -12,6 +12,7 @@ test('build cli command context deps maps handlers and values', () => {
texthookerService: { start: () => null, status: () => ({ running: false }) } as never, texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
getTexthookerPort: () => 5174, getTexthookerPort: () => 5174,
setTexthookerPort: (port) => calls.push(`port:${port}`), setTexthookerPort: (port) => calls.push(`port:${port}`),
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true, shouldOpenBrowser: () => true,
openExternal: async (url) => calls.push(`open:${url}`), openExternal: async (url) => calls.push(`open:${url}`),
logBrowserOpenError: (url) => calls.push(`open-error:${url}`), logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
@@ -82,6 +83,7 @@ test('build cli command context deps maps handlers and values', () => {
const deps = buildDeps(); const deps = buildDeps();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getTexthookerPort(), 5174); assert.equal(deps.getTexthookerPort(), 5174);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true); assert.equal(deps.shouldOpenBrowser(), true);
assert.equal(deps.isOverlayInitialized(), true); assert.equal(deps.isOverlayInitialized(), true);
assert.equal(deps.hasMainWindow(), true); assert.equal(deps.hasMainWindow(), true);

View File

@@ -10,6 +10,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: CliCommandContextFactoryDeps['texthookerService']; texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
@@ -58,6 +59,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: deps.texthookerService, texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort, getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort, setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser, shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal, openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError, logBrowserOpenError: deps.logBrowserOpenError,

View File

@@ -14,7 +14,13 @@ test('cli command context factory composes main deps and context handlers', () =
const createContext = createCliCommandContextFactory({ const createContext = createCliCommandContextFactory({
appState, appState,
texthookerService: { isRunning: () => false, start: () => null }, texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: () => {}, logBrowserOpenError: () => {},
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),

View File

@@ -14,7 +14,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
const build = createBuildCliCommandContextMainDepsHandler({ const build = createBuildCliCommandContextMainDepsHandler({
appState, appState,
texthookerService: { isRunning: () => false, start: () => null }, texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async (url) => { openExternal: async (url) => {
calls.push(`open:${url}`); calls.push(`open:${url}`);
}, },
@@ -110,6 +116,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.getTexthookerPort(), 5174); assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175); deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175); assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true); assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello'); deps.showOsd('hello');
deps.initializeOverlay(); deps.initializeOverlay();

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context'; import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = { type CliCommandContextMainState = {
@@ -12,7 +13,14 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState; appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void; setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService']; texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } }; getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
@@ -68,6 +76,15 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setTexthookerPort: (port: number) => { setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port; deps.appState.texthookerPort = port;
}, },
getTexthookerWebsocketUrl: () =>
resolveTexthookerWebsocketUrl(
deps.getResolvedConfig(),
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
),
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url), openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),

View File

@@ -18,6 +18,7 @@ function createDeps() {
texthookerService: {} as never, texthookerService: {} as never,
getTexthookerPort: () => 6677, getTexthookerPort: () => 6677,
setTexthookerPort: () => {}, setTexthookerPort: () => {},
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true, shouldOpenBrowser: () => true,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url), logBrowserOpenError: (url: string) => browserErrors.push(url),

View File

@@ -15,6 +15,7 @@ export type CliCommandContextFactoryDeps = {
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
@@ -67,6 +68,7 @@ export function createCliCommandContext(
texthookerService: deps.texthookerService, texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort, getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort, setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser, shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => { openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => { void deps.openExternal(url).catch((error) => {

View File

@@ -11,6 +11,9 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
setLogLevel: () => {}, setLogLevel: () => {},
texthookerService: {} as never, texthookerService: {} as never,
getResolvedConfig: () => ({}) as never, getResolvedConfig: () => ({}) as never,
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: () => {}, logBrowserOpenError: () => {},
showMpvOsd: () => {}, showMpvOsd: () => {},