Compare commits

...

4 Commits

Author SHA1 Message Date
sudacode db30c61327 [codex] Fix Jellyfin setup and discovery toggle (#59) 2026-05-02 19:56:10 -07:00
sudacode 27f5b2bb58 Polish changelog fragments with claude -p at release time
- Replace `renderGroupedChanges` with `polishFragmentsWithClaude` that pipes fragments through `claude -p --model sonnet` to merge related items, drop housekeeping noise, and produce user-facing release notes
- Internal fragments kept in CHANGELOG.md under a `<details>` collapse; dropped from GitHub release notes entirely
- CI no longer auto-runs `changelog:build` on tag-based releases — fails fast with a clear error if `changes/*.md` fragments are still pending; build locally and commit before tagging
- Add `runClaude` dep-injection seam to test surface; add failure-mode coverage (missing binary, empty output, missing headers, missing `<details>` wrapper)
- Delete implemented design doc; update `changes/README.md` and `docs/RELEASING.md` with claude CLI prerequisite and new workflow
2026-05-02 19:52:48 -07:00
sudacode baabdb6d30 Add design doc for AI-polished changelog workflow
- Capture decisions from brainstorming: replace bullet renderer with `claude -p`, write straight to disk, hard-fail on missing/failed claude, drop internal section from release notes but keep collapsed in CHANGELOG.md
- Document prompt input/output contract, affected files, test plan, and CI guard that fails tag-based releases when changelog fragments are still pending
- Set scope boundaries (no caching, no SDK fallback, no `--no-polish` escape hatch)
2026-05-02 19:52:13 -07:00
sudacode 3a67e23bc3 feat: open texthooker from cli and tray 2026-05-02 19:37:44 -07:00
67 changed files with 2164 additions and 197 deletions
+3 -4
View File
@@ -351,12 +351,11 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Build changelog artifacts for release - name: Guard against pending changelog fragments
run: | run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
bun run changelog:build --version "${{ steps.version.outputs.VERSION }}" echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
else exit 1
echo "No pending changelog fragments found."
fi fi
- name: Verify changelog is ready for tagged release - name: Verify changelog is ready for tagged release
+3 -1
View File
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr> </tr>
<tr> <tr>
<td><b>Jellyfin</b></td> <td><b>Jellyfin</b></td>
<td>Browse and launch media from your Jellyfin server</td> <td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr> </tr>
<tr> <tr>
<td><b>Jimaku</b></td> <td><b>Jimaku</b></td>
@@ -252,6 +252,8 @@ subminer app --setup # launch the first-run setup wizard
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup. SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
> [!NOTE] > [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch. > On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
@@ -0,0 +1,44 @@
---
id: TASK-314
title: Improve Jellyfin setup popup and tray discovery toggle
status: Done
assignee: []
created_date: '2026-05-02 22:45'
updated_date: '2026-05-02 23:11'
labels:
- jellyfin
dependencies: []
references:
- src/main/runtime/jellyfin-setup-window.ts
- src/main/runtime/jellyfin-cli-auth.ts
- src/main/runtime/tray-runtime.ts
- src/main/runtime/jellyfin-remote-session-lifecycle.ts
documentation:
- docs-site/jellyfin-integration.md
- docs-site/configuration.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Improve the Jellyfin integration setup experience and remove the need to use command-line discovery mode for normal tray-driven use. The existing `--jellyfin` setup popup should become a frontend for the same auth persistence path used by CLI login, with manual/recent server selection and inline feedback. The tray should expose a runtime-only Jellyfin Discovery checkbox when Jellyfin is configured so users can start or stop cast/discovery mode without changing config.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The Jellyfin setup popup supports config/recent/default server choices, manual URL entry, username/password login, logout when a session exists, done/close, and inline success/error status without persisting passwords.
- [x] #2 CLI login and setup popup login share the same auth persistence behavior, including encrypted token storage, enabled/server/username/client metadata config patching, and recent server updates.
- [x] #3 `jellyfin.recentServers` is parsed, normalized, deduplicated, capped, documented, and included in generated config examples if exposed.
- [x] #4 The tray keeps Configure Jellyfin visible and shows a Jellyfin Discovery checkbox only when Jellyfin is configured with enabled integration, server URL, access token, and user ID.
- [x] #5 The tray Jellyfin Discovery checkbox starts/stops the current remote session at runtime only, announces after start, reports OSD/log status, and does not patch config.
- [x] #6 Startup auto-connect behavior remains governed by existing config, including `remoteControlAutoConnect`; explicit tray start can start discovery without requiring `remoteControlAutoConnect`.
- [x] #7 Focused tests cover setup popup actions/rendering, shared auth persistence, config parsing, tray toggle visibility/state/click behavior, and remote lifecycle auto-connect versus explicit-start behavior.
- [x] #8 Jellyfin docs and changelog fragment are updated.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented Jellyfin setup popup improvements, shared auth persistence for CLI/setup config shape, recent server config support, runtime-only tray Jellyfin Discovery toggle, docs/config examples, and changelog fragment. Verified focused Jellyfin/tray tests, config tests, launcher tests, typecheck, and docs tests.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,35 @@
---
id: TASK-317
title: Add browser open affordance for texthooker
status: Done
assignee: []
created_date: '2026-05-03 02:02'
updated_date: '2026-05-03 02:21'
labels:
- feature
- texthooker
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a `-o` flag to the texthooker subcommand to open the texthooker page in the user's default browser, and add a tray app option that triggers the same behavior. Implement with tests and existing launcher/tray patterns.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `texthooker -o` starts/targets the texthooker page and opens it in the default browser.
- [x] #2 Tray app exposes a menu option to open the texthooker page in the default browser.
- [x] #3 Existing texthooker behavior without `-o` remains unchanged.
- [x] #4 Relevant CLI/tray behavior covered by tests.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented `subminer texthooker -o` by parsing the launcher subcommand flag, forwarding `--open-browser` to the app texthooker command, and allowing that app arg to force browser opening even when `texthooker.openBrowser` is false. Added an `Open Texthooker` tray menu item wired through the same CLI command path. Updated docs-site usage/launcher/API docs and added a changelog fragment. Verification: targeted CLI/tray tests passed; `bun run typecheck` passed; `bun run docs:test` passed; `bun run changelog:lint` passed; `bun run test:env` passed; `bun run build` passed; `bun run test:smoke:dist` passed; `bun run docs:build` passed after installing docs-site deps. `bun run test:fast` is blocked by an existing broader-suite failure in `runSubsyncManual writes deterministic _retimed filename when replace is false` (`window.electronAPI` undefined), followed by Bun nested-test cascade errors.
Follow-up fix: `subminer texthooker -o` now opens `http://127.0.0.1:5174` from the launcher after a successful texthooker app handoff, so it works even when the installed SubMiner app binary does not yet understand the app-side `--open-browser` flag. Reproduced the reported behavior; confirmed the texthooker server was running at `127.0.0.1:5174`; added a launcher regression asserting the browser URL is opened. Verification: `bun test launcher/mpv.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts src/core/services/cli-command.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts` passed; `bun run typecheck` passed; `bun run build:launcher` passed.
<!-- SECTION:FINAL_SUMMARY:END -->
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: release
- Replaced the changelog renderer with a `claude -p` polish pass that merges related fragments, drops PR housekeeping, and writes user-friendly release notes. CHANGELOG.md keeps internal items in a collapsed `<details>` block; the GitHub release notes drop them entirely.
- Removed the release CI auto-build for pending `changes/*.md` fragments. Tag-based release runs now fail fast with a clear error if fragments are still pending; build the changelog locally with `bun run changelog:build` (which requires the `claude` CLI on PATH) and commit before tagging.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Improved Jellyfin setup with recent server selection and inline authentication feedback.
- Added a tray Jellyfin Discovery toggle for runtime-only cast discovery.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: texthooker
- Texthooker: Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
+6
View File
@@ -31,6 +31,12 @@ Rules:
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes: Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` - prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
+1
View File
@@ -483,6 +483,7 @@
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting. "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting. "clientName": "SubMiner", // Client name setting.
+4
View File
@@ -1157,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "", "username": "",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
@@ -1174,6 +1175,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | | `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | | `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
@@ -1206,6 +1208,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord Rich Presence ### Discord Rich Presence
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off. Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
+11 -3
View File
@@ -6,7 +6,8 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items - listing libraries and media items
- launching item playback in the connected mpv instance - launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app - receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server/user/password input - opening an in-app setup window for server selection and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements ## Requirements
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "your-user", "username": "your-user",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
@@ -48,6 +50,8 @@ subminer jellyfin -l \
--password 'your-password' --password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
3. List libraries: 3. List libraries:
```bash ```bash
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d subminer jellyfin -d
``` ```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app: Stop discovery session/app:
```bash ```bash
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
- `jellyfin.enabled=true` - `jellyfin.enabled=true`
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session) - valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
- `jellyfin.remoteControlEnabled=true` (default) - `jellyfin.remoteControlEnabled=true` (default)
- `jellyfin.remoteControlAutoConnect=true` (default) - `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect) - `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
### Behavior ### Behavior
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities. - SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`. - `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback. - If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
- `Playstate` events map to mpv pause/resume/seek/stop controls. - `Playstate` events map to mpv pause/resume/seek/stop controls.
@@ -147,7 +154,8 @@ remote playback target in Jellyfin's cast-to-device menu.
- Device not visible in Jellyfin cast menu: - Device not visible in Jellyfin cast menu:
- ensure SubMiner is running - ensure SubMiner is running
- ensure session token is valid (`--jellyfin-login` again if needed) - ensure session token is valid (`--jellyfin-login` again if needed)
- ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true - ensure `remoteControlEnabled` is true
- use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
- Cast command received but playback does not start: - Cast command received but playback does not start:
- verify mpv IPC can connect (`--start` flow) - verify mpv IPC can connect (`--start` flow)
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...` - verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
+1
View File
@@ -85,6 +85,7 @@ subminer stats -b # start background stats daemon
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction | | `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series | | `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode | | `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` | Pass arguments directly to SubMiner binary | | `subminer app` | Pass arguments directly to SubMiner binary |
Use `subminer <subcommand> -h` for command-specific help. Use `subminer <subcommand> -h` for command-specific help.
+1
View File
@@ -483,6 +483,7 @@
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting. "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting. "clientName": "SubMiner", // Client name setting.
+4
View File
@@ -103,12 +103,14 @@ subminer dictionary /path/to/file-or-directory # Generate character dictionary
subminer dictionary --candidates /path/to/file.mkv subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow)
# Direct packaged app control # Direct packaged app control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs) SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
SubMiner.AppImage --setup # Open first-run setup popup SubMiner.AppImage --setup # Open first-run setup popup
SubMiner.AppImage --stop # Stop overlay SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
@@ -133,6 +135,8 @@ SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options SubMiner.AppImage --help # Show all options
``` ```
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
### Logging and App Mode ### Logging and App Mode
- `--log-level` controls logger verbosity. - `--log-level` controls logger verbosity.
+3 -1
View File
@@ -164,6 +164,8 @@ Start it with either:
```bash ```bash
subminer texthooker subminer texthooker
# or open the page immediately
subminer texthooker -o
``` ```
or by leaving `texthooker.launchAtStartup` enabled. or by leaving `texthooker.launchAtStartup` enabled.
@@ -273,7 +275,7 @@ Examples:
Examples: Examples:
- open a media picker, then call `subminer /path/to/file.mkv` - open a media picker, then call `subminer /path/to/file.mkv`
- launch browser-only subtitle tooling with `subminer texthooker` - launch browser-only subtitle tooling with `subminer texthooker -o`
- disable the helper UI for a session with `subminer --no-texthooker video.mkv` - disable the helper UI for a session with `subminer --no-texthooker video.mkv`
#### Build an overlay-adjacent client #### Build an overlay-adjacent client
+18 -6
View File
@@ -2,16 +2,28 @@
# Releasing # Releasing
## Prerequisites
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
`changelog:build` and `changelog:prerelease-notes` invoke
`claude -p --model sonnet` to merge and rewrite `changes/*.md` fragments into
a polished, user-facing release body. Either OAuth login (`claude /login`) or
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it.
## Stable Release ## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples. 2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
3. Run `bun run changelog:lint`. 3. Run `bun run changelog:lint`.
4. Bump `package.json` to the release version. 4. Bump `package.json` to the release version.
5. Build release metadata before tagging: 5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>` `bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed
6. Review `CHANGELOG.md` and `release/release-notes.md`. before tagging. Release CI no longer auto-builds the changelog; it fails
fast if `changes/*.md` fragments are still present on a tag-based run.
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
missed something — the committed Markdown is what ships.
7. Run release gate locally: 7. Run release gate locally:
`bun run changelog:check --version <version>` `bun run changelog:check --version <version>`
`bun run verify:config-example` `bun run verify:config-example`
@@ -31,7 +43,7 @@
1. Confirm release-facing docs and pending `changes/*.md` fragments are current. 1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
2. Run `bun run changelog:lint`. 2. Run `bun run changelog:lint`.
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`. 3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
4. Run the prerelease gate locally: 4. Run the prerelease gate locally (this calls `claude -p` locally):
`bun run changelog:prerelease-notes --version <version>` `bun run changelog:prerelease-notes --version <version>`
`bun run verify:config-example` `bun run verify:config-example`
`bun run typecheck` `bun run typecheck`
@@ -51,8 +63,8 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- 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`.
@@ -28,6 +28,7 @@ function createContext(): LauncherCommandContext {
useTexthooker: false, useTexthooker: false,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
passwordStore: '', passwordStore: '',
+34
View File
@@ -144,6 +144,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false,
}); });
assert.equal(parsed.jellyfin, false); assert.equal(parsed.jellyfin, false);
@@ -157,3 +158,36 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.configShow, true); assert.equal(parsed.configShow, true);
assert.equal(parsed.logLevel, 'warn'); assert.equal(parsed.logLevel, 'warn');
}); });
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
});
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
});
+2
View File
@@ -184,6 +184,7 @@ export function createDefaultArgs(
useTexthooker: true, useTexthooker: true,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
passwordStore: '', passwordStore: '',
@@ -247,6 +248,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
if (invocations.jellyfinInvocation) { if (invocations.jellyfinInvocation) {
if (invocations.jellyfinInvocation.logLevel) { if (invocations.jellyfinInvocation.logLevel) {
@@ -35,3 +35,10 @@ test('parseCliPrograms routes app alias arguments through passthrough mode', ()
appArgs: ['--anilist', '--log-level', 'debug'], appArgs: ['--anilist', '--log-level', 'debug'],
}); });
}); });
test('parseCliPrograms captures texthooker browser-open flag', () => {
const result = parseCliPrograms(['texthooker', '-o'], 'subminer');
assert.equal(result.invocations.texthookerTriggered, true);
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
+5
View File
@@ -42,6 +42,7 @@ export interface CliInvocations {
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean; texthookerTriggered: boolean;
texthookerLogLevel: string | null; texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean;
} }
function applyRootOptions(program: Command): void { function applyRootOptions(program: Command): void {
@@ -152,6 +153,7 @@ export function parseCliPrograms(
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false; let doctorRefreshKnownWords = false;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false;
let doctorTriggered = false; let doctorTriggered = false;
let texthookerTriggered = false; let texthookerTriggered = false;
@@ -313,10 +315,12 @@ export function parseCliPrograms(
commandProgram commandProgram
.command('texthooker') .command('texthooker')
.description('Launch texthooker-only mode') .description('Launch texthooker-only mode')
.option('-o, --open-browser', 'Open texthooker in the default browser')
.option('--log-level <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => { .action((options: Record<string, unknown>) => {
texthookerTriggered = true; texthookerTriggered = true;
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
texthookerOpenBrowser = options.openBrowser === true;
}); });
commandProgram commandProgram
@@ -369,6 +373,7 @@ export function parseCliPrograms(
doctorRefreshKnownWords, doctorRefreshKnownWords,
texthookerTriggered, texthookerTriggered,
texthookerLogLevel, texthookerLogLevel,
texthookerOpenBrowser,
}, },
}; };
} }
+24
View File
@@ -270,6 +270,29 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
assert.equal(error.code, 1); assert.equal(error.code, 1);
}); });
test('launchTexthookerOnly forwards browser-open request to app command', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const argsPath = path.join(dir, 'args.txt');
const openedUrls: string[] = [];
fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`);
fs.chmodSync(appPath, 0o755);
const error = withProcessExitIntercept(() => {
launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
openBrowser: (url) => openedUrls.push(url),
});
});
assert.equal(error.code, 0);
assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
'--texthooker',
'--open-browser',
]);
assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('launchAppCommandDetached handles child process spawn errors', async () => { test('launchAppCommandDetached handles child process spawn errors', async () => {
let uncaughtError: Error | null = null; let uncaughtError: Error | null = null;
const onUncaughtException = (error: Error) => { const onUncaughtException = (error: Error) => {
@@ -399,6 +422,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useTexthooker: false, useTexthooker: false,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'error', logLevel: 'error',
passwordStore: '', passwordStore: '',
+30 -1
View File
@@ -831,8 +831,30 @@ export async function startOverlay(
} }
} }
export function launchTexthookerOnly(appPath: string, args: Args): never { export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
? { command: 'open', args: [url] }
: process.platform === 'win32'
? { command: 'cmd', args: ['/c', 'start', '', url] }
: { command: 'xdg-open', args: [url] };
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: process.env,
windowsHide: true,
});
if (result.error) {
log('warn', logLevel, `Failed to open browser for ${url}: ${result.error.message}`);
}
}
export function launchTexthookerOnly(
appPath: string,
args: Args,
deps: { openBrowser?: (url: string) => void } = {},
): never {
const overlayArgs = ['--texthooker']; const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
@@ -840,6 +862,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (result.error) { if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`); fail(`Failed to launch texthooker mode: ${result.error.message}`);
} }
if (args.texthookerOpenBrowser && (result.status ?? 0) === 0) {
const url = 'http://127.0.0.1:5174';
const openBrowser =
deps.openBrowser ??
((browserUrl: string) => openUrlInDefaultBrowser(browserUrl, args.logLevel));
openBrowser(url);
}
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} }
+1
View File
@@ -105,6 +105,7 @@ export interface Args {
useTexthooker: boolean; useTexthooker: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
texthookerOnly: boolean; texthookerOnly: boolean;
texthookerOpenBrowser: boolean;
useRofi: boolean; useRofi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
passwordStore: string; passwordStore: string;
+362 -17
View File
@@ -13,6 +13,75 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(baseDir, `${name}-`)); return fs.mkdtempSync(path.join(baseDir, `${name}-`));
} }
type RunClaudeArgs = { input: string; args: string[] };
function recordingRunClaude(responder: (input: string) => string): {
runClaude: (input: string, args: string[]) => string;
calls: RunClaudeArgs[];
} {
const calls: RunClaudeArgs[] = [];
return {
calls,
runClaude(input, args) {
calls.push({ input, args });
return responder(input);
},
};
}
function modeFromPrompt(input: string): 'changelog' | 'release-notes' | null {
// Anchor to start-of-line so we don't accidentally match the instructions text,
// which mentions "MODE: changelog" and "MODE: release-notes" mid-sentence.
const match = /^MODE: (changelog|release-notes)$/m.exec(input);
return (match?.[1] as 'changelog' | 'release-notes') ?? null;
}
function fragmentTypesInPrompt(input: string): string[] {
return input
.split(/\r?\n/)
.filter((line) => line.startsWith('type: '))
.map((line) => line.slice('type: '.length).trim());
}
function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input);
const sections: string[] = [];
const has = (t: string) => types.includes(t);
const hasBreaking = /^breaking: true$/m.test(input);
if (hasBreaking) {
sections.push('### Breaking Changes\n- Polished: breaking change.');
}
if (has('added')) {
sections.push('### Added\n- Polished: added entry.');
}
if (has('changed')) {
sections.push('### Changed\n- Polished: changed entry.');
}
if (has('fixed')) {
sections.push('### Fixed\n- Polished: fixed entry.');
}
if (has('docs')) {
sections.push('### Docs\n- Polished: docs entry.');
}
if (mode === 'changelog' && has('internal')) {
sections.push(
'<details>\n<summary>Internal changes</summary>\n\n### Internal\n- Polished: internal entry.\n\n</details>',
);
}
if (sections.length === 0) {
sections.push('### Changed\n- Polished: empty fallback.');
}
return sections.join('\n\n');
}
function defaultStubClaude() {
return recordingRunClaude(defaultPolishedBody);
}
test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => { test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => {
const { resolveChangelogOutputPaths } = await loadModule(); const { resolveChangelogOutputPaths } = await loadModule();
const workspace = createWorkspace('with-docs-repo'); const workspace = createWorkspace('with-docs-repo');
@@ -62,10 +131,12 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
); );
try { try {
const stub = defaultStubClaude();
const result = writeChangelogArtifacts({ const result = writeChangelogArtifacts({
cwd: projectRoot, cwd: projectRoot,
version: '0.4.1', version: '0.4.1',
date: '2026-03-07', date: '2026-03-07',
deps: { runClaude: stub.runClaude },
}); });
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -77,18 +148,28 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); assert.equal(
assert.match( stub.calls.length,
changelog, 2,
/^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m, 'expected one Claude call per output (changelog + release notes)',
); );
assert.deepEqual(
stub.calls.map((call) => modeFromPrompt(call.input)),
['changelog', 'release-notes'],
);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/);
assert.match(changelog, /### Added\n- Polished: added entry\./);
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
const releaseNotes = fs.readFileSync( const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'), path.join(projectRoot, 'release', 'release-notes.md'),
'utf8', 'utf8',
); );
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./); assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./); assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
@@ -159,10 +240,12 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
); );
try { try {
const stub = defaultStubClaude();
const result = writeStableReleaseArtifacts({ const result = writeStableReleaseArtifacts({
cwd: projectRoot, cwd: projectRoot,
version: '0.4.1', version: '0.4.1',
date: '2026-03-07', date: '2026-03-07',
deps: { runClaude: stub.runClaude },
}); });
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
@@ -260,10 +343,12 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
); );
try { try {
const stub = defaultStubClaude();
writeChangelogArtifacts({ writeChangelogArtifacts({
cwd: projectRoot, cwd: projectRoot,
version: '0.5.0', version: '0.5.0',
date: '2026-04-06', date: '2026-04-06',
deps: { runClaude: stub.runClaude },
}); });
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
@@ -276,8 +361,14 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
assert.notEqual(fixedIndex, -1, 'Fixed section should exist'); assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed'); assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed'); assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./); const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation');
assert.match(
changelogCall.input,
/breaking: true/,
'breaking metadata should reach the prompt verbatim',
);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
@@ -384,9 +475,11 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
); );
try { try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.11.3-beta.1', version: '0.11.3-beta.1',
deps: { runClaude: stub.runClaude },
}); });
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
@@ -403,13 +496,13 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true); assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match( assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
prereleaseNotes, assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
/## Highlights\n### Added\n- Overlay: Added prerelease coverage\./,
);
assert.match(prereleaseNotes, /### Fixed\n- Launcher: Fixed prerelease packaging checks\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
@@ -434,16 +527,15 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
); );
try { try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.11.3-rc.1', version: '0.11.3-rc.1',
deps: { runClaude: stub.runClaude },
}); });
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match( assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./);
prereleaseNotes,
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
@@ -536,3 +628,256 @@ test('writePrereleaseNotesForVersion rejects empty prerelease note generation wh
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
}); });
test('writeChangelogArtifacts surfaces a clear error when claude is missing from PATH', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-missing');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
// The production defaultRunClaude wrapper translates ENOENT into this friendly
// message; we simulate that contract here so the test exercises the propagation
// path through polishFragmentsWithClaude rather than re-implementing the
// execFileSync mock.
const enoent = (): string => {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: enoent },
}),
/claude CLI not found on PATH/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects empty claude output', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-empty');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => ' \n ' },
}),
/claude returned empty output/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects claude output missing required section headers', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-headers');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A change.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: () => 'Sure, here is your changelog: it is great.' },
}),
/missing the expected section heading/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts rejects changelog-mode output that omits the Internal <details> wrapper when internal fragments are present', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('claude-no-details');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal note.'].join('\n'),
'utf8',
);
const noDetailsResponder = (input: string): string => {
if (modeFromPrompt(input) === 'changelog') {
return '### Added\n- Polished: added.\n\n### Internal\n- Polished: internal (no details wrapper).';
}
return defaultPolishedBody(input);
};
try {
assert.throws(
() =>
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: noDetailsResponder },
}),
/<details><summary>Internal changes<\/summary> wrapper/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts filters internal fragments from the release-notes Claude prompt', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('release-notes-internal-filter');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: internal', 'area: release', '', '- An internal CI tweak.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
deps: { runClaude: stub.runClaude },
});
const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
const releaseNotesCall = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(changelogCall, 'expected a changelog-mode invocation');
assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation');
assert.deepEqual(
fragmentTypesInPrompt(changelogCall.input).sort(),
['added', 'internal'],
'changelog mode keeps internal fragments',
);
assert.deepEqual(
fragmentTypesInPrompt(releaseNotesCall.input),
['added'],
'release-notes mode drops internal fragments',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(changelog, /<details>[\s\S]*<summary>Internal changes<\/summary>/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
'## v0.4.1 (2026-03-07)',
'### Added',
'- Polished: previously committed.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'### Internal',
'- Polished: internal note.',
'',
'</details>',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Stale fragment.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-08',
deps: { runClaude: stub.runClaude },
});
assert.equal(
stub.calls.length,
0,
'no Claude calls should fire when the section already exists',
);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+185 -52
View File
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string;
type ChangelogFsDeps = { type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void; mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -10,8 +12,11 @@ type ChangelogFsDeps = {
rmSync?: (candidate: string) => void; rmSync?: (candidate: string) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void; log?: (message: string) => void;
runClaude?: RunClaude;
}; };
type PolishMode = 'changelog' | 'release-notes';
type ChangelogOptions = { type ChangelogOptions = {
cwd?: string; cwd?: string;
date?: string; date?: string;
@@ -41,13 +46,6 @@ const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md'); const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog'; const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal']; const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
added: 'Added',
changed: 'Changed',
fixed: 'Fixed',
docs: 'Docs',
internal: 'Internal',
};
const SKIP_CHANGELOG_LABEL = 'skip-changelog'; const SKIP_CHANGELOG_LABEL = 'skip-changelog';
function normalizeVersion(version: string): string { function normalizeVersion(version: string): string {
@@ -217,54 +215,179 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
}); });
} }
function formatAreaLabel(area: string): string { // We deliberately don't pass --bare here. --bare skips OAuth/keychain reads and
return area // requires ANTHROPIC_API_KEY, which most Claude Code users don't have set up.
.split(/[-_\s]+/) // The polish prompt is self-contained and doesn't need tools, so loading the
.filter(Boolean) // user's hooks/MCP/CLAUDE.md is harmless overhead.
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) const CLAUDE_CLI_ARGS = [
.join(' '); '-p',
} '--model',
'sonnet',
'--permission-mode',
'bypassPermissions',
'--output-format',
'text',
];
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string { const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m;
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
}
function renderGroupedChanges(fragments: ChangeFragment[]): string { const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release changelog for end users of SubMiner, an Electron app for Japanese sentence mining.
const sections: string[] = [];
const breakingFragments = fragments.filter((fragment) => fragment.breaking); You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
if (breakingFragments.length > 0) {
const bullets = breakingFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### Breaking Changes\n${bullets}`);
}
for (const type of CHANGE_TYPES) { ## Output Rules
const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) { 1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
continue; 2. Use these section headings, in this order, omitting any that have no bullets:
### Breaking Changes
### Added
### Changed
### Fixed
### Docs
3. In MODE: changelog only, append a final section after Docs:
<details>
<summary>Internal changes</summary>
### Internal
-
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each bullet should:
- Lead with a short feature/area name in title case followed by a colon, e.g. "Playlist browser:", "Windows overlay:", "Stats dashboard:". Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of every breaking change in ### Breaking Changes. Do not soften or omit them.
5. Do not invent features. Every bullet must be grounded in the input fragments.
6. Do not include the version heading (## v...) that wrapper is added by the caller.
The input begins below.
`;
function defaultRunClaude(input: string, args: string[]): string {
try {
return execFileSync('claude', args, {
input,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'inherit'],
});
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new Error(
"claude CLI not found on PATH. Install Claude Code (https://claude.com/claude-code) and ensure 'claude' is on your PATH before running changelog:build.",
);
} }
throw new Error(`claude CLI invocation failed: ${err.message}`);
const bullets = typeFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
} }
return sections.join('\n\n');
} }
function buildReleaseSection(version: string, date: string, fragments: ChangeFragment[]): string { function serializeFragmentsForPrompt(
fragments: ChangeFragment[],
mode: PolishMode,
version: string,
date?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
header.push(`DATE: ${date}`);
}
const fragmentBlocks = fragments.map((fragment) => {
const relativePath = fragment.path.replace(/^.*?(changes\/.*)$/u, '$1');
return [
`FRAGMENT ${relativePath}`,
`type: ${fragment.type}`,
`area: ${fragment.area}`,
`breaking: ${fragment.breaking}`,
...fragment.bullets,
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
output: string,
mode: PolishMode,
hasInternalFragments: boolean,
): string {
const trimmed = output.trim();
if (!trimmed) {
throw new Error('claude returned empty output for changelog polish.');
}
if (!SECTION_HEADER_PATTERN.test(trimmed)) {
throw new Error(
`claude output is missing the expected section heading (### Added/Changed/Fixed/Docs/Breaking Changes). Got:\n${trimmed.slice(0, 400)}`,
);
}
if (mode === 'changelog' && hasInternalFragments) {
if (!/<details>[\s\S]*<summary>[^<]*Internal[^<]*<\/summary>/m.test(trimmed)) {
throw new Error(
'claude output is missing the expected <details><summary>Internal changes</summary> wrapper for the Internal section.',
);
}
}
return trimmed;
}
function polishFragmentsWithClaude(
fragments: ChangeFragment[],
options: {
mode: PolishMode;
version: string;
date?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
mode === 'release-notes'
? fragments.filter((fragment) => fragment.type !== 'internal')
: fragments;
const hasInternalFragments =
mode === 'changelog' && fragments.some((fragment) => fragment.type === 'internal');
if (filtered.length === 0) {
throw new Error(
mode === 'release-notes'
? 'No user-facing changelog fragments found in changes/ (only internal fragments are present, which are dropped from release notes).'
: 'No changelog fragments found in changes/.',
);
}
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
function stripDetailsBlocks(body: string): string {
return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
}
function buildReleaseSection(
version: string,
date: string,
fragments: ChangeFragment[],
deps?: ChangelogFsDeps,
): string {
if (fragments.length === 0) { if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.'); throw new Error('No changelog fragments found in changes/.');
} }
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join('\n'); const polished = polishFragmentsWithClaude(fragments, {
mode: 'changelog',
version,
date,
deps,
});
return [`## v${version} (${date})`, '', polished, ''].join('\n');
} }
function ensureChangelogHeader(existingChangelog: string): string { function ensureChangelogHeader(existingChangelog: string): string {
@@ -392,7 +515,11 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Removed ${fragment.path}`); log(`Removed ${fragment.path}`);
} }
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps); const releaseNotesPath = writeReleaseNotesFile(
cwd,
stripDetailsBlocks(existingReleaseSection),
options?.deps,
);
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
return { return {
@@ -402,7 +529,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
}; };
} }
const releaseSection = buildReleaseSection(version, date, fragments); const releaseSection = buildReleaseSection(version, date, fragments, options?.deps);
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version); const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
for (const outputPath of outputPaths) { for (const outputPath of outputPaths) {
@@ -411,11 +538,13 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Updated ${outputPath}`); log(`Updated ${outputPath}`);
} }
const releaseNotesPath = writeReleaseNotesFile( const releaseNotesBody = polishFragmentsWithClaude(fragments, {
cwd, mode: 'release-notes',
extractReleaseSectionBody(nextChangelog, version) ?? releaseSection, version,
options?.deps, date,
); deps: options?.deps,
});
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) { for (const fragment of fragments) {
@@ -645,7 +774,7 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
throw new Error(`Missing CHANGELOG section for v${version}.`); throw new Error(`Missing CHANGELOG section for v${version}.`);
} }
return writeReleaseNotesFile(cwd, changes, options?.deps); return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
} }
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string { export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
@@ -664,7 +793,11 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.'); throw new Error('No changelog fragments found in changes/.');
} }
const changes = renderGroupedChanges(fragments); const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, { return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer: disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
+3
View File
@@ -124,9 +124,12 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
test('standalone texthooker classification excludes integrated start flow', () => { test('standalone texthooker classification excludes integrated start flow', () => {
const standalone = parseArgs(['--texthooker']); const standalone = parseArgs(['--texthooker']);
const standaloneOpenBrowser = parseArgs(['--texthooker', '--open-browser']);
const integrated = parseArgs(['--start', '--texthooker']); const integrated = parseArgs(['--start', '--texthooker']);
assert.equal(isStandaloneTexthookerCommand(standalone), true); assert.equal(isStandaloneTexthookerCommand(standalone), true);
assert.equal(standaloneOpenBrowser.texthookerOpenBrowser, true);
assert.equal(isStandaloneTexthookerCommand(standaloneOpenBrowser), true);
assert.equal(isStandaloneTexthookerCommand(integrated), false); assert.equal(isStandaloneTexthookerCommand(integrated), false);
}); });
+3
View File
@@ -71,6 +71,7 @@ export interface CliArgs {
jellyfinRemoteAnnounce: boolean; jellyfinRemoteAnnounce: boolean;
jellyfinPreviewAuth: boolean; jellyfinPreviewAuth: boolean;
texthooker: boolean; texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean; help: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
generateConfig: boolean; generateConfig: boolean;
@@ -164,6 +165,7 @@ export function parseArgs(argv: string[]): CliArgs {
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
@@ -327,6 +329,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true; else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true; else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true; else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true; else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--generate-config') args.generateConfig = true; else if (arg === '--generate-config') args.generateConfig = true;
else if (arg === '--backup-overwrite') args.backupOverwrite = true; else if (arg === '--backup-overwrite') args.backupOverwrite = true;
+1
View File
@@ -19,6 +19,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /default: 7777/); assert.match(output, /default: 7777/);
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/); assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
assert.match(output, /--stats\s+Open the stats dashboard in your browser/); assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/); assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/); assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/); assert.match(output, /--anilist-status/);
+1
View File
@@ -16,6 +16,7 @@ ${B}Session${R}
--stop Stop the running instance --stop Stop the running instance
--stats Open the stats dashboard in your browser --stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R} --texthooker Start texthooker server only ${D}(no overlay)${R}
--open-browser Open texthooker in your default browser
${B}Overlay${R} ${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay --toggle-visible-overlay Toggle subtitle overlay
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
jellyfin: { jellyfin: {
enabled: false, enabled: false,
serverUrl: '', serverUrl: '',
recentServers: [],
username: '', username: '',
deviceId: 'subminer', deviceId: 'subminer',
clientName: 'SubMiner', clientName: 'SubMiner',
@@ -265,6 +265,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.serverUrl, defaultValue: defaultConfig.jellyfin.serverUrl,
description: 'Base Jellyfin server URL (for example: http://localhost:8096).', description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
}, },
{
path: 'jellyfin.recentServers',
kind: 'array',
defaultValue: defaultConfig.jellyfin.recentServers,
description: 'Recently authenticated Jellyfin server URLs shown in setup.',
},
{ {
path: 'jellyfin.username', path: 'jellyfin.username',
kind: 'string', kind: 'string',
+20
View File
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string array.', 'Expected string array.',
); );
} }
if (Array.isArray(src.jellyfin.recentServers)) {
const seenRecentServers = new Set<string>();
resolved.jellyfin.recentServers = src.jellyfin.recentServers
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().replace(/\/+$/, ''))
.filter((item) => {
if (!item || seenRecentServers.has(item)) return false;
seenRecentServers.add(item);
return true;
})
.slice(0, 5);
} else if (src.jellyfin.recentServers !== undefined) {
warn(
'jellyfin.recentServers',
src.jellyfin.recentServers,
resolved.jellyfin.recentServers,
'Expected string array.',
);
}
} }
if (isObject(src.discordPresence)) { if (isObject(src.discordPresence)) {
+28
View File
@@ -17,6 +17,34 @@ test('jellyfin directPlayContainers are normalized', () => {
assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']); assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']);
}); });
test('jellyfin recentServers are normalized, deduped, and capped', () => {
const { context } = createResolveContext({
jellyfin: {
recentServers: [
' http://one.local:8096/ ',
'',
'http://two.local:8096',
'http://one.local:8096',
42 as unknown as string,
'http://three.local:8096',
'http://four.local:8096',
'http://five.local:8096',
'http://six.local:8096',
],
},
});
applyIntegrationConfig(context);
assert.deepEqual(context.resolved.jellyfin.recentServers, [
'http://one.local:8096',
'http://two.local:8096',
'http://three.local:8096',
'http://four.local:8096',
'http://five.local:8096',
]);
});
test('jellyfin legacy auth keys are ignored by resolver', () => { test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({ const { context } = createResolveContext({
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never, jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
+1
View File
@@ -66,6 +66,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
+16
View File
@@ -68,6 +68,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
@@ -399,6 +400,21 @@ test('handleCliCommand runs texthooker flow with browser open', () => {
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
}); });
test('handleCliCommand opens texthooker browser when requested even if config disables auto-open', () => {
const { deps, calls } = createDeps({
shouldOpenTexthookerBrowser: () => false,
});
const args = {
...makeArgs({ texthooker: true }),
texthookerOpenBrowser: true,
} as CliArgs;
handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});
test('handleCliCommand forwards resolved websocket url to texthooker startup', () => { test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678', getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
+1 -1
View File
@@ -704,7 +704,7 @@ export function handleCliCommand(
} else if (args.texthooker) { } else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort(); const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl()); deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
if (deps.shouldOpenTexthookerBrowser()) { if (args.texthookerOpenBrowser || deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`); deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
} }
deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`); deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`);
@@ -66,6 +66,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
+64 -3
View File
@@ -181,6 +181,8 @@ import {
buildJellyfinSetupFormHtml, buildJellyfinSetupFormHtml,
parseJellyfinSetupSubmissionUrl, parseJellyfinSetupSubmissionUrl,
getConfiguredJellyfinSession, getConfiguredJellyfinSession,
mergeJellyfinRecentServers,
persistJellyfinAuthSession,
type ActiveJellyfinRemotePlaybackState, type ActiveJellyfinRemotePlaybackState,
} from './main/runtime/domains/jellyfin'; } from './main/runtime/domains/jellyfin';
import { import {
@@ -389,6 +391,11 @@ import {
launchWindowsMpv, launchWindowsMpv,
} from './main/runtime/windows-mpv-launch'; } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
} from './main/runtime/jellyfin-tray-discovery';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch'; import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy'; import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -2369,6 +2376,7 @@ const {
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
getJellyfinClientInfo,
} = composeJellyfinRuntimeHandlers({ } = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: { getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
@@ -2488,11 +2496,13 @@ const {
handleJellyfinAuthCommandsMainDeps: { handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: (patch) => { patchRawConfig: (patch) => {
configService.patchRawConfig(patch); configService.patchRawConfig(patch);
refreshTrayMenuIfPresent();
}, },
authenticateWithPassword: (serverUrl, username, password, clientInfo) => authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo), authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
clearStoredSession: () => jellyfinTokenStore.clearSession(), clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
handleJellyfinListCommandsMainDeps: { handleJellyfinListCommandsMainDeps: {
@@ -2547,21 +2557,43 @@ const {
createSetupWindow: createCreateJellyfinSetupWindowHandler({ createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options), createBrowserWindow: (options) => new BrowserWindow(options),
}), }),
buildSetupFormHtml: (defaultServer, defaultUser) => buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) => authenticateWithPassword: (server, username, password, clientInfo) =>
authenticateWithPasswordRuntime(server, username, password, clientInfo), authenticateWithPasswordRuntime(server, username, password, clientInfo),
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => { patchJellyfinConfig: (session) => {
const clientInfo = getJellyfinClientInfo();
const recentServers = mergeJellyfinRecentServers(
session.serverUrl,
getResolvedConfig().jellyfin.recentServers || [],
);
configService.patchRawConfig({ configService.patchRawConfig({
jellyfin: { jellyfin: {
enabled: true, enabled: true,
serverUrl: session.serverUrl, serverUrl: session.serverUrl,
username: session.username, username: session.username,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
recentServers,
}, },
}); });
refreshTrayMenuIfPresent();
}, },
persistAuthenticatedSession: (session, clientInfo) =>
persistJellyfinAuthSession({
session,
clientInfo,
existingRecentServers: getResolvedConfig().jellyfin.recentServers || [],
saveStoredSession: (storedSession) => jellyfinTokenStore.saveSession(storedSession),
patchRawConfig: (patch) => {
configService.patchRawConfig(patch);
refreshTrayMenuIfPresent();
},
}),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logError: (message, error) => logger.error(message, error), logError: (message, error) => logger.error(message, error),
showMpvOsd: (message) => showMpvOsd(message), showMpvOsd: (message) => showMpvOsd(message),
@@ -2572,6 +2604,8 @@ const {
appState.jellyfinSetupWindow = window as BrowserWindow; appState.jellyfinSetupWindow = window as BrowserWindow;
}, },
encodeURIComponent: (value) => encodeURIComponent(value), encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096',
hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()),
}, },
}); });
@@ -5134,6 +5168,26 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setMainWindow: (window) => overlayManager.setMainWindow(window), setMainWindow: (window) => overlayManager.setMainWindow(window),
setModalWindow: (window) => overlayManager.setModalWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
function refreshTrayMenuIfPresent(): void {
if (appTray) {
ensureTrayHandler();
}
}
function getJellyfinTrayDiscoveryDeps() {
return {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getRemoteSession: () => appState.jellyfinRemoteSession,
clearStoredSession: () => jellyfinTokenStore.clearSession(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
refreshTrayMenu: () => refreshTrayMenuIfPresent(),
logger,
showMpvOsd: (message: string) => showMpvOsd(message),
};
}
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
createTrayRuntimeHandlers({ createTrayRuntimeHandlers({
resolveTrayIconPathDeps: { resolveTrayIconPathDeps: {
@@ -5150,12 +5204,19 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(), openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(), openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32', showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
toggleJellyfinDiscovery: () =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
openAnilistSetupWindow: () => openAnilistSetupWindow(), openAnilistSetupWindow: () => openAnilistSetupWindow(),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
}, },
@@ -159,8 +159,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
isDestroyed: () => false, isDestroyed: () => false,
close: () => {}, close: () => {},
}) as never, }) as never,
buildSetupFormHtml: (defaultServer, defaultUser) => buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}${state.username}</html>`,
`<html>${defaultServer}${defaultUser}</html>`,
parseSubmissionUrl: () => null, parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({ authenticateWithPassword: async () => ({
serverUrl: 'https://example.test', serverUrl: 'https://example.test',
@@ -169,6 +168,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
userId: 'id', userId: 'id',
}), }),
saveStoredSession: () => {}, saveStoredSession: () => {},
clearStoredSession: () => {},
patchJellyfinConfig: () => {}, patchJellyfinConfig: () => {},
logInfo: () => {}, logInfo: () => {},
logError: () => {}, logError: () => {},
@@ -176,6 +176,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
clearSetupWindow: () => {}, clearSetupWindow: () => {},
setSetupWindow: () => {}, setSetupWindow: () => {},
encodeURIComponent, encodeURIComponent,
defaultServerUrl: 'https://example.test',
hasStoredSession: () => false,
}, },
}); });
@@ -227,7 +227,7 @@ export function composeJellyfinRuntimeHandlers(
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand( const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({ createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps, ...options.handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(), startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
})(), })(),
); );
@@ -80,6 +80,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinRemoteAnnounce: false, jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
+51 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createHandleJellyfinAuthCommands } from './jellyfin-cli-auth'; import { createHandleJellyfinAuthCommands, persistJellyfinAuthSession } from './jellyfin-cli-auth';
test('jellyfin auth handler processes logout', async () => { test('jellyfin auth handler processes logout', async () => {
const calls: string[] = []; const calls: string[] = [];
@@ -70,6 +70,7 @@ test('jellyfin auth handler processes login', async () => {
jellyfinConfig: { jellyfinConfig: {
serverUrl: '', serverUrl: '',
username: '', username: '',
recentServers: ['http://localhost'],
}, },
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
clientInfo: { clientInfo: {
@@ -91,11 +92,60 @@ test('jellyfin auth handler processes login', async () => {
deviceId: 'd1', deviceId: 'd1',
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0', clientVersion: '1.0',
recentServers: ['http://localhost'],
}, },
}); });
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
}); });
test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
let patchPayload: unknown = null;
let storedSession: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [
' http://old.example:8096/ ',
'http://localhost:8096',
'',
'http://another.example:8096',
],
saveStoredSession: (session) => {
storedSession = session;
},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(storedSession, { accessToken: 'token', userId: 'uid' });
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: [
'http://localhost:8096',
'http://old.example:8096',
'http://another.example:8096',
],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => { test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({ const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {}, patchRawConfig: () => {},
+68 -13
View File
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
type JellyfinConfig = { type JellyfinConfig = {
serverUrl: string; serverUrl: string;
username: string; username: string;
recentServers?: string[];
}; };
type JellyfinClientInfo = { type JellyfinClientInfo = {
@@ -18,6 +19,67 @@ type JellyfinSession = {
userId: string; userId: string;
}; };
const MAX_RECENT_JELLYFIN_SERVERS = 5;
export function normalizeJellyfinServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
export function normalizeJellyfinRecentServers(values: unknown[]): string[] {
const seen = new Set<string>();
const servers: string[] = [];
for (const value of values) {
if (typeof value !== 'string') continue;
const normalized = normalizeJellyfinServerUrl(value);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
servers.push(normalized);
if (servers.length >= MAX_RECENT_JELLYFIN_SERVERS) break;
}
return servers;
}
export function mergeJellyfinRecentServers(serverUrl: string, existing: unknown[]): string[] {
return normalizeJellyfinRecentServers([serverUrl, ...existing]);
}
export function persistJellyfinAuthSession(deps: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
existingRecentServers?: unknown[];
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
patchRawConfig: (patch: {
jellyfin: Partial<{
enabled: boolean;
serverUrl: string;
username: string;
deviceId: string;
clientName: string;
clientVersion: string;
recentServers: string[];
}>;
}) => void;
}): void {
deps.saveStoredSession({
accessToken: deps.session.accessToken,
userId: deps.session.userId,
});
deps.patchRawConfig({
jellyfin: {
enabled: true,
serverUrl: deps.session.serverUrl,
username: deps.session.username,
deviceId: deps.clientInfo.deviceId,
clientName: deps.clientInfo.clientName,
clientVersion: deps.clientInfo.clientVersion,
recentServers: mergeJellyfinRecentServers(
deps.session.serverUrl,
deps.existingRecentServers || [],
),
},
});
}
export function createHandleJellyfinAuthCommands(deps: { export function createHandleJellyfinAuthCommands(deps: {
patchRawConfig: (patch: { patchRawConfig: (patch: {
jellyfin: Partial<{ jellyfin: Partial<{
@@ -66,19 +128,12 @@ export function createHandleJellyfinAuthCommands(deps: {
password, password,
params.clientInfo, params.clientInfo,
); );
deps.saveStoredSession({ persistJellyfinAuthSession({
accessToken: session.accessToken, session,
userId: session.userId, clientInfo: params.clientInfo,
}); existingRecentServers: params.jellyfinConfig.recentServers || [],
deps.patchRawConfig({ saveStoredSession: (storedSession) => deps.saveStoredSession(storedSession),
jellyfin: { patchRawConfig: (patch) => deps.patchRawConfig(patch),
enabled: true,
serverUrl: session.serverUrl,
username: session.username,
deviceId: params.clientInfo.deviceId,
clientName: params.clientInfo.clientName,
clientVersion: params.clientInfo.clientVersion,
},
}); });
deps.logInfo(`Jellyfin login succeeded for ${session.username}.`); deps.logInfo(`Jellyfin login succeeded for ${session.username}.`);
return true; return true;
@@ -94,17 +94,17 @@ test('jellyfin remote announce main deps builder maps callbacks', async () => {
const calls: string[] = []; const calls: string[] = [];
const session = { advertiseNow: async () => true }; const session = { advertiseNow: async () => true };
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({ const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
startJellyfinRemoteSession: async () => { startJellyfinRemoteSession: async (options) => {
calls.push('start'); calls.push(`start:${options?.explicit ? 'explicit' : 'default'}`);
}, },
getRemoteSession: () => session, getRemoteSession: () => session,
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
})(); })();
await deps.startJellyfinRemoteSession(); await deps.startJellyfinRemoteSession({ explicit: true });
assert.equal(deps.getRemoteSession(), session); assert.equal(deps.getRemoteSession(), session);
deps.logInfo('visible'); deps.logInfo('visible');
deps.logWarn('not-visible'); deps.logWarn('not-visible');
assert.deepEqual(calls, ['start', 'info:visible', 'warn:not-visible']); assert.deepEqual(calls, ['start:explicit', 'info:visible', 'warn:not-visible']);
}); });
+1 -1
View File
@@ -51,7 +51,7 @@ export function createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler(
deps: HandleJellyfinRemoteAnnounceCommandMainDeps, deps: HandleJellyfinRemoteAnnounceCommandMainDeps,
) { ) {
return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({ return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({
startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(), startJellyfinRemoteSession: (options) => deps.startJellyfinRemoteSession(options),
getRemoteSession: () => deps.getRemoteSession(), getRemoteSession: () => deps.getRemoteSession(),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logWarn: (message: string) => deps.logWarn(message), logWarn: (message: string) => deps.logWarn(message),
@@ -23,10 +23,10 @@ test('remote announce handler no-ops when flag is disabled', async () => {
test('remote announce handler warns when session is unavailable', async () => { test('remote announce handler warns when session is unavailable', async () => {
const warnings: string[] = []; const warnings: string[] = [];
let started = false; let startOptions: { explicit?: boolean } | undefined;
const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({
startJellyfinRemoteSession: async () => { startJellyfinRemoteSession: async (options) => {
started = true; startOptions = options;
}, },
getRemoteSession: () => null, getRemoteSession: () => null,
logInfo: () => {}, logInfo: () => {},
@@ -38,7 +38,7 @@ test('remote announce handler warns when session is unavailable', async () => {
} as never); } as never);
assert.equal(handled, true); assert.equal(handled, true);
assert.equal(started, true); assert.deepEqual(startOptions, { explicit: true });
assert.deepEqual(warnings, ['Jellyfin remote session is not available.']); assert.deepEqual(warnings, ['Jellyfin remote session is not available.']);
}); });
@@ -5,7 +5,7 @@ type JellyfinRemoteSession = {
}; };
export function createHandleJellyfinRemoteAnnounceCommand(deps: { export function createHandleJellyfinRemoteAnnounceCommand(deps: {
startJellyfinRemoteSession: () => Promise<void>; startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
getRemoteSession: () => JellyfinRemoteSession | null; getRemoteSession: () => JellyfinRemoteSession | null;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
@@ -15,7 +15,7 @@ export function createHandleJellyfinRemoteAnnounceCommand(deps: {
return false; return false;
} }
await deps.startJellyfinRemoteSession(); await deps.startJellyfinRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession(); const remoteSession = deps.getRemoteSession();
if (!remoteSession) { if (!remoteSession) {
deps.logWarn('Jellyfin remote session is not available.'); deps.logWarn('Jellyfin remote session is not available.');
@@ -78,6 +78,37 @@ test('start handler no-ops when remote control is disabled', async () => {
assert.equal(created, false); assert.equal(created, false);
}); });
test('start handler respects auto-connect unless explicit start is requested', async () => {
let created = 0;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ remoteControlAutoConnect: false }),
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () => {
created += 1;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote();
assert.equal(created, 0);
await startRemote({ explicit: true });
assert.equal(created, 1);
});
test('start handler creates, starts, and stores session', async () => { test('start handler creates, starts, and stores session', async () => {
let storedSession: { let storedSession: {
start: () => void; start: () => void;
@@ -53,11 +53,11 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string, details?: unknown) => void;
}) { }) {
return async (): Promise<void> => { return async (options?: { explicit?: boolean }): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig(); const jellyfinConfig = deps.getJellyfinConfig();
if (jellyfinConfig.enabled === false) return; if (jellyfinConfig.enabled === false) return;
if (jellyfinConfig.remoteControlEnabled === false) return; if (jellyfinConfig.remoteControlEnabled === false) return;
if (jellyfinConfig.remoteControlAutoConnect === false) return; if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
const existing = deps.getCurrentSession(); const existing = deps.getCurrentSession();
@@ -4,12 +4,28 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se
test('open jellyfin setup window main deps builder maps callbacks', async () => { test('open jellyfin setup window main deps builder maps callbacks', async () => {
const calls: string[] = []; const calls: string[] = [];
const expectedState = {
servers: [],
selectedServerUrl: 'a',
username: 'b',
hasStoredSession: false,
statusMessage: '',
statusKind: 'idle' as const,
};
let capturedBuildState: unknown = null;
let capturedParseUrl = '';
const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({ const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({
maybeFocusExistingSetupWindow: () => false, maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => ({}) as never, createSetupWindow: () => ({}) as never,
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }), getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
buildSetupFormHtml: () => '<html></html>', buildSetupFormHtml: (state) => {
parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }), capturedBuildState = state;
return '<html></html>';
},
parseSubmissionUrl: (rawUrl) => {
capturedParseUrl = rawUrl;
return { action: 'login', server: 's', username: 'u', password: 'p' };
},
authenticateWithPassword: async () => ({ authenticateWithPassword: async () => ({
serverUrl: 'http://127.0.0.1:8096', serverUrl: 'http://127.0.0.1:8096',
username: 'alice', username: 'alice',
@@ -22,13 +38,17 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
deviceId: 'dev', deviceId: 'dev',
}), }),
saveStoredSession: () => calls.push('save'), saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear-session'),
patchJellyfinConfig: () => calls.push('patch'), patchJellyfinConfig: () => calls.push('patch'),
persistAuthenticatedSession: () => calls.push('persist'),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`), logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear'), clearSetupWindow: () => calls.push('clear'),
setSetupWindow: () => calls.push('set-window'), setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value), encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => true,
})(); })();
assert.equal(deps.maybeFocusExistingSetupWindow(), false); assert.equal(deps.maybeFocusExistingSetupWindow(), false);
@@ -36,12 +56,16 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
serverUrl: 'http://127.0.0.1:8096', serverUrl: 'http://127.0.0.1:8096',
username: 'alice', username: 'alice',
}); });
assert.equal(deps.buildSetupFormHtml('a', 'b'), '<html></html>'); assert.equal(deps.buildSetupFormHtml(expectedState), '<html></html>');
assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), { assert.deepEqual(capturedBuildState, expectedState);
const setupUrl = 'subminer://jellyfin-setup?x=1';
assert.deepEqual(deps.parseSubmissionUrl(setupUrl), {
action: 'login',
server: 's', server: 's',
username: 'u', username: 'u',
password: 'p', password: 'p',
}); });
assert.equal(capturedParseUrl, setupUrl);
assert.deepEqual( assert.deepEqual(
await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()), await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()),
{ {
@@ -52,21 +76,35 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
}, },
); );
deps.saveStoredSession({ accessToken: 'token', userId: 'uid' }); deps.saveStoredSession({ accessToken: 'token', userId: 'uid' });
deps.clearStoredSession();
deps.patchJellyfinConfig({ deps.patchJellyfinConfig({
serverUrl: 'http://127.0.0.1:8096', serverUrl: 'http://127.0.0.1:8096',
username: 'alice', username: 'alice',
accessToken: 'token', accessToken: 'token',
userId: 'uid', userId: 'uid',
}); });
deps.persistAuthenticatedSession?.(
{
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
deps.getJellyfinClientInfo(),
);
deps.logInfo('ok'); deps.logInfo('ok');
deps.logError('bad', null); deps.logError('bad', null);
deps.showMpvOsd('toast'); deps.showMpvOsd('toast');
deps.clearSetupWindow(); deps.clearSetupWindow();
deps.setSetupWindow({} as never); deps.setSetupWindow({} as never);
assert.equal(deps.encodeURIComponent('a b'), 'a%20b'); assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096');
assert.equal(deps.hasStoredSession(), true);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'save', 'save',
'clear-session',
'patch', 'patch',
'persist',
'info:ok', 'info:ok',
'error:bad', 'error:bad',
'osd:toast', 'osd:toast',
@@ -9,19 +9,24 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(), maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
createSetupWindow: () => deps.createSetupWindow(), createSetupWindow: () => deps.createSetupWindow(),
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state),
deps.buildSetupFormHtml(defaultServer, defaultUser),
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl), parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) => authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo), deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredSession: (session) => deps.saveStoredSession(session), saveStoredSession: (session) => deps.saveStoredSession(session),
clearStoredSession: () => deps.clearStoredSession(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined,
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error), logError: (message: string, error: unknown) => deps.logError(message, error),
showMpvOsd: (message: string) => deps.showMpvOsd(message), showMpvOsd: (message: string) => deps.showMpvOsd(message),
clearSetupWindow: () => deps.clearSetupWindow(), clearSetupWindow: () => deps.clearSetupWindow(),
setSetupWindow: (window) => deps.setSetupWindow(window), setSetupWindow: (window) => deps.setSetupWindow(window),
encodeURIComponent: (value: string) => deps.encodeURIComponent(value), encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
defaultServerUrl: deps.defaultServerUrl,
hasStoredSession: () => deps.hasStoredSession(),
}); });
} }
+235 -21
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
buildJellyfinSetupFormHtml, buildJellyfinSetupFormHtml,
buildJellyfinSetupViewState,
createHandleJellyfinSetupWindowClosedHandler, createHandleJellyfinSetupWindowClosedHandler,
createHandleJellyfinSetupNavigationHandler, createHandleJellyfinSetupNavigationHandler,
createHandleJellyfinSetupSubmissionHandler, createHandleJellyfinSetupSubmissionHandler,
@@ -12,10 +13,50 @@ import {
} from './jellyfin-setup-window'; } from './jellyfin-setup-window';
test('buildJellyfinSetupFormHtml escapes default values', () => { test('buildJellyfinSetupFormHtml escapes default values', () => {
const html = buildJellyfinSetupFormHtml('http://host/"x"', 'user"name'); const html = buildJellyfinSetupFormHtml({
servers: [
{
serverUrl: 'http://host/"x"',
label: 'Configured "Server"',
source: 'config',
},
],
selectedServerUrl: 'http://host/"x"',
username: 'user"name',
hasStoredSession: true,
statusMessage: 'Ready "now"',
statusKind: 'success',
});
assert.ok(html.includes('http://host/&quot;x&quot;')); assert.ok(html.includes('http://host/&quot;x&quot;'));
assert.ok(html.includes('user&quot;name')); assert.ok(html.includes('user&quot;name'));
assert.ok(html.includes('Ready &quot;now&quot;'));
assert.ok(html.includes('Logout'));
assert.ok(html.includes('subminer://jellyfin-setup?')); assert.ok(html.includes('subminer://jellyfin-setup?'));
assert.equal(html.includes('params.set("password"'), false);
});
test('buildJellyfinSetupViewState composes config, recent, and default servers', () => {
const state = buildJellyfinSetupViewState({
config: {
serverUrl: ' http://configured:8096/ ',
username: 'alice',
recentServers: ['http://recent:8096', 'http://configured:8096', ''],
},
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: false,
});
assert.deepEqual(
state.servers.map((server) => [server.serverUrl, server.source]),
[
['http://configured:8096', 'config'],
['http://recent:8096', 'recent'],
['http://127.0.0.1:8096', 'default'],
],
);
assert.equal(state.selectedServerUrl, 'http://configured:8096');
assert.equal(state.username, 'alice');
assert.equal(state.statusKind, 'idle');
}); });
test('maybe focus jellyfin setup window no-ops without window', () => { test('maybe focus jellyfin setup window no-ops without window', () => {
@@ -28,13 +69,26 @@ test('maybe focus jellyfin setup window no-ops without window', () => {
test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => { test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => {
const parsed = parseJellyfinSetupSubmissionUrl( const parsed = parseJellyfinSetupSubmissionUrl(
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b',
); );
assert.deepEqual(parsed, { assert.deepEqual(parsed, {
action: 'login',
server: 'http://localhost', server: 'http://localhost',
username: 'a', username: 'a',
password: 'b', password: 'b',
}); });
assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=logout'), {
action: 'logout',
server: '',
username: '',
password: '',
});
assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=done'), {
action: 'done',
server: '',
username: '',
password: '',
});
assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null); assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null);
}); });
@@ -42,14 +96,18 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
const calls: string[] = []; const calls: string[] = [];
let patchPayload: unknown = null; let patchPayload: unknown = null;
let savedSession: unknown = null; let savedSession: unknown = null;
let authPassword = '';
const handler = createHandleJellyfinSetupSubmissionHandler({ const handler = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => ({ authenticateWithPassword: async (_server, _username, password) => {
serverUrl: 'http://localhost', authPassword = password;
username: 'user', return {
accessToken: 'token', serverUrl: 'http://localhost',
userId: 'uid', username: 'user',
}), accessToken: 'token',
userId: 'uid',
};
},
getJellyfinClientInfo: () => ({ getJellyfinClientInfo: () => ({
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0', clientVersion: '1.0',
@@ -59,6 +117,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
savedSession = session; savedSession = session;
calls.push('save'); calls.push('save');
}, },
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: (session) => { patchJellyfinConfig: (session) => {
patchPayload = session; patchPayload = session;
calls.push('patch'); calls.push('patch');
@@ -67,13 +126,16 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
logError: () => calls.push('error'), logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'), closeSetupWindow: () => calls.push('close'),
reloadSetupWindow: () => calls.push('reload'),
}); });
const handled = await handler( const handled = await handler(
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
'b',
); );
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'close']); assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'reload']);
assert.equal(authPassword, 'b');
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' }); assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
assert.deepEqual(patchPayload, { assert.deepEqual(patchPayload, {
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
@@ -96,18 +158,155 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
deviceId: 'did', deviceId: 'did',
}), }),
saveStoredSession: () => calls.push('save'), saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'), patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'), logInfo: () => calls.push('info'),
logError: () => calls.push('error'), logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'), closeSetupWindow: () => calls.push('close'),
reloadSetupWindow: (_state) => calls.push('reload'),
}); });
const handled = await handler( const handled = await handler(
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b',
); );
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials']); assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials', 'reload']);
});
test('createHandleJellyfinSetupSubmissionHandler reports logout failure inline', async () => {
const calls: string[] = [];
let reloadState: unknown = null;
const handler = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => {
throw new Error('should not authenticate');
},
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => {
throw new Error('logout failed');
},
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'),
reloadSetupWindow: (state) => {
reloadState = state;
calls.push('reload');
},
});
assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true);
assert.deepEqual(calls, [
'error:Jellyfin logout failed',
'osd:Jellyfin logout failed: logout failed',
'reload',
]);
assert.deepEqual(reloadState, {
statusMessage: 'logout failed',
statusKind: 'error',
});
});
test('createHandleJellyfinSetupSubmissionHandler ignores concurrent login submissions', async () => {
const calls: string[] = [];
type TestSession = {
serverUrl: string;
username: string;
accessToken: string;
userId: string;
};
let finishAuth: ((session: TestSession) => void) | undefined;
const handler = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () =>
new Promise<TestSession>((resolve) => {
finishAuth = resolve;
}),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'),
reloadSetupWindow: (state) => calls.push(`reload:${state?.statusKind || 'none'}`),
});
const first = handler(
'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
'first',
);
const second = await handler(
'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a',
'second',
);
assert.equal(second, true);
const resolveAuth = finishAuth;
if (!resolveAuth) {
throw new Error('missing auth resolver');
}
resolveAuth({
serverUrl: 'http://localhost',
username: 'a',
accessToken: 'token',
userId: 'uid',
});
assert.equal(await first, true);
assert.deepEqual(calls, [
'osd:Jellyfin login already in progress',
'reload:loading',
'save',
'patch',
'info',
'osd:Jellyfin login success',
'reload:success',
]);
});
test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async () => {
const calls: string[] = [];
const handler = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => {
throw new Error('should not authenticate');
},
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: (message) => calls.push(message),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
closeSetupWindow: () => calls.push('close'),
reloadSetupWindow: () => calls.push('reload'),
});
assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true);
assert.equal(await handler('subminer://jellyfin-setup?action=done'), true);
assert.deepEqual(calls, [
'clear',
'Cleared stored Jellyfin auth session.',
'osd:Jellyfin logged out',
'reload',
'close',
]);
}); });
test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => { test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => {
@@ -200,7 +399,10 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
showMpvOsd: () => {}, showMpvOsd: () => {},
clearSetupWindow: () => {}, clearSetupWindow: () => {},
setSetupWindow: () => {}, setSetupWindow: () => {},
clearStoredSession: () => {},
encodeURIComponent: (value) => value, encodeURIComponent: (value) => value,
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => false,
}); });
handler(); handler();
@@ -224,6 +426,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
willNavigateHandler = handler; willNavigateHandler = handler;
} }
}, },
executeJavaScript: async () => 'pass',
}, },
loadURL: (url: string) => { loadURL: (url: string) => {
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`); calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
@@ -240,21 +443,29 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
const handler = createOpenJellyfinSetupWindowHandler({ const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false, maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow, createSetupWindow: () => fakeWindow,
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }), getResolvedJellyfinConfig: () => ({
buildSetupFormHtml: (server, username) => `<html>${server}|${username}</html>`,
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async () => ({
serverUrl: 'http://localhost:8096', serverUrl: 'http://localhost:8096',
username: 'alice', username: 'alice',
accessToken: 'token', recentServers: [],
userId: 'uid',
}), }),
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}|${state.username}</html>`,
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async (_server, _username, password) => {
calls.push(`password:${password}`);
return {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
};
},
getJellyfinClientInfo: () => ({ getJellyfinClientInfo: () => ({
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0', clientVersion: '1.0',
deviceId: 'did', deviceId: 'did',
}), }),
saveStoredSession: () => calls.push('save'), saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'), patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'), logInfo: () => calls.push('info'),
logError: () => calls.push('error'), logError: () => calls.push('error'),
@@ -262,6 +473,8 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
clearSetupWindow: () => calls.push('clear-window'), clearSetupWindow: () => calls.push('clear-window'),
setSetupWindow: () => calls.push('set-window'), setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value), encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => true,
}); });
handler(); handler();
@@ -281,15 +494,16 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
prevented = true; prevented = true;
}, },
}, },
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass', 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=alice',
); );
await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(prevented, true); assert.equal(prevented, true);
assert.ok(calls.includes('password:pass'));
assert.ok(calls.includes('save')); assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch')); assert.ok(calls.includes('patch'));
assert.ok(calls.includes('osd:Jellyfin login success')); assert.ok(calls.includes('osd:Jellyfin login success'));
assert.ok(calls.includes('close')); assert.ok(calls.includes('load:data-url'));
const onClosed = closedHandler as (() => void) | null; const onClosed = closedHandler as (() => void) | null;
if (!onClosed) { if (!onClosed) {
+286 -38
View File
@@ -1,3 +1,5 @@
import { normalizeJellyfinRecentServers } from './jellyfin-cli-auth';
type JellyfinSession = { type JellyfinSession = {
serverUrl: string; serverUrl: string;
username: string; username: string;
@@ -17,6 +19,7 @@ type FocusableWindowLike = {
type JellyfinSetupWebContentsLike = { type JellyfinSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
executeJavaScript?: (code: string, userGesture?: boolean) => Promise<unknown>;
}; };
type JellyfinSetupWindowLike = FocusableWindowLike & { type JellyfinSetupWindowLike = FocusableWindowLike & {
@@ -27,10 +30,43 @@ type JellyfinSetupWindowLike = FocusableWindowLike & {
close: () => void; close: () => void;
}; };
export type JellyfinSetupAction = 'login' | 'logout' | 'done';
export type JellyfinSetupServerOption = {
serverUrl: string;
label: string;
source: 'config' | 'recent' | 'default';
username?: string;
};
export type JellyfinSetupViewState = {
servers: JellyfinSetupServerOption[];
selectedServerUrl: string;
username: string;
hasStoredSession: boolean;
statusMessage: string;
statusKind: 'idle' | 'success' | 'error' | 'loading';
};
type JellyfinSetupViewOverrides = {
selectedServerUrl?: string;
username?: string;
statusMessage?: string;
statusKind?: JellyfinSetupViewState['statusKind'];
};
function escapeHtmlAttr(value: string): string { function escapeHtmlAttr(value: string): string {
return value.replace(/"/g, '&quot;'); return value.replace(/"/g, '&quot;');
} }
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: { export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null; getSetupWindow: () => FocusableWindowLike | null;
}) { }) {
@@ -44,55 +80,151 @@ export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
}; };
} }
export function buildJellyfinSetupFormHtml(defaultServer: string, defaultUser: string): string { export function buildJellyfinSetupViewState(input: {
config: {
serverUrl?: string | null;
username?: string | null;
recentServers?: unknown[];
};
defaultServerUrl: string;
hasStoredSession: boolean;
statusMessage?: string;
statusKind?: JellyfinSetupViewState['statusKind'];
selectedServerUrl?: string;
username?: string;
}): JellyfinSetupViewState {
const configServer = normalizeJellyfinRecentServers([input.config.serverUrl || ''])[0] || '';
const recentServers = normalizeJellyfinRecentServers(input.config.recentServers || []);
const defaultServer = normalizeJellyfinRecentServers([input.defaultServerUrl])[0] || '';
const seen = new Set<string>();
const servers: JellyfinSetupServerOption[] = [];
const addServer = (serverUrl: string, source: JellyfinSetupServerOption['source']) => {
if (!serverUrl || seen.has(serverUrl)) return;
seen.add(serverUrl);
servers.push({
serverUrl,
label:
source === 'config'
? `${serverUrl} (configured)`
: source === 'default'
? `${serverUrl} (default)`
: serverUrl,
source,
});
};
addServer(configServer, 'config');
for (const recent of recentServers) addServer(recent, 'recent');
addServer(defaultServer, 'default');
const selectedServerUrl =
normalizeJellyfinRecentServers([input.selectedServerUrl || ''])[0] ||
configServer ||
recentServers[0] ||
defaultServer;
return {
servers,
selectedServerUrl,
username: input.username ?? input.config.username ?? '',
hasStoredSession: input.hasStoredSession,
statusMessage: input.statusMessage || '',
statusKind: input.statusKind || 'idle',
};
}
export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): string {
const options = state.servers
.map(
(server) =>
`<option value="${escapeHtmlAttr(server.serverUrl)}"${
server.serverUrl === state.selectedServerUrl ? ' selected' : ''
}>${escapeHtml(server.label)}</option>`,
)
.join('');
const statusClass = `status ${state.statusKind}`;
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Jellyfin Setup</title> <title>Jellyfin Setup</title>
<style> <style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; } :root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
main { padding: 20px; } body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
h1 { margin: 0 0 8px; font-size: 22px; } main { padding: 22px; }
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; } h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
label { display: block; margin: 10px 0 4px; font-size: 13px; } p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; } label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; } input, select { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; } button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.actions .primary { grid-column: 1 / -1; }
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
.status.success { color: var(--accent); }
.status.error { color: var(--danger); }
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>Jellyfin Setup</h1> <h1>Jellyfin Setup</h1>
<p>Login info is used to fetch a token and save Jellyfin config values.</p> <p>Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
<form id="form"> <form id="form">
<label for="serverSelect">Known servers</label>
<select id="serverSelect">${options}</select>
<label for="server">Server URL</label> <label for="server">Server URL</label>
<input id="server" name="server" value="${escapeHtmlAttr(defaultServer)}" required /> <input id="server" name="server" value="${escapeHtmlAttr(state.selectedServerUrl)}" required />
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" name="username" value="${escapeHtmlAttr(defaultUser)}" required /> <input id="username" name="username" value="${escapeHtmlAttr(state.username)}" required />
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" name="password" type="password" required /> <input id="password" name="password" type="password" required />
<button type="submit">Save and Login</button> <div id="status" class="${statusClass}">${escapeHtml(state.statusMessage)}</div>
<div class="actions">
<button class="primary" type="submit">Login</button>
${
state.hasStoredSession
? '<button id="logout" class="danger" type="button">Logout</button>'
: '<span></span>'
}
<button id="done" class="secondary" type="button">Done</button>
</div>
<div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div> <div class="hint">Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...</div>
</form> </form>
</main> </main>
<script> <script>
const form = document.getElementById("form"); const form = document.getElementById("form");
const select = document.getElementById("serverSelect");
const server = document.getElementById("server");
select?.addEventListener("change", () => {
server.value = select.value || server.value;
});
function submitAction(action) {
const params = new URLSearchParams();
params.set("action", action);
if (action === "login") {
const data = new FormData(form);
params.set("server", String(data.get("server") || ""));
params.set("username", String(data.get("username") || ""));
window.__subminerJellyfinPassword = String(data.get("password") || "");
}
window.location.href = "subminer://jellyfin-setup?" + params.toString();
}
form?.addEventListener("submit", (event) => { form?.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const data = new FormData(form); submitAction("login");
const params = new URLSearchParams();
params.set("server", String(data.get("server") || ""));
params.set("username", String(data.get("username") || ""));
params.set("password", String(data.get("password") || ""));
window.location.href = "subminer://jellyfin-setup?" + params.toString();
}); });
document.getElementById("logout")?.addEventListener("click", () => submitAction("logout"));
document.getElementById("done")?.addEventListener("click", () => submitAction("done"));
</script> </script>
</body> </body>
</html>`; </html>`;
} }
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): { export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
action: JellyfinSetupAction;
server: string; server: string;
username: string; username: string;
password: string; password: string;
@@ -101,7 +233,11 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
return null; return null;
} }
const parsed = new URL(rawUrl); const parsed = new URL(rawUrl);
const rawAction = parsed.searchParams.get('action') || 'login';
const action: JellyfinSetupAction =
rawAction === 'logout' || rawAction === 'done' ? rawAction : 'login';
return { return {
action,
server: parsed.searchParams.get('server') || '', server: parsed.searchParams.get('server') || '',
username: parsed.searchParams.get('username') || '', username: parsed.searchParams.get('username') || '',
password: parsed.searchParams.get('password') || '', password: parsed.searchParams.get('password') || '',
@@ -111,7 +247,7 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
export function createHandleJellyfinSetupSubmissionHandler(deps: { export function createHandleJellyfinSetupSubmissionHandler(deps: {
parseSubmissionUrl: ( parseSubmissionUrl: (
rawUrl: string, rawUrl: string,
) => { server: string; username: string; password: string } | null; ) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
authenticateWithPassword: ( authenticateWithPassword: (
server: string, server: string,
username: string, username: string,
@@ -120,37 +256,95 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
) => Promise<JellyfinSession>; ) => Promise<JellyfinSession>;
getJellyfinClientInfo: () => JellyfinClientInfo; getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredSession: (session: { accessToken: string; userId: string }) => void; saveStoredSession: (session: { accessToken: string; userId: string }) => void;
clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void; patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void; showMpvOsd: (message: string) => void;
closeSetupWindow: () => void; closeSetupWindow: () => void;
reloadSetupWindow: (state?: JellyfinSetupViewOverrides) => void;
}) { }) {
return async (rawUrl: string): Promise<boolean> => { let loginInFlight = false;
return async (rawUrl: string, passwordOverride?: string): Promise<boolean> => {
const submission = deps.parseSubmissionUrl(rawUrl); const submission = deps.parseSubmissionUrl(rawUrl);
if (!submission) { if (!submission) {
return false; return false;
} }
if (submission.action === 'done') {
deps.closeSetupWindow();
return true;
}
if (submission.action === 'logout') {
try {
deps.clearStoredSession();
deps.logInfo('Cleared stored Jellyfin auth session.');
deps.showMpvOsd('Jellyfin logged out');
deps.reloadSetupWindow({
statusMessage: 'Jellyfin session cleared.',
statusKind: 'success',
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logError('Jellyfin logout failed', error);
deps.showMpvOsd(`Jellyfin logout failed: ${message}`);
deps.reloadSetupWindow({
statusMessage: message,
statusKind: 'error',
});
}
return true;
}
if (loginInFlight) {
deps.showMpvOsd('Jellyfin login already in progress');
deps.reloadSetupWindow({
selectedServerUrl: submission.server,
username: submission.username,
statusMessage: 'Jellyfin login already in progress.',
statusKind: 'loading',
});
return true;
}
loginInFlight = true;
try { try {
const clientInfo = deps.getJellyfinClientInfo();
const session = await deps.authenticateWithPassword( const session = await deps.authenticateWithPassword(
submission.server, submission.server,
submission.username, submission.username,
submission.password, passwordOverride ?? submission.password,
deps.getJellyfinClientInfo(), clientInfo,
); );
deps.saveStoredSession({ if (deps.persistAuthenticatedSession) {
accessToken: session.accessToken, deps.persistAuthenticatedSession(session, clientInfo);
userId: session.userId, } else {
}); deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
deps.patchJellyfinConfig(session); deps.patchJellyfinConfig(session);
}
deps.logInfo(`Jellyfin setup saved for ${session.username}.`); deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
deps.showMpvOsd('Jellyfin login success'); deps.showMpvOsd('Jellyfin login success');
deps.closeSetupWindow(); deps.reloadSetupWindow({
selectedServerUrl: session.serverUrl,
username: session.username,
statusMessage: `Authenticated as ${session.username}.`,
statusKind: 'success',
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
deps.logError('Jellyfin setup failed', error); deps.logError('Jellyfin setup failed', error);
deps.showMpvOsd(`Jellyfin login failed: ${message}`); deps.showMpvOsd(`Jellyfin login failed: ${message}`);
deps.reloadSetupWindow({
selectedServerUrl: submission.server,
username: submission.username,
statusMessage: message,
statusKind: 'error',
});
} finally {
loginInFlight = false;
} }
return true; return true;
}; };
@@ -173,6 +367,27 @@ export function createHandleJellyfinSetupNavigationHandler(deps: {
}; };
} }
async function readJellyfinSetupPasswordFromWindow(
setupWindow: JellyfinSetupWindowLike,
): Promise<string | undefined> {
const executeJavaScript = setupWindow.webContents.executeJavaScript;
if (!executeJavaScript) {
return undefined;
}
const value = await executeJavaScript(
`(() => {
const input = document.getElementById("password");
const password = String(window.__subminerJellyfinPassword || input?.value || "");
window.__subminerJellyfinPassword = "";
if (input) input.value = "";
return password;
})()`,
true,
);
return typeof value === 'string' ? value : '';
}
export function createHandleJellyfinSetupWindowClosedHandler(deps: { export function createHandleJellyfinSetupWindowClosedHandler(deps: {
clearSetupWindow: () => void; clearSetupWindow: () => void;
}) { }) {
@@ -192,11 +407,15 @@ export function createOpenJellyfinSetupWindowHandler<
>(deps: { >(deps: {
maybeFocusExistingSetupWindow: () => boolean; maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow; createSetupWindow: () => TWindow;
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null }; getResolvedJellyfinConfig: () => {
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string; serverUrl?: string | null;
username?: string | null;
recentServers?: unknown[];
};
buildSetupFormHtml: (state: JellyfinSetupViewState) => string;
parseSubmissionUrl: ( parseSubmissionUrl: (
rawUrl: string, rawUrl: string,
) => { server: string; username: string; password: string } | null; ) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
authenticateWithPassword: ( authenticateWithPassword: (
server: string, server: string,
username: string, username: string,
@@ -205,13 +424,17 @@ export function createOpenJellyfinSetupWindowHandler<
) => Promise<JellyfinSession>; ) => Promise<JellyfinSession>;
getJellyfinClientInfo: () => JellyfinClientInfo; getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredSession: (session: { accessToken: string; userId: string }) => void; saveStoredSession: (session: { accessToken: string; userId: string }) => void;
clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void; patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void; showMpvOsd: (message: string) => void;
clearSetupWindow: () => void; clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void; setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string; encodeURIComponent: (value: string) => string;
defaultServerUrl: string;
hasStoredSession: () => boolean;
}) { }) {
return (): void => { return (): void => {
if (deps.maybeFocusExistingSetupWindow()) { if (deps.maybeFocusExistingSetupWindow()) {
@@ -219,17 +442,30 @@ export function createOpenJellyfinSetupWindowHandler<
} }
const setupWindow = deps.createSetupWindow(); const setupWindow = deps.createSetupWindow();
const defaults = deps.getResolvedJellyfinConfig(); const loadSetupForm = (overrides: JellyfinSetupViewOverrides = {}) => {
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096'; const state = buildJellyfinSetupViewState({
const defaultUser = defaults.username || ''; config: deps.getResolvedJellyfinConfig(),
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser); defaultServerUrl: deps.defaultServerUrl,
hasStoredSession: deps.hasStoredSession(),
selectedServerUrl: overrides.selectedServerUrl,
username: overrides.username,
statusMessage: overrides.statusMessage,
statusKind: overrides.statusKind,
});
const formHtml = deps.buildSetupFormHtml(state);
void setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`);
};
const handleSubmission = createHandleJellyfinSetupSubmissionHandler({ const handleSubmission = createHandleJellyfinSetupSubmissionHandler({
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl), parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
authenticateWithPassword: (server, username, password, clientInfo) => authenticateWithPassword: (server, username, password, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo), deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredSession: (session) => deps.saveStoredSession(session), saveStoredSession: (session) => deps.saveStoredSession(session),
clearStoredSession: () => deps.clearStoredSession(),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined,
logInfo: (message) => deps.logInfo(message), logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error), logError: (message, error) => deps.logError(message, error),
showMpvOsd: (message) => deps.showMpvOsd(message), showMpvOsd: (message) => deps.showMpvOsd(message),
@@ -238,10 +474,22 @@ export function createOpenJellyfinSetupWindowHandler<
setupWindow.close(); setupWindow.close();
} }
}, },
reloadSetupWindow: (state) => {
if (!setupWindow.isDestroyed()) {
loadSetupForm(state);
}
},
}); });
const handleNavigation = createHandleJellyfinSetupNavigationHandler({ const handleNavigation = createHandleJellyfinSetupNavigationHandler({
setupSchemePrefix: 'subminer://jellyfin-setup', setupSchemePrefix: 'subminer://jellyfin-setup',
handleSubmission: (rawUrl) => handleSubmission(rawUrl), handleSubmission: async (rawUrl) => {
const submission = deps.parseSubmissionUrl(rawUrl);
const password =
submission?.action === 'login' && !submission.password
? await readJellyfinSetupPasswordFromWindow(setupWindow)
: undefined;
return handleSubmission(rawUrl, password);
},
logError: (message, error) => deps.logError(message, error), logError: (message, error) => deps.logError(message, error),
}); });
const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({ const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({
@@ -262,7 +510,7 @@ export function createOpenJellyfinSetupWindowHandler<
}, },
}); });
}); });
void setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`); loadSetupForm();
setupWindow.on('closed', () => { setupWindow.on('closed', () => {
handleWindowClosed(); handleWindowClosed();
}); });
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearJellyfinAuthSessionAndRefreshTray,
isJellyfinConfiguredForTray,
toggleJellyfinDiscoveryFromTray,
} from './jellyfin-tray-discovery';
test('detects Jellyfin tray configuration when Jellyfin has a server URL', () => {
assert.equal(
isJellyfinConfiguredForTray({
getResolvedJellyfinConfig: () => ({
enabled: true,
serverUrl: 'http://server:8096',
accessToken: 'token',
userId: 'user',
}),
}),
true,
);
assert.equal(
isJellyfinConfiguredForTray({
getResolvedJellyfinConfig: () => ({
enabled: true,
serverUrl: 'http://server:8096',
}),
}),
true,
);
assert.equal(
isJellyfinConfiguredForTray({
getResolvedJellyfinConfig: () => ({
enabled: false,
serverUrl: 'http://server:8096',
accessToken: 'token',
userId: 'user',
}),
}),
false,
);
assert.equal(
isJellyfinConfiguredForTray({
getResolvedJellyfinConfig: () => ({
enabled: true,
serverUrl: '',
accessToken: 'token',
userId: 'user',
}),
}),
false,
);
});
test('clears stored auth, stops active discovery, and refreshes tray', () => {
const calls: string[] = [];
clearJellyfinAuthSessionAndRefreshTray({
clearStoredSession: () => calls.push('clear'),
getRemoteSession: () => ({ advertiseNow: async () => true }),
stopRemoteSession: () => calls.push('stop'),
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
});
assert.deepEqual(calls, ['clear', 'stop', 'refresh']);
});
test('clear auth still refreshes tray when clear or stop throws', () => {
const calls: string[] = [];
clearJellyfinAuthSessionAndRefreshTray({
clearStoredSession: () => {
throw new Error('clear failed');
},
getRemoteSession: () => ({ advertiseNow: async () => true }),
stopRemoteSession: () => {
throw new Error('stop failed');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
});
assert.deepEqual(calls, [
'error:Failed to clear Jellyfin auth session.',
'error:Failed to stop Jellyfin discovery while clearing auth session.',
'refresh',
]);
});
test('starts explicit discovery and advertises cast target from tray', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return true;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'start',
'advertise',
'info:Jellyfin discovery started; cast target is visible in server sessions.',
'osd:Jellyfin discovery started',
'refresh',
]);
});
test('starts explicit discovery and reports pending visibility from tray', async () => {
const calls: string[] = [];
let session: { advertiseNow: () => Promise<boolean> } | null = null;
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => session,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async (options) => {
assert.deepEqual(options, { explicit: true });
calls.push('start');
session = {
advertiseNow: async () => {
calls.push('advertise');
return false;
},
};
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'start',
'advertise',
'warn:Jellyfin discovery started, but cast target is not visible yet.',
'osd:Jellyfin discovery started; waiting for visibility',
'refresh',
]);
});
test('stops active discovery from tray', async () => {
const calls: string[] = [];
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => ({ advertiseNow: async () => true }),
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
calls.push('start');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'stop',
'info:Jellyfin discovery stopped.',
'osd:Jellyfin discovery stopped',
'refresh',
]);
});
test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
const calls: string[] = [];
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => null,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
calls.push('start');
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message) => calls.push(`error:${message}`),
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'start',
'warn:Jellyfin discovery could not start. Configure Jellyfin first.',
'osd:Jellyfin discovery unavailable',
'refresh',
]);
});
test('reports discovery toggle failures and still refreshes tray', async () => {
const calls: string[] = [];
const error = new Error('boom');
await toggleJellyfinDiscoveryFromTray({
getRemoteSession: () => null,
stopRemoteSession: () => calls.push('stop'),
startRemoteSession: async () => {
throw error;
},
refreshTrayMenu: () => calls.push('refresh'),
logger: {
info: (message) => calls.push(`info:${message}`),
warn: (message) => calls.push(`warn:${message}`),
error: (message, actualError) => {
calls.push(`error:${message}`);
assert.equal(actualError, error);
},
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
});
assert.deepEqual(calls, [
'error:Failed to toggle Jellyfin discovery.',
'osd:Jellyfin discovery failed',
'refresh',
]);
});
+101
View File
@@ -0,0 +1,101 @@
type JellyfinTrayConfig = {
enabled?: boolean;
serverUrl?: string | null;
accessToken?: string | null;
userId?: string | null;
};
type JellyfinTrayRemoteSession = {
advertiseNow: () => Promise<boolean>;
};
type JellyfinTrayLogger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: unknown) => void;
};
type JellyfinTrayDiscoveryDeps<TSession extends JellyfinTrayRemoteSession> = {
getResolvedJellyfinConfig: () => JellyfinTrayConfig;
getRemoteSession: () => TSession | null;
clearStoredSession: () => void;
stopRemoteSession: () => void;
startRemoteSession: (options: { explicit: true }) => Promise<void>;
refreshTrayMenu: () => void;
logger: JellyfinTrayLogger;
showMpvOsd: (message: string) => void;
};
export function isJellyfinConfiguredForTray(
deps: Pick<JellyfinTrayDiscoveryDeps<JellyfinTrayRemoteSession>, 'getResolvedJellyfinConfig'>,
): boolean {
const jellyfin = deps.getResolvedJellyfinConfig();
return Boolean(jellyfin.enabled !== false && jellyfin.serverUrl);
}
export function clearJellyfinAuthSessionAndRefreshTray<TSession extends JellyfinTrayRemoteSession>(
deps: Pick<
JellyfinTrayDiscoveryDeps<TSession>,
'clearStoredSession' | 'getRemoteSession' | 'stopRemoteSession' | 'refreshTrayMenu' | 'logger'
>,
): void {
try {
deps.clearStoredSession();
} catch (error) {
deps.logger.error('Failed to clear Jellyfin auth session.', error);
}
try {
if (deps.getRemoteSession()) {
deps.stopRemoteSession();
}
} catch (error) {
deps.logger.error('Failed to stop Jellyfin discovery while clearing auth session.', error);
} finally {
deps.refreshTrayMenu();
}
}
export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinTrayRemoteSession>(
deps: Pick<
JellyfinTrayDiscoveryDeps<TSession>,
| 'getRemoteSession'
| 'stopRemoteSession'
| 'startRemoteSession'
| 'refreshTrayMenu'
| 'logger'
| 'showMpvOsd'
>,
): Promise<void> {
try {
const activeSession = deps.getRemoteSession();
if (activeSession) {
deps.stopRemoteSession();
deps.logger.info('Jellyfin discovery stopped.');
deps.showMpvOsd('Jellyfin discovery stopped');
return;
}
await deps.startRemoteSession({ explicit: true });
const remoteSession = deps.getRemoteSession();
if (!remoteSession) {
deps.logger.warn('Jellyfin discovery could not start. Configure Jellyfin first.');
deps.showMpvOsd('Jellyfin discovery unavailable');
return;
}
const visible = await remoteSession.advertiseNow();
if (visible) {
deps.logger.info('Jellyfin discovery started; cast target is visible in server sessions.');
deps.showMpvOsd('Jellyfin discovery started');
} else {
deps.logger.warn('Jellyfin discovery started, but cast target is not visible yet.');
deps.showMpvOsd('Jellyfin discovery started; waiting for visibility');
}
} catch (error) {
deps.logger.error('Failed to toggle Jellyfin discovery.', error);
deps.showMpvOsd('Jellyfin discovery failed');
} finally {
deps.refreshTrayMenu();
}
}
@@ -42,11 +42,13 @@ test('build tray template handler wires actions and init guards', () => {
const buildTemplate = createBuildTrayMenuTemplateHandler({ const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => { buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openSessionHelp(); handlers.openSessionHelp();
handlers.openTexthookerInBrowser();
handlers.openFirstRunSetup(); handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup(); handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings(); handlers.openYomitanSettings();
handlers.openRuntimeOptions(); handlers.openRuntimeOptions();
handlers.openJellyfinSetup(); handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
handlers.openAnilistSetup(); handlers.openAnilistSetup();
handlers.quitApp(); handlers.quitApp();
return [{ label: 'ok' }] as never; return [{ label: 'ok' }] as never;
@@ -57,12 +59,18 @@ test('build tray template handler wires actions and init guards', () => {
}, },
isOverlayRuntimeInitialized: () => initialized, isOverlayRuntimeInitialized: () => initialized,
openSessionHelpModal: () => calls.push('help'), openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showFirstRunSetup: () => true, showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'), openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'), openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: async () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'), openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
}); });
@@ -72,11 +80,13 @@ test('build tray template handler wires actions and init guards', () => {
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'init', 'init',
'help', 'help',
'texthooker',
'setup', 'setup',
'setup', 'setup',
'yomitan', 'yomitan',
'runtime-options', 'runtime-options',
'jellyfin', 'jellyfin',
'jellyfin-discovery',
'anilist', 'anilist',
'quit', 'quit',
]); ]);
+16
View File
@@ -29,6 +29,7 @@ export function createResolveTrayIconPathHandler(deps: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: { export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: { buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
showFirstRunSetup: boolean; showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
@@ -36,18 +37,25 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
quitApp: () => void; quitApp: () => void;
}) => TMenuItem[]; }) => TMenuItem[];
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void; openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showFirstRunSetup: () => boolean; showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void; openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean; showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
quitApp: () => void; quitApp: () => void;
}) { }) {
@@ -59,6 +67,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
} }
deps.openSessionHelpModal(); deps.openSessionHelpModal();
}, },
openTexthookerInBrowser: () => {
deps.openTexthookerInBrowser();
},
openFirstRunSetup: () => { openFirstRunSetup: () => {
deps.openFirstRunSetupWindow(); deps.openFirstRunSetupWindow();
}, },
@@ -79,6 +90,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openJellyfinSetup: () => { openJellyfinSetup: () => {
deps.openJellyfinSetupWindow(); deps.openJellyfinSetupWindow();
}, },
showJellyfinDiscovery: deps.isJellyfinConfigured(),
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
toggleJellyfinDiscovery: () => {
void deps.toggleJellyfinDiscovery();
},
openAnilistSetup: () => { openAnilistSetup: () => {
deps.openAnilistSetupWindow(); deps.openAnilistSetupWindow();
}, },
+10
View File
@@ -25,18 +25,25 @@ test('tray main deps builders return mapped handlers', () => {
initializeOverlayRuntime: () => calls.push('init'), initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
openSessionHelpModal: () => calls.push('help'), openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showFirstRunSetup: () => true, showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'), openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'), openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'), openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
})(); })();
const template = menuDeps.buildTrayMenuTemplateRuntime({ const template = menuDeps.buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('open-help'), openSessionHelp: () => calls.push('open-help'),
openTexthookerInBrowser: () => calls.push('open-texthooker'),
openFirstRunSetup: () => calls.push('open-setup'), openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true, showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'), openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
@@ -44,6 +51,9 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'), openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'), openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
openAnilistSetup: () => calls.push('open-anilist'), openAnilistSetup: () => calls.push('open-anilist'),
quitApp: () => calls.push('quit-app'), quitApp: () => calls.push('quit-app'),
}); });
+12
View File
@@ -28,6 +28,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: { export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: { buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
showFirstRunSetup: boolean; showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
@@ -35,18 +36,25 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
quitApp: () => void; quitApp: () => void;
}) => TMenuItem[]; }) => TMenuItem[];
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void; openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showFirstRunSetup: () => boolean; showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void; openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean; showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
quitApp: () => void; quitApp: () => void;
}) { }) {
@@ -55,12 +63,16 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: deps.initializeOverlayRuntime, initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal, openSessionHelpModal: deps.openSessionHelpModal,
openTexthookerInBrowser: deps.openTexthookerInBrowser,
showFirstRunSetup: deps.showFirstRunSetup, showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow, openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow, openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
openAnilistSetupWindow: deps.openAnilistSetupWindow, openAnilistSetupWindow: deps.openAnilistSetupWindow,
quitApp: deps.quitApp, quitApp: deps.quitApp,
}); });
@@ -25,12 +25,16 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
}, },
isOverlayRuntimeInitialized: () => overlayInitialized, isOverlayRuntimeInitialized: () => overlayInitialized,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
openTexthookerInBrowser: () => {},
showFirstRunSetup: () => true, showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {}, openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {}, openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {}, openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {},
openAnilistSetupWindow: () => {}, openAnilistSetupWindow: () => {},
quitApp: () => {}, quitApp: () => {},
}, },
+46 -5
View File
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = []; const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({ const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'), openSessionHelp: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
openFirstRunSetup: () => calls.push('setup'), openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true, showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'), openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
@@ -37,22 +38,35 @@ test('tray menu template contains expected entries and handlers', () => {
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'), openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'), openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
openAnilistSetup: () => calls.push('anilist'), openAnilistSetup: () => calls.push('anilist'),
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
}); });
assert.equal(template.length, 9); assert.equal(template.length, 11);
assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false); assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
);
assert.equal(template[0]!.label, 'Open Help'); assert.equal(template[0]!.label, 'Open Help');
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false);
discovery?.click?.();
template[0]!.click?.(); template[0]!.click?.();
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); assert.equal(template[1]!.label, 'Open Texthooker');
template[8]!.click?.(); template[1]!.click?.();
assert.deepEqual(calls, ['help', 'separator', 'quit']); template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[10]!.click?.();
assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
}); });
test('tray menu template omits first-run setup entry when setup is complete', () => { test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({ const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined, openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
openFirstRunSetup: () => undefined, openFirstRunSetup: () => undefined,
showFirstRunSetup: false, showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined, openWindowsMpvLauncherSetup: () => undefined,
@@ -60,6 +74,9 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openYomitanSettings: () => undefined, openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined, openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined, openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined, openAnilistSetup: () => undefined,
quitApp: () => undefined, quitApp: () => undefined,
}) })
@@ -68,4 +85,28 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
assert.equal(labels.includes('Complete Setup'), false); assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false); assert.equal(labels.includes('Manage Windows mpv launcher'), false);
assert.equal(labels.includes('Jellyfin Discovery'), false);
});
test('tray menu template renders active jellyfin discovery checkbox', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
quitApp: () => undefined,
});
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, true);
}); });
+22 -1
View File
@@ -31,6 +31,7 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = { export type TrayMenuActionHandlers = {
openSessionHelp: () => void; openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
openFirstRunSetup: () => void; openFirstRunSetup: () => void;
showFirstRunSetup: boolean; showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
@@ -38,13 +39,18 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void; openAnilistSetup: () => void;
quitApp: () => void; quitApp: () => void;
}; };
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string; label?: string;
type?: 'separator'; type?: 'separator' | 'checkbox';
checked?: boolean;
enabled?: boolean;
click?: () => void; click?: () => void;
}> { }> {
return [ return [
@@ -52,6 +58,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Help', label: 'Open Help',
click: handlers.openSessionHelp, click: handlers.openSessionHelp,
}, },
{
label: 'Open Texthooker',
click: handlers.openTexthookerInBrowser,
},
...(handlers.showFirstRunSetup ...(handlers.showFirstRunSetup
? [ ? [
{ {
@@ -80,6 +90,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Configure Jellyfin', label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup, click: handlers.openJellyfinSetup,
}, },
...(handlers.showJellyfinDiscovery
? [
{
label: 'Jellyfin Discovery',
type: 'checkbox' as const,
checked: handlers.jellyfinDiscoveryActive,
enabled: true,
click: handlers.toggleJellyfinDiscovery,
},
]
: []),
{ {
label: 'Configure AniList', label: 'Configure AniList',
click: handlers.openAnilistSetup, click: handlers.openAnilistSetup,
+6 -3
View File
@@ -36,9 +36,12 @@ test('release workflow verifies a committed changelog section before publish', (
assert.match(releaseWorkflow, /bun run changelog:check/); assert.match(releaseWorkflow, /bun run changelog:check/);
}); });
test('release workflow builds changelog artifacts when fragments are pending', () => { test('release workflow guards against pending changelog fragments instead of auto-building them', () => {
assert.match(releaseWorkflow, /Build changelog artifacts for release/); assert.match(releaseWorkflow, /Guard against pending changelog fragments/);
assert.match(releaseWorkflow, /changelog:build --version/); assert.match(releaseWorkflow, /::error::Pending changelog fragments detected/);
assert.match(releaseWorkflow, /Run 'bun run changelog:build --version/);
assert.doesNotMatch(releaseWorkflow, /Build changelog artifacts for release/);
assert.doesNotMatch(releaseWorkflow, /bun run changelog:build --version "\$\{\{ steps\.version/);
}); });
test('release workflow verifies generated config examples before packaging artifacts', () => { test('release workflow verifies generated config examples before packaging artifacts', () => {
+1 -4
View File
@@ -2,10 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions'; import { SPECIAL_COMMANDS } from '../../config/definitions';
import { import { describeSessionHelpCommand, formatSessionHelpKeybinding } from './session-help.js';
describeSessionHelpCommand,
formatSessionHelpKeybinding,
} from './session-help.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => { test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle'); assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
+1
View File
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
jellyfin: { jellyfin: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
recentServers: string[];
username: string; username: string;
deviceId: string; deviceId: string;
clientName: string; clientName: string;
+1
View File
@@ -85,6 +85,7 @@ export interface YomitanConfig {
export interface JellyfinConfig { export interface JellyfinConfig {
enabled?: boolean; enabled?: boolean;
serverUrl?: string; serverUrl?: string;
recentServers?: string[];
username?: string; username?: string;
deviceId?: string; deviceId?: string;
clientName?: string; clientName?: string;