mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
Compare commits
4 Commits
13e2b5f8c8
...
db30c61327
| Author | SHA1 | Date | |
|---|---|---|---|
| db30c61327 | |||
|
27f5b2bb58
|
|||
|
baabdb6d30
|
|||
|
3a67e23bc3
|
@@ -351,12 +351,11 @@ jobs:
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build changelog artifacts for release
|
||||
- name: Guard against pending changelog fragments
|
||||
run: |
|
||||
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 }}"
|
||||
else
|
||||
echo "No pending changelog fragments found."
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify changelog is ready for tagged release
|
||||
|
||||
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<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.
|
||||
|
||||
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]
|
||||
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
|
||||
|
||||
|
||||
+44
@@ -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 -->
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -31,6 +31,12 @@ Rules:
|
||||
- `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
|
||||
|
||||
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 tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"jellyfin": {
|
||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||
"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.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
|
||||
@@ -1157,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
"jellyfin": {
|
||||
"enabled": true,
|
||||
"serverUrl": "http://127.0.0.1:8096",
|
||||
"recentServers": ["http://127.0.0.1:8096"],
|
||||
"username": "",
|
||||
"remoteControlEnabled": 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`) |
|
||||
| `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` |
|
||||
| `deviceId` | string | Client device id 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`.
|
||||
|
||||
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 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.
|
||||
|
||||
@@ -6,7 +6,8 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
||||
- listing libraries and media items
|
||||
- launching item playback in the connected mpv instance
|
||||
- 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
|
||||
|
||||
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
||||
"jellyfin": {
|
||||
"enabled": true,
|
||||
"serverUrl": "http://127.0.0.1:8096",
|
||||
"recentServers": ["http://127.0.0.1:8096"],
|
||||
"username": "your-user",
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
@@ -48,6 +50,8 @@ subminer jellyfin -l \
|
||||
--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:
|
||||
|
||||
```bash
|
||||
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
|
||||
- `jellyfin.enabled=true`
|
||||
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
|
||||
- `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)
|
||||
|
||||
### Behavior
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -147,7 +154,8 @@ remote playback target in Jellyfin's cast-to-device menu.
|
||||
- Device not visible in Jellyfin cast menu:
|
||||
- ensure SubMiner is running
|
||||
- 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:
|
||||
- verify mpv IPC can connect (`--start` flow)
|
||||
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
|
||||
|
||||
@@ -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 --select <id> <path>` | Pin an AniList media ID for that target series |
|
||||
| `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 |
|
||||
|
||||
Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"jellyfin": {
|
||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||
"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.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
|
||||
@@ -103,12 +103,14 @@ subminer dictionary /path/to/file-or-directory # Generate character dictionary
|
||||
subminer dictionary --candidates /path/to/file.mkv
|
||||
subminer dictionary --select 21355 /path/to/file.mkv
|
||||
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)
|
||||
|
||||
# Direct packaged app control
|
||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||
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 --stop # Stop overlay
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
|
||||
@@ -164,6 +164,8 @@ Start it with either:
|
||||
|
||||
```bash
|
||||
subminer texthooker
|
||||
# or open the page immediately
|
||||
subminer texthooker -o
|
||||
```
|
||||
|
||||
or by leaving `texthooker.launchAtStartup` enabled.
|
||||
@@ -273,7 +275,7 @@ Examples:
|
||||
Examples:
|
||||
|
||||
- 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`
|
||||
|
||||
#### Build an overlay-adjacent client
|
||||
|
||||
+18
-6
@@ -2,16 +2,28 @@
|
||||
|
||||
# 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
|
||||
|
||||
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.
|
||||
3. Run `bun run changelog:lint`.
|
||||
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>`
|
||||
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain.
|
||||
6. Review `CHANGELOG.md` and `release/release-notes.md`.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
|
||||
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:
|
||||
`bun run changelog:check --version <version>`
|
||||
`bun run verify:config-example`
|
||||
@@ -31,7 +43,7 @@
|
||||
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
|
||||
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`.
|
||||
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 verify:config-example`
|
||||
`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.
|
||||
- `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:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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 version’s 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,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
passwordStore: '',
|
||||
|
||||
@@ -144,6 +144,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
doctorRefreshKnownWords: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: 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.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);
|
||||
});
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createDefaultArgs(
|
||||
useTexthooker: true,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
passwordStore: '',
|
||||
@@ -247,6 +248,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
|
||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
|
||||
|
||||
if (invocations.jellyfinInvocation) {
|
||||
if (invocations.jellyfinInvocation.logLevel) {
|
||||
|
||||
@@ -35,3 +35,10 @@ test('parseCliPrograms routes app alias arguments through passthrough mode', ()
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CliInvocations {
|
||||
doctorRefreshKnownWords: boolean;
|
||||
texthookerTriggered: boolean;
|
||||
texthookerLogLevel: string | null;
|
||||
texthookerOpenBrowser: boolean;
|
||||
}
|
||||
|
||||
function applyRootOptions(program: Command): void {
|
||||
@@ -152,6 +153,7 @@ export function parseCliPrograms(
|
||||
let doctorLogLevel: string | null = null;
|
||||
let doctorRefreshKnownWords = false;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let texthookerOpenBrowser = false;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
|
||||
@@ -313,10 +315,12 @@ export function parseCliPrograms(
|
||||
commandProgram
|
||||
.command('texthooker')
|
||||
.description('Launch texthooker-only mode')
|
||||
.option('-o, --open-browser', 'Open texthooker in the default browser')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
texthookerTriggered = true;
|
||||
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
texthookerOpenBrowser = options.openBrowser === true;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
@@ -369,6 +373,7 @@ export function parseCliPrograms(
|
||||
doctorRefreshKnownWords,
|
||||
texthookerTriggered,
|
||||
texthookerLogLevel,
|
||||
texthookerOpenBrowser,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,6 +270,29 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
|
||||
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 () => {
|
||||
let uncaughtError: Error | null = null;
|
||||
const onUncaughtException = (error: Error) => {
|
||||
@@ -399,6 +422,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
logLevel: 'error',
|
||||
passwordStore: '',
|
||||
|
||||
+30
-1
@@ -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'];
|
||||
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
|
||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||
|
||||
log('info', args.logLevel, 'Launching texthooker mode...');
|
||||
@@ -840,6 +862,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
|
||||
if (result.error) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface Args {
|
||||
useTexthooker: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
texthookerOnly: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
useRofi: boolean;
|
||||
logLevel: LogLevel;
|
||||
passwordStore: string;
|
||||
|
||||
+362
-17
@@ -13,6 +13,75 @@ function createWorkspace(name: string): string {
|
||||
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 () => {
|
||||
const { resolveChangelogOutputPaths } = await loadModule();
|
||||
const workspace = createWorkspace('with-docs-repo');
|
||||
@@ -62,10 +131,12 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const result = writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.4.1',
|
||||
date: '2026-03-07',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
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', 'README.md')), true);
|
||||
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.match(
|
||||
changelog,
|
||||
/^# 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,
|
||||
assert.equal(
|
||||
stub.calls.length,
|
||||
2,
|
||||
'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(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./);
|
||||
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./);
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
@@ -159,10 +240,12 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const result = writeStableReleaseArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.4.1',
|
||||
date: '2026-03-07',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
|
||||
@@ -260,10 +343,12 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.5.0',
|
||||
date: '2026-04-06',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
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.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
|
||||
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 {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -384,9 +475,11 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const outputPath = writePrereleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.11.3-beta.1',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
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', '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');
|
||||
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
||||
assert.match(
|
||||
prereleaseNotes,
|
||||
/## Highlights\n### Added\n- Overlay: Added prerelease coverage\./,
|
||||
);
|
||||
assert.match(prereleaseNotes, /### Fixed\n- Launcher: Fixed prerelease packaging checks\./);
|
||||
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
@@ -434,16 +527,15 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const outputPath = writePrereleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.11.3-rc.1',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||
assert.match(
|
||||
prereleaseNotes,
|
||||
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
|
||||
);
|
||||
assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./);
|
||||
} finally {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
type RunClaude = (input: string, args: string[]) => string;
|
||||
|
||||
type ChangelogFsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
@@ -10,8 +12,11 @@ type ChangelogFsDeps = {
|
||||
rmSync?: (candidate: string) => void;
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
log?: (message: string) => void;
|
||||
runClaude?: RunClaude;
|
||||
};
|
||||
|
||||
type PolishMode = 'changelog' | 'release-notes';
|
||||
|
||||
type ChangelogOptions = {
|
||||
cwd?: 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 CHANGELOG_HEADER = '# Changelog';
|
||||
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';
|
||||
|
||||
function normalizeVersion(version: string): string {
|
||||
@@ -217,54 +215,179 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
|
||||
});
|
||||
}
|
||||
|
||||
function formatAreaLabel(area: string): string {
|
||||
return area
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
// We deliberately don't pass --bare here. --bare skips OAuth/keychain reads and
|
||||
// requires ANTHROPIC_API_KEY, which most Claude Code users don't have set up.
|
||||
// The polish prompt is self-contained and doesn't need tools, so loading the
|
||||
// user's hooks/MCP/CLAUDE.md is harmless overhead.
|
||||
const CLAUDE_CLI_ARGS = [
|
||||
'-p',
|
||||
'--model',
|
||||
'sonnet',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
'--output-format',
|
||||
'text',
|
||||
];
|
||||
|
||||
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string {
|
||||
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
|
||||
}
|
||||
const SECTION_HEADER_PATTERN = /^### (Breaking Changes|Added|Changed|Fixed|Docs|Internal)$/m;
|
||||
|
||||
function renderGroupedChanges(fragments: ChangeFragment[]): string {
|
||||
const sections: 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 breakingFragments = fragments.filter((fragment) => fragment.breaking);
|
||||
if (breakingFragments.length > 0) {
|
||||
const bullets = breakingFragments
|
||||
.flatMap((fragment) =>
|
||||
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
||||
)
|
||||
.join('\n');
|
||||
sections.push(`### Breaking Changes\n${bullets}`);
|
||||
}
|
||||
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.
|
||||
|
||||
for (const type of CHANGE_TYPES) {
|
||||
const typeFragments = fragments.filter((fragment) => fragment.type === type);
|
||||
if (typeFragments.length === 0) {
|
||||
continue;
|
||||
## Output Rules
|
||||
|
||||
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
const bullets = typeFragments
|
||||
.flatMap((fragment) =>
|
||||
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
||||
)
|
||||
.join('\n');
|
||||
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
|
||||
throw new Error(`claude CLI invocation failed: ${err.message}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
@@ -392,7 +515,11 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
log(`Removed ${fragment.path}`);
|
||||
}
|
||||
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps);
|
||||
const releaseNotesPath = writeReleaseNotesFile(
|
||||
cwd,
|
||||
stripDetailsBlocks(existingReleaseSection),
|
||||
options?.deps,
|
||||
);
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
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);
|
||||
|
||||
for (const outputPath of outputPaths) {
|
||||
@@ -411,11 +538,13 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
log(`Updated ${outputPath}`);
|
||||
}
|
||||
|
||||
const releaseNotesPath = writeReleaseNotesFile(
|
||||
cwd,
|
||||
extractReleaseSectionBody(nextChangelog, version) ?? releaseSection,
|
||||
options?.deps,
|
||||
);
|
||||
const releaseNotesBody = polishFragmentsWithClaude(fragments, {
|
||||
mode: 'release-notes',
|
||||
version,
|
||||
date,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
for (const fragment of fragments) {
|
||||
@@ -645,7 +774,7 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
||||
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 {
|
||||
@@ -664,7 +793,11 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
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, {
|
||||
disclaimer:
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
|
||||
@@ -124,9 +124,12 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
|
||||
|
||||
test('standalone texthooker classification excludes integrated start flow', () => {
|
||||
const standalone = parseArgs(['--texthooker']);
|
||||
const standaloneOpenBrowser = parseArgs(['--texthooker', '--open-browser']);
|
||||
const integrated = parseArgs(['--start', '--texthooker']);
|
||||
|
||||
assert.equal(isStandaloneTexthookerCommand(standalone), true);
|
||||
assert.equal(standaloneOpenBrowser.texthookerOpenBrowser, true);
|
||||
assert.equal(isStandaloneTexthookerCommand(standaloneOpenBrowser), true);
|
||||
assert.equal(isStandaloneTexthookerCommand(integrated), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface CliArgs {
|
||||
jellyfinRemoteAnnounce: boolean;
|
||||
jellyfinPreviewAuth: boolean;
|
||||
texthooker: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
help: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
generateConfig: boolean;
|
||||
@@ -164,6 +165,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
autoStartOverlay: 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-preview-auth') args.jellyfinPreviewAuth = 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 === '--generate-config') args.generateConfig = true;
|
||||
else if (arg === '--backup-overwrite') args.backupOverwrite = true;
|
||||
|
||||
@@ -19,6 +19,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /default: 7777/);
|
||||
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, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
|
||||
@@ -16,6 +16,7 @@ ${B}Session${R}
|
||||
--stop Stop the running instance
|
||||
--stats Open the stats dashboard in your browser
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
--open-browser Open texthooker in your default browser
|
||||
|
||||
${B}Overlay${R}
|
||||
--toggle-visible-overlay Toggle subtitle overlay
|
||||
|
||||
@@ -117,6 +117,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
jellyfin: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
recentServers: [],
|
||||
username: '',
|
||||
deviceId: 'subminer',
|
||||
clientName: 'SubMiner',
|
||||
|
||||
@@ -265,6 +265,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.serverUrl,
|
||||
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',
|
||||
kind: 'string',
|
||||
|
||||
@@ -318,6 +318,26 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
'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)) {
|
||||
|
||||
@@ -17,6 +17,34 @@ test('jellyfin directPlayContainers are normalized', () => {
|
||||
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', () => {
|
||||
const { context } = createResolveContext({
|
||||
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
|
||||
|
||||
@@ -66,6 +66,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
|
||||
@@ -68,6 +68,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
autoStartOverlay: 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'));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
|
||||
|
||||
@@ -704,7 +704,7 @@ export function handleCliCommand(
|
||||
} else if (args.texthooker) {
|
||||
const texthookerPort = deps.getTexthookerPort();
|
||||
deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
|
||||
if (deps.shouldOpenTexthookerBrowser()) {
|
||||
if (args.texthookerOpenBrowser || deps.shouldOpenTexthookerBrowser()) {
|
||||
deps.openTexthookerInBrowser(`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,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
|
||||
+64
-3
@@ -181,6 +181,8 @@ import {
|
||||
buildJellyfinSetupFormHtml,
|
||||
parseJellyfinSetupSubmissionUrl,
|
||||
getConfiguredJellyfinSession,
|
||||
mergeJellyfinRecentServers,
|
||||
persistJellyfinAuthSession,
|
||||
type ActiveJellyfinRemotePlaybackState,
|
||||
} from './main/runtime/domains/jellyfin';
|
||||
import {
|
||||
@@ -389,6 +391,11 @@ import {
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
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 { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
@@ -2369,6 +2376,7 @@ const {
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
getJellyfinClientInfo,
|
||||
} = composeJellyfinRuntimeHandlers({
|
||||
getResolvedJellyfinConfigMainDeps: {
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
@@ -2488,11 +2496,13 @@ const {
|
||||
handleJellyfinAuthCommandsMainDeps: {
|
||||
patchRawConfig: (patch) => {
|
||||
configService.patchRawConfig(patch);
|
||||
refreshTrayMenuIfPresent();
|
||||
},
|
||||
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
||||
authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo),
|
||||
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
||||
clearStoredSession: () => jellyfinTokenStore.clearSession(),
|
||||
clearStoredSession: () =>
|
||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
handleJellyfinListCommandsMainDeps: {
|
||||
@@ -2547,21 +2557,43 @@ const {
|
||||
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
}),
|
||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
||||
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
|
||||
buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||
authenticateWithPasswordRuntime(server, username, password, clientInfo),
|
||||
saveStoredSession: (session) => jellyfinTokenStore.saveSession(session),
|
||||
clearStoredSession: () =>
|
||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const clientInfo = getJellyfinClientInfo();
|
||||
const recentServers = mergeJellyfinRecentServers(
|
||||
session.serverUrl,
|
||||
getResolvedConfig().jellyfin.recentServers || [],
|
||||
);
|
||||
configService.patchRawConfig({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: session.serverUrl,
|
||||
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),
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
@@ -2572,6 +2604,8 @@ const {
|
||||
appState.jellyfinSetupWindow = window as BrowserWindow;
|
||||
},
|
||||
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),
|
||||
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 } =
|
||||
createTrayRuntimeHandlers({
|
||||
resolveTrayIconPathDeps: {
|
||||
@@ -5150,12 +5204,19 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
isJellyfinConfigured: () =>
|
||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
||||
toggleJellyfinDiscovery: () =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
},
|
||||
|
||||
@@ -159,8 +159,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
isDestroyed: () => false,
|
||||
close: () => {},
|
||||
}) as never,
|
||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
||||
`<html>${defaultServer}${defaultUser}</html>`,
|
||||
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}${state.username}</html>`,
|
||||
parseSubmissionUrl: () => null,
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'https://example.test',
|
||||
@@ -169,6 +168,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
userId: 'id',
|
||||
}),
|
||||
saveStoredSession: () => {},
|
||||
clearStoredSession: () => {},
|
||||
patchJellyfinConfig: () => {},
|
||||
logInfo: () => {},
|
||||
logError: () => {},
|
||||
@@ -176,6 +176,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {},
|
||||
encodeURIComponent,
|
||||
defaultServerUrl: 'https://example.test',
|
||||
hasStoredSession: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
|
||||
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
||||
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
|
||||
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
|
||||
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
|
||||
})(),
|
||||
);
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
@@ -70,6 +70,7 @@ test('jellyfin auth handler processes login', async () => {
|
||||
jellyfinConfig: {
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
recentServers: ['http://localhost'],
|
||||
},
|
||||
serverUrl: 'http://localhost',
|
||||
clientInfo: {
|
||||
@@ -91,11 +92,60 @@ test('jellyfin auth handler processes login', async () => {
|
||||
deviceId: 'd1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
recentServers: ['http://localhost'],
|
||||
},
|
||||
});
|
||||
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 () => {
|
||||
const handleAuth = createHandleJellyfinAuthCommands({
|
||||
patchRawConfig: () => {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
type JellyfinConfig = {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
recentServers?: string[];
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
@@ -18,6 +19,67 @@ type JellyfinSession = {
|
||||
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: {
|
||||
patchRawConfig: (patch: {
|
||||
jellyfin: Partial<{
|
||||
@@ -66,19 +128,12 @@ export function createHandleJellyfinAuthCommands(deps: {
|
||||
password,
|
||||
params.clientInfo,
|
||||
);
|
||||
deps.saveStoredSession({
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
});
|
||||
deps.patchRawConfig({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: session.serverUrl,
|
||||
username: session.username,
|
||||
deviceId: params.clientInfo.deviceId,
|
||||
clientName: params.clientInfo.clientName,
|
||||
clientVersion: params.clientInfo.clientVersion,
|
||||
},
|
||||
persistJellyfinAuthSession({
|
||||
session,
|
||||
clientInfo: params.clientInfo,
|
||||
existingRecentServers: params.jellyfinConfig.recentServers || [],
|
||||
saveStoredSession: (storedSession) => deps.saveStoredSession(storedSession),
|
||||
patchRawConfig: (patch) => deps.patchRawConfig(patch),
|
||||
});
|
||||
deps.logInfo(`Jellyfin login succeeded for ${session.username}.`);
|
||||
return true;
|
||||
|
||||
@@ -94,17 +94,17 @@ test('jellyfin remote announce main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const session = { advertiseNow: async () => true };
|
||||
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('start');
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
calls.push(`start:${options?.explicit ? 'explicit' : 'default'}`);
|
||||
},
|
||||
getRemoteSession: () => session,
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
await deps.startJellyfinRemoteSession();
|
||||
await deps.startJellyfinRemoteSession({ explicit: true });
|
||||
assert.equal(deps.getRemoteSession(), session);
|
||||
deps.logInfo('visible');
|
||||
deps.logWarn('not-visible');
|
||||
assert.deepEqual(calls, ['start', 'info:visible', 'warn:not-visible']);
|
||||
assert.deepEqual(calls, ['start:explicit', 'info:visible', 'warn:not-visible']);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler(
|
||||
deps: HandleJellyfinRemoteAnnounceCommandMainDeps,
|
||||
) {
|
||||
return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({
|
||||
startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(),
|
||||
startJellyfinRemoteSession: (options) => deps.startJellyfinRemoteSession(options),
|
||||
getRemoteSession: () => deps.getRemoteSession(),
|
||||
logInfo: (message: string) => deps.logInfo(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 () => {
|
||||
const warnings: string[] = [];
|
||||
let started = false;
|
||||
let startOptions: { explicit?: boolean } | undefined;
|
||||
const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({
|
||||
startJellyfinRemoteSession: async () => {
|
||||
started = true;
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
startOptions = options;
|
||||
},
|
||||
getRemoteSession: () => null,
|
||||
logInfo: () => {},
|
||||
@@ -38,7 +38,7 @@ test('remote announce handler warns when session is unavailable', async () => {
|
||||
} as never);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(started, true);
|
||||
assert.deepEqual(startOptions, { explicit: true });
|
||||
assert.deepEqual(warnings, ['Jellyfin remote session is not available.']);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ type JellyfinRemoteSession = {
|
||||
};
|
||||
|
||||
export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
|
||||
getRemoteSession: () => JellyfinRemoteSession | null;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
@@ -15,7 +15,7 @@ export function createHandleJellyfinRemoteAnnounceCommand(deps: {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deps.startJellyfinRemoteSession();
|
||||
await deps.startJellyfinRemoteSession({ explicit: true });
|
||||
const remoteSession = deps.getRemoteSession();
|
||||
if (!remoteSession) {
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
let storedSession: {
|
||||
start: () => void;
|
||||
|
||||
@@ -53,11 +53,11 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
return async (options?: { explicit?: boolean }): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
if (jellyfinConfig.enabled === 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;
|
||||
|
||||
const existing = deps.getCurrentSession();
|
||||
|
||||
@@ -4,12 +4,28 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se
|
||||
|
||||
test('open jellyfin setup window main deps builder maps callbacks', async () => {
|
||||
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({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => ({}) as never,
|
||||
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
|
||||
buildSetupFormHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }),
|
||||
buildSetupFormHtml: (state) => {
|
||||
capturedBuildState = state;
|
||||
return '<html></html>';
|
||||
},
|
||||
parseSubmissionUrl: (rawUrl) => {
|
||||
capturedParseUrl = rawUrl;
|
||||
return { action: 'login', server: 's', username: 'u', password: 'p' };
|
||||
},
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'http://127.0.0.1:8096',
|
||||
username: 'alice',
|
||||
@@ -22,13 +38,17 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
deviceId: 'dev',
|
||||
}),
|
||||
saveStoredSession: () => calls.push('save'),
|
||||
clearStoredSession: () => calls.push('clear-session'),
|
||||
patchJellyfinConfig: () => calls.push('patch'),
|
||||
persistAuthenticatedSession: () => calls.push('persist'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear'),
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||
hasStoredSession: () => true,
|
||||
})();
|
||||
|
||||
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',
|
||||
username: 'alice',
|
||||
});
|
||||
assert.equal(deps.buildSetupFormHtml('a', 'b'), '<html></html>');
|
||||
assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), {
|
||||
assert.equal(deps.buildSetupFormHtml(expectedState), '<html></html>');
|
||||
assert.deepEqual(capturedBuildState, expectedState);
|
||||
const setupUrl = 'subminer://jellyfin-setup?x=1';
|
||||
assert.deepEqual(deps.parseSubmissionUrl(setupUrl), {
|
||||
action: 'login',
|
||||
server: 's',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
});
|
||||
assert.equal(capturedParseUrl, setupUrl);
|
||||
assert.deepEqual(
|
||||
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.clearStoredSession();
|
||||
deps.patchJellyfinConfig({
|
||||
serverUrl: 'http://127.0.0.1:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
});
|
||||
deps.persistAuthenticatedSession?.(
|
||||
{
|
||||
serverUrl: 'http://127.0.0.1:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
},
|
||||
deps.getJellyfinClientInfo(),
|
||||
);
|
||||
deps.logInfo('ok');
|
||||
deps.logError('bad', null);
|
||||
deps.showMpvOsd('toast');
|
||||
deps.clearSetupWindow();
|
||||
deps.setSetupWindow({} as never);
|
||||
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, [
|
||||
'save',
|
||||
'clear-session',
|
||||
'patch',
|
||||
'persist',
|
||||
'info:ok',
|
||||
'error:bad',
|
||||
'osd:toast',
|
||||
|
||||
@@ -9,19 +9,24 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
|
||||
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
|
||||
createSetupWindow: () => deps.createSetupWindow(),
|
||||
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) =>
|
||||
deps.buildSetupFormHtml(defaultServer, defaultUser),
|
||||
buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state),
|
||||
parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
|
||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||
saveStoredSession: (session) => deps.saveStoredSession(session),
|
||||
clearStoredSession: () => deps.clearStoredSession(),
|
||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||
: undefined,
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
setSetupWindow: (window) => deps.setSetupWindow(window),
|
||||
encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
|
||||
defaultServerUrl: deps.defaultServerUrl,
|
||||
hasStoredSession: () => deps.hasStoredSession(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildJellyfinSetupFormHtml,
|
||||
buildJellyfinSetupViewState,
|
||||
createHandleJellyfinSetupWindowClosedHandler,
|
||||
createHandleJellyfinSetupNavigationHandler,
|
||||
createHandleJellyfinSetupSubmissionHandler,
|
||||
@@ -12,10 +13,50 @@ import {
|
||||
} from './jellyfin-setup-window';
|
||||
|
||||
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/"x"'));
|
||||
assert.ok(html.includes('user"name'));
|
||||
assert.ok(html.includes('Ready "now"'));
|
||||
assert.ok(html.includes('Logout'));
|
||||
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', () => {
|
||||
@@ -28,13 +69,26 @@ test('maybe focus jellyfin setup window no-ops without window', () => {
|
||||
|
||||
test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => {
|
||||
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, {
|
||||
action: 'login',
|
||||
server: 'http://localhost',
|
||||
username: 'a',
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -42,14 +96,18 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
||||
const calls: string[] = [];
|
||||
let patchPayload: unknown = null;
|
||||
let savedSession: unknown = null;
|
||||
let authPassword = '';
|
||||
const handler = createHandleJellyfinSetupSubmissionHandler({
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'http://localhost',
|
||||
username: 'user',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
}),
|
||||
authenticateWithPassword: async (_server, _username, password) => {
|
||||
authPassword = password;
|
||||
return {
|
||||
serverUrl: 'http://localhost',
|
||||
username: 'user',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
};
|
||||
},
|
||||
getJellyfinClientInfo: () => ({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
@@ -59,6 +117,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
||||
savedSession = session;
|
||||
calls.push('save');
|
||||
},
|
||||
clearStoredSession: () => calls.push('clear'),
|
||||
patchJellyfinConfig: (session) => {
|
||||
patchPayload = session;
|
||||
calls.push('patch');
|
||||
@@ -67,13 +126,16 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
||||
logError: () => calls.push('error'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
closeSetupWindow: () => calls.push('close'),
|
||||
reloadSetupWindow: () => calls.push('reload'),
|
||||
});
|
||||
|
||||
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.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(patchPayload, {
|
||||
serverUrl: 'http://localhost',
|
||||
@@ -96,18 +158,155 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
|
||||
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'),
|
||||
});
|
||||
|
||||
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.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', () => {
|
||||
@@ -200,7 +399,10 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
|
||||
showMpvOsd: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {},
|
||||
clearStoredSession: () => {},
|
||||
encodeURIComponent: (value) => value,
|
||||
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||
hasStoredSession: () => false,
|
||||
});
|
||||
|
||||
handler();
|
||||
@@ -224,6 +426,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
||||
willNavigateHandler = handler;
|
||||
}
|
||||
},
|
||||
executeJavaScript: async () => 'pass',
|
||||
},
|
||||
loadURL: (url: string) => {
|
||||
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({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => fakeWindow,
|
||||
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }),
|
||||
buildSetupFormHtml: (server, username) => `<html>${server}|${username}</html>`,
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: async () => ({
|
||||
getResolvedJellyfinConfig: () => ({
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
recentServers: [],
|
||||
}),
|
||||
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: () => ({
|
||||
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'),
|
||||
@@ -262,6 +473,8 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
defaultServerUrl: 'http://127.0.0.1:8096',
|
||||
hasStoredSession: () => true,
|
||||
});
|
||||
|
||||
handler();
|
||||
@@ -281,15 +494,16 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
|
||||
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.ok(calls.includes('password:pass'));
|
||||
assert.ok(calls.includes('save'));
|
||||
assert.ok(calls.includes('patch'));
|
||||
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;
|
||||
if (!onClosed) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeJellyfinRecentServers } from './jellyfin-cli-auth';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
@@ -17,6 +19,7 @@ type FocusableWindowLike = {
|
||||
|
||||
type JellyfinSetupWebContentsLike = {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
executeJavaScript?: (code: string, userGesture?: boolean) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type JellyfinSetupWindowLike = FocusableWindowLike & {
|
||||
@@ -27,10 +30,43 @@ type JellyfinSetupWindowLike = FocusableWindowLike & {
|
||||
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 {
|
||||
return value.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
|
||||
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>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0b1020; color: #e5e7eb; }
|
||||
main { padding: 20px; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 14px; color: #cbd5e1; font-size: 13px; line-height: 1.4; }
|
||||
label { display: block; margin: 10px 0 4px; font-size: 13px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #334155; border-radius: 8px; background: #111827; color: #e5e7eb; }
|
||||
button { margin-top: 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; font-weight: 600; cursor: pointer; background: #2563eb; color: #f8fafc; }
|
||||
.hint { margin-top: 12px; font-size: 12px; color: #94a3b8; }
|
||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||
main { padding: 22px; }
|
||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
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; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<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">
|
||||
<label for="serverSelect">Known servers</label>
|
||||
<select id="serverSelect">${options}</select>
|
||||
<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>
|
||||
<input id="username" name="username" value="${escapeHtmlAttr(defaultUser)}" required />
|
||||
<input id="username" name="username" value="${escapeHtmlAttr(state.username)}" required />
|
||||
<label for="password">Password</label>
|
||||
<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>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
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();
|
||||
submitAction("login");
|
||||
});
|
||||
document.getElementById("logout")?.addEventListener("click", () => submitAction("logout"));
|
||||
document.getElementById("done")?.addEventListener("click", () => submitAction("done"));
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||
action: JellyfinSetupAction;
|
||||
server: string;
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -101,7 +233,11 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||
return null;
|
||||
}
|
||||
const parsed = new URL(rawUrl);
|
||||
const rawAction = parsed.searchParams.get('action') || 'login';
|
||||
const action: JellyfinSetupAction =
|
||||
rawAction === 'logout' || rawAction === 'done' ? rawAction : 'login';
|
||||
return {
|
||||
action,
|
||||
server: parsed.searchParams.get('server') || '',
|
||||
username: parsed.searchParams.get('username') || '',
|
||||
password: parsed.searchParams.get('password') || '',
|
||||
@@ -111,7 +247,7 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
|
||||
export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
parseSubmissionUrl: (
|
||||
rawUrl: string,
|
||||
) => { server: string; username: string; password: string } | null;
|
||||
) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
|
||||
authenticateWithPassword: (
|
||||
server: string,
|
||||
username: string,
|
||||
@@ -120,37 +256,95 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
) => Promise<JellyfinSession>;
|
||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||
clearStoredSession: () => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => 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);
|
||||
if (!submission) {
|
||||
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 {
|
||||
const clientInfo = deps.getJellyfinClientInfo();
|
||||
const session = await deps.authenticateWithPassword(
|
||||
submission.server,
|
||||
submission.username,
|
||||
submission.password,
|
||||
deps.getJellyfinClientInfo(),
|
||||
passwordOverride ?? submission.password,
|
||||
clientInfo,
|
||||
);
|
||||
deps.saveStoredSession({
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
});
|
||||
deps.patchJellyfinConfig(session);
|
||||
if (deps.persistAuthenticatedSession) {
|
||||
deps.persistAuthenticatedSession(session, clientInfo);
|
||||
} else {
|
||||
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
|
||||
deps.patchJellyfinConfig(session);
|
||||
}
|
||||
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
||||
deps.showMpvOsd('Jellyfin login success');
|
||||
deps.closeSetupWindow();
|
||||
deps.reloadSetupWindow({
|
||||
selectedServerUrl: session.serverUrl,
|
||||
username: session.username,
|
||||
statusMessage: `Authenticated as ${session.username}.`,
|
||||
statusKind: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logError('Jellyfin setup failed', error);
|
||||
deps.showMpvOsd(`Jellyfin login failed: ${message}`);
|
||||
deps.reloadSetupWindow({
|
||||
selectedServerUrl: submission.server,
|
||||
username: submission.username,
|
||||
statusMessage: message,
|
||||
statusKind: 'error',
|
||||
});
|
||||
} finally {
|
||||
loginInFlight = false;
|
||||
}
|
||||
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: {
|
||||
clearSetupWindow: () => void;
|
||||
}) {
|
||||
@@ -192,11 +407,15 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null };
|
||||
buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string;
|
||||
getResolvedJellyfinConfig: () => {
|
||||
serverUrl?: string | null;
|
||||
username?: string | null;
|
||||
recentServers?: unknown[];
|
||||
};
|
||||
buildSetupFormHtml: (state: JellyfinSetupViewState) => string;
|
||||
parseSubmissionUrl: (
|
||||
rawUrl: string,
|
||||
) => { server: string; username: string; password: string } | null;
|
||||
) => { action: JellyfinSetupAction; server: string; username: string; password: string } | null;
|
||||
authenticateWithPassword: (
|
||||
server: string,
|
||||
username: string,
|
||||
@@ -205,13 +424,17 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
) => Promise<JellyfinSession>;
|
||||
getJellyfinClientInfo: () => JellyfinClientInfo;
|
||||
saveStoredSession: (session: { accessToken: string; userId: string }) => void;
|
||||
clearStoredSession: () => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
defaultServerUrl: string;
|
||||
hasStoredSession: () => boolean;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.maybeFocusExistingSetupWindow()) {
|
||||
@@ -219,17 +442,30 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
}
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
const defaults = deps.getResolvedJellyfinConfig();
|
||||
const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096';
|
||||
const defaultUser = defaults.username || '';
|
||||
const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser);
|
||||
const loadSetupForm = (overrides: JellyfinSetupViewOverrides = {}) => {
|
||||
const state = buildJellyfinSetupViewState({
|
||||
config: deps.getResolvedJellyfinConfig(),
|
||||
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({
|
||||
parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword: (server, username, password, clientInfo) =>
|
||||
deps.authenticateWithPassword(server, username, password, clientInfo),
|
||||
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
|
||||
saveStoredSession: (session) => deps.saveStoredSession(session),
|
||||
clearStoredSession: () => deps.clearStoredSession(),
|
||||
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
|
||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||
: undefined,
|
||||
logInfo: (message) => deps.logInfo(message),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||
@@ -238,10 +474,22 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
setupWindow.close();
|
||||
}
|
||||
},
|
||||
reloadSetupWindow: (state) => {
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
loadSetupForm(state);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
|
||||
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),
|
||||
});
|
||||
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', () => {
|
||||
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',
|
||||
]);
|
||||
});
|
||||
@@ -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({
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openSessionHelp();
|
||||
handlers.openTexthookerInBrowser();
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
handlers.openAnilistSetup();
|
||||
handlers.quitApp();
|
||||
return [{ label: 'ok' }] as never;
|
||||
@@ -57,12 +59,18 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: async () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
},
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
@@ -72,11 +80,13 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
assert.deepEqual(calls, [
|
||||
'init',
|
||||
'help',
|
||||
'texthooker',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
'anilist',
|
||||
'quit',
|
||||
]);
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createResolveTrayIconPathHandler(deps: {
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -36,18 +37,25 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
@@ -59,6 +67,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}
|
||||
deps.openSessionHelpModal();
|
||||
},
|
||||
openTexthookerInBrowser: () => {
|
||||
deps.openTexthookerInBrowser();
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
@@ -79,6 +90,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openJellyfinSetup: () => {
|
||||
deps.openJellyfinSetupWindow();
|
||||
},
|
||||
showJellyfinDiscovery: deps.isJellyfinConfigured(),
|
||||
jellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive(),
|
||||
toggleJellyfinDiscovery: () => {
|
||||
void deps.toggleJellyfinDiscovery();
|
||||
},
|
||||
openAnilistSetup: () => {
|
||||
deps.openAnilistSetupWindow();
|
||||
},
|
||||
|
||||
@@ -25,18 +25,25 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
},
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
})();
|
||||
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
@@ -44,6 +51,9 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
|
||||
openAnilistSetup: () => calls.push('open-anilist'),
|
||||
quitApp: () => calls.push('quit-app'),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -35,18 +36,25 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
@@ -55,12 +63,16 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
|
||||
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
|
||||
openAnilistSetupWindow: deps.openAnilistSetupWindow,
|
||||
quitApp: deps.quitApp,
|
||||
});
|
||||
|
||||
@@ -25,12 +25,16 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
openSessionHelpModal: () => {},
|
||||
openTexthookerInBrowser: () => {},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
quitApp: () => {},
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
@@ -37,22 +38,35 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 9);
|
||||
assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false);
|
||||
assert.equal(template.length, 11);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
);
|
||||
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[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[8]!.click?.();
|
||||
assert.deepEqual(calls, ['help', 'separator', 'quit']);
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
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', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
@@ -60,6 +74,9 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => undefined,
|
||||
openAnilistSetup: () => 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('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);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -38,13 +39,18 @@ export type TrayMenuActionHandlers = {
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
};
|
||||
|
||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||
label?: string;
|
||||
type?: 'separator';
|
||||
type?: 'separator' | 'checkbox';
|
||||
checked?: boolean;
|
||||
enabled?: boolean;
|
||||
click?: () => void;
|
||||
}> {
|
||||
return [
|
||||
@@ -52,6 +58,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
{
|
||||
label: 'Open Texthooker',
|
||||
click: handlers.openTexthookerInBrowser,
|
||||
},
|
||||
...(handlers.showFirstRunSetup
|
||||
? [
|
||||
{
|
||||
@@ -80,6 +90,17 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Configure Jellyfin',
|
||||
click: handlers.openJellyfinSetup,
|
||||
},
|
||||
...(handlers.showJellyfinDiscovery
|
||||
? [
|
||||
{
|
||||
label: 'Jellyfin Discovery',
|
||||
type: 'checkbox' as const,
|
||||
checked: handlers.jellyfinDiscoveryActive,
|
||||
enabled: true,
|
||||
click: handlers.toggleJellyfinDiscovery,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Configure AniList',
|
||||
click: handlers.openAnilistSetup,
|
||||
|
||||
@@ -36,9 +36,12 @@ test('release workflow verifies a committed changelog section before publish', (
|
||||
assert.match(releaseWorkflow, /bun run changelog:check/);
|
||||
});
|
||||
|
||||
test('release workflow builds changelog artifacts when fragments are pending', () => {
|
||||
assert.match(releaseWorkflow, /Build changelog artifacts for release/);
|
||||
assert.match(releaseWorkflow, /changelog:build --version/);
|
||||
test('release workflow guards against pending changelog fragments instead of auto-building them', () => {
|
||||
assert.match(releaseWorkflow, /Guard against pending changelog fragments/);
|
||||
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', () => {
|
||||
|
||||
@@ -2,10 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import {
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
} from './session-help.js';
|
||||
import { describeSessionHelpCommand, formatSessionHelpKeybinding } from './session-help.js';
|
||||
|
||||
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
||||
|
||||
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
|
||||
jellyfin: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
recentServers: string[];
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface YomitanConfig {
|
||||
export interface JellyfinConfig {
|
||||
enabled?: boolean;
|
||||
serverUrl?: string;
|
||||
recentServers?: string[];
|
||||
username?: string;
|
||||
deviceId?: string;
|
||||
clientName?: string;
|
||||
|
||||
Reference in New Issue
Block a user