mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Merge remote-tracking branch 'origin/main' into add-hachidori-backend
# Conflicts: # src/renderer/handlers/keyboard.test.ts
This commit is contained in:
54 files changed
+2363
-825
No files matched your search
@@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package.
|
|||||||
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||||
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||||
- Stats dashboard: `bun run test:stats`
|
- Stats dashboard: `bun run test:stats`
|
||||||
|
- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e`
|
||||||
- Build/release scripts: `bun run test:scripts`
|
- Build/release scripts: `bun run test:scripts`
|
||||||
|
|
||||||
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||||
|
|||||||
@@ -9,3 +9,79 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-test-audit:
|
build-test-audit:
|
||||||
uses: ./.github/workflows/quality-gate.yml
|
uses: ./.github/workflows/quality-gate.yml
|
||||||
|
|
||||||
|
# Boots the built app with a real mpv on each platform. Kept outside the
|
||||||
|
# reusable quality gate so releases do not depend on it while the lane is new.
|
||||||
|
e2e:
|
||||||
|
name: E2E (${{ matrix.os }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SUBMINER_E2E_KEEP: '1'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Point temp files at an uploadable directory
|
||||||
|
run: |
|
||||||
|
# os.tmpdir() follows these, so the kept session logs land under runner.temp.
|
||||||
|
dir="$RUNNER_TEMP/e2e"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
{ echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Install mpv and ffmpeg (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
# Only Ubuntu sources are needed; unrelated runner repositories can be unavailable.
|
||||||
|
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
|
||||||
|
sudo apt-get "${apt_sources[@]}" update
|
||||||
|
sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb
|
||||||
|
|
||||||
|
- name: Install mpv and ffmpeg (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: brew install mpv ffmpeg
|
||||||
|
|
||||||
|
- name: Install mpv and ffmpeg (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
choco install mpv ffmpeg -y --no-progress
|
||||||
|
# The mpv package adds no shim, so put its directory on PATH for later steps.
|
||||||
|
mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)"
|
||||||
|
test -n "$mpv_exe"
|
||||||
|
cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: End-to-end suite
|
||||||
|
run: bun run test:e2e
|
||||||
|
|
||||||
|
- name: Upload session logs (on failure)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-logs-${{ matrix.os }}
|
||||||
|
path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -45,6 +45,7 @@ Start here, then leave this file.
|
|||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||||
|
- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks)
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: testing
|
||||||
|
|
||||||
|
- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
|
||||||
|
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
|
||||||
|
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
|
||||||
|
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Keyboard-only mode no longer swallows `h`, `l`, and the left/right arrows while a modal is open or a text field has focus, so typing into fields like the Jimaku or TsukiHime title works and no longer seeks mpv. Pressing `Ctrl/Cmd+Shift+Y` (or the controller toggle) inside a modal no longer turns on a hidden keyboard-only mode in the modal window.
|
||||||
@@ -521,12 +521,12 @@ In external-profile mode, SubMiner only reads the profile. It does not open its
|
|||||||
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
|
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
|
| ----------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
| `jellyfin.enabled` | `false` | Enable Jellyfin |
|
||||||
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
|
||||||
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
|
||||||
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
|
||||||
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
|
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup (also needs `startupWarmups.jellyfinRemoteSession`) |
|
||||||
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
|
||||||
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
|
||||||
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ SubMiner stores an encrypted session token, not your password, and turns the int
|
|||||||
|
|
||||||
## Casting from Jellyfin
|
## Casting from Jellyfin
|
||||||
|
|
||||||
After you sign in, SubMiner connects to Jellyfin at startup and shows up in the cast ("Play on") menu under your computer's hostname. To connect for the current session only, tick **Jellyfin Discovery** in the tray menu.
|
After you sign in, tick **Jellyfin Discovery** in the tray menu. SubMiner then shows up in the cast ("Play on") menu under your computer's hostname until it quits. To connect every time SubMiner starts, turn on `startupWarmups.jellyfinRemoteSession` and leave `remoteControlAutoConnect` on.
|
||||||
|
|
||||||
1. In the Jellyfin web or mobile app, start playing an episode.
|
1. In the Jellyfin web or mobile app, start playing an episode.
|
||||||
2. Open the cast menu and pick your computer.
|
2. Open the cast menu and pick your computer.
|
||||||
@@ -34,18 +34,18 @@ subminer jellyfin -p # fzf picker; `jf` is an alias for `jellyfin`
|
|||||||
subminer -R jellyfin -p # rofi picker
|
subminer -R jellyfin -p # rofi picker
|
||||||
```
|
```
|
||||||
|
|
||||||
Sign in first. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
Sign in first. Playback resumes from Jellyfin's saved position, and watch progress syncs back the same way it does for casting, whether or not discovery is on. If SubMiner is already running, the picker uses it and leaves it running. Otherwise the launcher starts SubMiner and quits it when you close mpv, the same as playing a local file. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
|
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
|
||||||
|
|
||||||
| Key | What it does |
|
| Key | What it does |
|
||||||
| -------------------------- | -------------------------------------------------------------------- |
|
| -------------------------- | ---------------------------------------------------------------------- |
|
||||||
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
| `enabled` | Turns the integration on. Set for you when you sign in. |
|
||||||
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
|
||||||
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
|
||||||
| `remoteControlAutoConnect` | Connects at startup. Turn off to start discovery from the tray. |
|
| `remoteControlAutoConnect` | Connects at startup when `startupWarmups.jellyfinRemoteSession` is on. |
|
||||||
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
|
||||||
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Verification
|
# Verification
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-13
|
Last verified: 2026-10-05
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: selecting the right verification lane for a change
|
Read when: selecting the right verification lane for a change
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@ bun run docs:build
|
|||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
||||||
- Stats dashboard UI: `bun run test:stats`
|
- Stats dashboard UI: `bun run test:stats`
|
||||||
|
- Overlay, renderer, mpv, or mining behavior that unit tests cannot show: `bun run build`,
|
||||||
|
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||||
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
||||||
@@ -94,6 +96,54 @@ bun run docs:build
|
|||||||
- This is not a full Electron UI startup check. It does not require a display and
|
- This is not a full Electron UI startup check. It does not require a display and
|
||||||
makes no claims about renderer, window, tray, or mpv behavior.
|
makes no claims about renderer, window, tray, or mpv behavior.
|
||||||
|
|
||||||
|
## Headless End-to-End Harness
|
||||||
|
|
||||||
|
`bun run test:e2e` boots the built app against a real mpv and asserts on what the
|
||||||
|
overlay renders and what reaches AnkiConnect. Use it instead of a live desktop test
|
||||||
|
whenever the behavior does not depend on a specific compositor.
|
||||||
|
|
||||||
|
- Needs an existing `bun run build`, plus `mpv` and `ffmpeg` on `PATH` (and `Xvfb` on
|
||||||
|
Linux). It is not part of `test:fast` or the handoff gate. The `e2e` job in
|
||||||
|
`.github/workflows/ci.yml` runs it on Linux, macOS, and Windows for pull requests and
|
||||||
|
`main`, outside the reusable quality gate, and uploads the session logs when it fails.
|
||||||
|
- Every run is sandboxed in a temp dir: its own config and Electron profile, mpv IPC
|
||||||
|
socket, and a fake AnkiConnect on a free port. It never touches the user's running
|
||||||
|
SubMiner, Anki, or stats database. It is not offline: the app still asks AniList for
|
||||||
|
cover art matching the clip's title.
|
||||||
|
- Media is synthetic: an ffmpeg test-pattern clip, a three-cue Japanese `.srt`, and a
|
||||||
|
tiny Yomitan dictionary covering those cues (`e2e/harness/fixtures.ts`). The harness
|
||||||
|
imports the dictionary on a first boot, then restarts the app for the scenario.
|
||||||
|
- Display: Linux runs everything inside a private Xvfb server, so no window reaches the
|
||||||
|
desktop. macOS and Windows have no offscreen display server, so windows open in the
|
||||||
|
current session there; `SUBMINER_E2E_DISPLAY=host` forces the same on Linux. The
|
||||||
|
CI job runs all three. On macOS and Windows the app pre-creates a hidden modal window
|
||||||
|
that loads the same renderer document, so window matching must include the
|
||||||
|
`layer=visible` query (`OVERLAY_PAGE`).
|
||||||
|
- The app is driven over the Chrome DevTools Protocol (DOM queries, input events,
|
||||||
|
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||||
|
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||||
|
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||||
|
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
||||||
|
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||||
|
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||||
|
|
||||||
|
For exploratory checks, keep one session up and poke at it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run e2e start & # prints the session JSON, stays up until stopped
|
||||||
|
bun run e2e mpv seek 2 absolute+exact
|
||||||
|
bun run e2e eval overlay 'document.getElementById("subtitleRoot").textContent'
|
||||||
|
bun run e2e shot overlay /tmp/overlay.png
|
||||||
|
bun run e2e app --mine-sentence
|
||||||
|
bun run e2e anki # AnkiConnect requests received so far
|
||||||
|
bun run e2e stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Out of scope: compositor-specific behavior (Hyprland placement, stacking, fullscreen,
|
||||||
|
click-through), real GPU video output, and global shortcuts. Xvfb runs without a window
|
||||||
|
manager or compositor, so the transparent overlay is not composited over the video;
|
||||||
|
check overlay visuals with per-window screenshots, and use the live desktop for the rest.
|
||||||
|
|
||||||
## Dependency Audit Policy
|
## Dependency Audit Policy
|
||||||
|
|
||||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
// Keeps one isolated e2e session running so a change can be poked at by hand
|
||||||
|
// (or by an agent) without touching the real desktop:
|
||||||
|
//
|
||||||
|
// bun run e2e start boot and stay up until `stop` or Ctrl-C
|
||||||
|
// bun run e2e eval <page> <expression> evaluate JS in a window (or "main" for the main process)
|
||||||
|
// bun run e2e shot <page> <file.png> save a screenshot of a window
|
||||||
|
// bun run e2e mpv <command> [args...] send an mpv IPC command (args parsed as JSON)
|
||||||
|
// bun run e2e app <args...> run a SubMiner CLI command against the instance
|
||||||
|
// bun run e2e anki print the AnkiConnect requests received so far
|
||||||
|
// bun run e2e stop
|
||||||
|
//
|
||||||
|
// <page> is a URL fragment: "overlay" is shorthand for the overlay window.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { connectCdp, connectCdpPage, listCdpTargets } from './harness/cdp';
|
||||||
|
import { connectMpv } from './harness/mpv';
|
||||||
|
import { OVERLAY_PAGE, runAppCommand, startE2eSession, type AppTarget } from './harness/session';
|
||||||
|
import { waitUntil } from './harness/wait';
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
pid: number;
|
||||||
|
root: string;
|
||||||
|
display: string;
|
||||||
|
cdpPort: number;
|
||||||
|
inspectorUrl: string;
|
||||||
|
mpvSocketPath: string;
|
||||||
|
ankiUrl: string;
|
||||||
|
target: AppTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateFile = path.join(os.tmpdir(), 'subminer-e2e-session.json');
|
||||||
|
|
||||||
|
function readState(): SessionState {
|
||||||
|
if (!fs.existsSync(stateFile)) {
|
||||||
|
throw new Error('No e2e session is running. Start one with `bun run e2e start`.');
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonOrString(value: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectNamedPage(state: SessionState, pageName: string) {
|
||||||
|
if (pageName === 'main') return connectCdp(state.inspectorUrl, 'main process');
|
||||||
|
const urlPart = pageName === 'overlay' ? OVERLAY_PAGE : pageName;
|
||||||
|
const targets = await listCdpTargets(state.cdpPort);
|
||||||
|
const target = targets.find((candidate) => candidate.url.includes(urlPart));
|
||||||
|
if (!target) {
|
||||||
|
const open = targets.map((candidate) => ` ${candidate.type} ${candidate.url.slice(0, 100)}`);
|
||||||
|
throw new Error(`No window matches "${pageName}". Open targets:\n${open.join('\n')}`);
|
||||||
|
}
|
||||||
|
return connectCdpPage(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withPage<T>(
|
||||||
|
state: SessionState,
|
||||||
|
pageName: string,
|
||||||
|
use: (page: Awaited<ReturnType<typeof connectCdpPage>>) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const page = await connectNamedPage(state, pageName);
|
||||||
|
try {
|
||||||
|
return await use(page);
|
||||||
|
} finally {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
if (fs.existsSync(stateFile) && isAlive(readState().pid)) {
|
||||||
|
throw new Error('An e2e session is already running. Stop it with `bun run e2e stop`.');
|
||||||
|
}
|
||||||
|
const session = await startE2eSession();
|
||||||
|
const state: SessionState = {
|
||||||
|
pid: process.pid,
|
||||||
|
root: session.root,
|
||||||
|
display: session.display,
|
||||||
|
cdpPort: session.cdpPort,
|
||||||
|
inspectorUrl: session.inspectorUrl,
|
||||||
|
mpvSocketPath: session.mpvSocketPath,
|
||||||
|
ankiUrl: session.anki.url,
|
||||||
|
target: session.target,
|
||||||
|
};
|
||||||
|
// The state file grants main-process code evaluation via inspectorUrl, so keep it
|
||||||
|
// owner-only. 'wx' creates it exclusively: it fails rather than follow a symlink
|
||||||
|
// planted in the shared temp dir after the stale file is removed.
|
||||||
|
fs.rmSync(stateFile, { force: true });
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
|
||||||
|
console.log(JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.once('SIGINT', resolve);
|
||||||
|
process.once('SIGTERM', resolve);
|
||||||
|
});
|
||||||
|
fs.rmSync(stateFile, { force: true });
|
||||||
|
await session.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
if (command === 'start') return start();
|
||||||
|
|
||||||
|
const state = readState();
|
||||||
|
switch (command) {
|
||||||
|
case 'eval': {
|
||||||
|
const [pageName, expression] = args;
|
||||||
|
if (!pageName || !expression) throw new Error('Usage: eval <page> <expression>');
|
||||||
|
const value = await withPage(state, pageName, (page) => page.evaluate<unknown>(expression));
|
||||||
|
console.log(JSON.stringify(value, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'shot': {
|
||||||
|
const [pageName, outputPath] = args;
|
||||||
|
if (!pageName || !outputPath) throw new Error('Usage: shot <page> <file.png>');
|
||||||
|
fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'mpv': {
|
||||||
|
if (args.length === 0) throw new Error('Usage: mpv <command> [args...]');
|
||||||
|
const mpv = await connectMpv(state.mpvSocketPath);
|
||||||
|
try {
|
||||||
|
console.log(JSON.stringify((await mpv.command(...args.map(parseJsonOrString))) ?? null));
|
||||||
|
} finally {
|
||||||
|
mpv.close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'app':
|
||||||
|
await runAppCommand(state.target, args);
|
||||||
|
return;
|
||||||
|
case 'anki':
|
||||||
|
console.log(JSON.stringify(await (await fetch(state.ankiUrl)).json(), null, 2));
|
||||||
|
return;
|
||||||
|
case 'stop':
|
||||||
|
process.kill(state.pid, 'SIGTERM');
|
||||||
|
await waitUntil(() => !isAlive(state.pid), { description: 'e2e session to exit' });
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command "${command ?? ''}". See the header of e2e/cli.ts.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { waitUntil, type Truthy, type WaitOptions } from './wait';
|
||||||
|
|
||||||
|
export type CdpTarget = {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
webSocketDebuggerUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CdpPage = {
|
||||||
|
/** Sends a raw DevTools protocol command, for anything the helpers below do not cover. */
|
||||||
|
send: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
/** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */
|
||||||
|
evaluate: <T>(expression: string) => Promise<T>;
|
||||||
|
/** Polls an expression until it is truthy and returns that value. */
|
||||||
|
waitFor: <T>(expression: string, options: WaitOptions) => Promise<Truthy<T>>;
|
||||||
|
/** Console output and uncaught exceptions seen since connecting, oldest first. */
|
||||||
|
console: string[];
|
||||||
|
/** PNG of the page as rendered by Chromium; needs no OS screen-capture permission. */
|
||||||
|
screenshot: () => Promise<Buffer>;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpReply = {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
error?: { message: string };
|
||||||
|
result?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RemoteObject = { value?: unknown; description?: string };
|
||||||
|
|
||||||
|
// Renders a console/exception event as one log line.
|
||||||
|
function formatConsoleEvent(method: string, params: Record<string, unknown>): string | null {
|
||||||
|
if (method === 'Runtime.consoleAPICalled') {
|
||||||
|
const args = Array.isArray(params.args) ? (params.args as RemoteObject[]) : [];
|
||||||
|
const text = args.map((arg) => arg.description ?? JSON.stringify(arg.value)).join(' ');
|
||||||
|
return `console.${String(params.type)}: ${text}`;
|
||||||
|
}
|
||||||
|
if (method === 'Runtime.exceptionThrown') {
|
||||||
|
const details = params.exceptionDetails as
|
||||||
|
| { text?: string; exception?: RemoteObject }
|
||||||
|
| undefined;
|
||||||
|
return `exception: ${details?.exception?.description ?? details?.text ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
if (method === 'Log.entryAdded') {
|
||||||
|
const entry = params.entry as { level?: string; source?: string; text?: string } | undefined;
|
||||||
|
return `${String(entry?.source)}.${String(entry?.level)}: ${String(entry?.text)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvaluateResult = {
|
||||||
|
result: { value?: unknown };
|
||||||
|
exceptionDetails?: { text: string; exception?: { description?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listCdpTargets(port: number): Promise<CdpTarget[]> {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||||
|
return (await response.json()) as CdpTarget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectCdpPage(target: CdpTarget): Promise<CdpPage> {
|
||||||
|
return connectCdp(target.webSocketDebuggerUrl, target.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects to any DevTools endpoint: a renderer page or Electron's main-process inspector. */
|
||||||
|
export async function connectCdp(webSocketDebuggerUrl: string, label: string): Promise<CdpPage> {
|
||||||
|
const socket = new WebSocket(webSocketDebuggerUrl);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.addEventListener('open', () => resolve(), { once: true });
|
||||||
|
socket.addEventListener('error', () => reject(new Error(`CDP connection to ${label} failed`)), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = new Map<number, (reply: CdpReply) => void>();
|
||||||
|
const consoleLines: string[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
const reply: CdpReply = JSON.parse(String(event.data));
|
||||||
|
if (reply.id === undefined) {
|
||||||
|
if (reply.method) {
|
||||||
|
const line = formatConsoleEvent(reply.method, reply.params ?? {});
|
||||||
|
if (line) consoleLines.push(line);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.get(reply.id)?.(reply);
|
||||||
|
pending.delete(reply.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (method: string, params: Record<string, unknown> = {}) =>
|
||||||
|
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
|
const id = nextId++;
|
||||||
|
pending.set(id, (reply) => {
|
||||||
|
if (reply.error) reject(new Error(`CDP ${method} failed: ${reply.error.message}`));
|
||||||
|
else resolve(reply.result ?? {});
|
||||||
|
});
|
||||||
|
socket.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluate = async <T>(expression: string): Promise<T> => {
|
||||||
|
const reply = (await send('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
returnByValue: true,
|
||||||
|
awaitPromise: true,
|
||||||
|
})) as EvaluateResult;
|
||||||
|
if (reply.exceptionDetails) {
|
||||||
|
const { text, exception } = reply.exceptionDetails;
|
||||||
|
throw new Error(`Page evaluation threw: ${exception?.description ?? text}`);
|
||||||
|
}
|
||||||
|
return reply.result.value as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
await send('Runtime.enable');
|
||||||
|
// The Node inspector behind Electron's main process has no Log domain.
|
||||||
|
await send('Log.enable').catch(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
send,
|
||||||
|
evaluate,
|
||||||
|
console: consoleLines,
|
||||||
|
waitFor: <T>(expression: string, options: WaitOptions) =>
|
||||||
|
waitUntil(() => evaluate<T>(expression), options),
|
||||||
|
screenshot: async () => {
|
||||||
|
const reply = await send('Page.captureScreenshot', { format: 'png' });
|
||||||
|
return Buffer.from(String(reply.data), 'base64');
|
||||||
|
},
|
||||||
|
close: () => socket.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { stopProcess, type EnvDelta } from './process';
|
||||||
|
|
||||||
|
export const E2E_SCREEN = { width: 1280, height: 720 } as const;
|
||||||
|
|
||||||
|
export type E2eDisplay = {
|
||||||
|
kind: 'xvfb' | 'host';
|
||||||
|
env: EnvDelta;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Linux gets a private Xvfb server so no window ever reaches the real desktop.
|
||||||
|
// macOS and Windows have no offscreen display server, so windows open in the
|
||||||
|
// current GUI session there. SUBMINER_E2E_DISPLAY=host forces that on Linux too.
|
||||||
|
export async function startDisplay(): Promise<E2eDisplay> {
|
||||||
|
const requested = process.env.SUBMINER_E2E_DISPLAY?.trim().toLowerCase();
|
||||||
|
if (requested && requested !== 'host' && requested !== 'xvfb') {
|
||||||
|
throw new Error(`SUBMINER_E2E_DISPLAY must be "host" or "xvfb", got "${requested}"`);
|
||||||
|
}
|
||||||
|
const kind = requested ?? (process.platform === 'linux' ? 'xvfb' : 'host');
|
||||||
|
if (kind === 'host') {
|
||||||
|
return { kind, env: { set: {}, unset: [] }, stop: async () => {} };
|
||||||
|
}
|
||||||
|
return startXvfb();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startXvfb(): Promise<E2eDisplay> {
|
||||||
|
// -displayfd makes Xvfb pick a free display number and write it to fd 3 once
|
||||||
|
// it accepts connections, so there is no port guessing and no startup sleep.
|
||||||
|
const child = spawn(
|
||||||
|
'Xvfb',
|
||||||
|
[
|
||||||
|
'-displayfd',
|
||||||
|
'3',
|
||||||
|
'-screen',
|
||||||
|
'0',
|
||||||
|
`${E2E_SCREEN.width}x${E2E_SCREEN.height}x24`,
|
||||||
|
'-nolisten',
|
||||||
|
'tcp',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'ignore', 'pipe'] },
|
||||||
|
);
|
||||||
|
const displayFd = child.stdio[3];
|
||||||
|
if (!displayFd) throw new Error('Xvfb did not expose its display fd');
|
||||||
|
|
||||||
|
const displayNumber = await Promise.race([
|
||||||
|
once(displayFd, 'data').then(([chunk]) => String(chunk).trim()),
|
||||||
|
once(child, 'error').then(([error]) => {
|
||||||
|
throw new Error(
|
||||||
|
`Could not start Xvfb (${String(error)}). Install it or set SUBMINER_E2E_DISPLAY=host.`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
once(child, 'exit').then(([code]) => {
|
||||||
|
throw new Error(`Xvfb exited during startup with code ${String(code)}`);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'xvfb',
|
||||||
|
env: {
|
||||||
|
set: { DISPLAY: `:${displayNumber}`, XDG_SESSION_TYPE: 'x11' },
|
||||||
|
// Without these the app and mpv would pick the real Wayland session, or
|
||||||
|
// take the compositor-specific code paths of the desktop running the tests.
|
||||||
|
unset: [
|
||||||
|
'WAYLAND_DISPLAY',
|
||||||
|
'HYPRLAND_INSTANCE_SIGNATURE',
|
||||||
|
'SWAYSOCK',
|
||||||
|
'XDG_CURRENT_DESKTOP',
|
||||||
|
'XDG_SESSION_DESKTOP',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stop: () => stopProcess(child),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { once } from 'node:events';
|
||||||
|
import http from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
|
||||||
|
export type AnkiRequest = { action: string; params: Record<string, unknown> };
|
||||||
|
|
||||||
|
export type FakeAnki = {
|
||||||
|
url: string;
|
||||||
|
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
|
||||||
|
requests: AnkiRequest[];
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FAKE_ANKI_DECK = 'E2E';
|
||||||
|
export const FAKE_ANKI_MODEL = 'Lapis';
|
||||||
|
|
||||||
|
const MODEL_FIELDS = [
|
||||||
|
'Expression',
|
||||||
|
'ExpressionFurigana',
|
||||||
|
'ExpressionReading',
|
||||||
|
'ExpressionAudio',
|
||||||
|
'SelectionText',
|
||||||
|
'MainDefinition',
|
||||||
|
'Sentence',
|
||||||
|
'SentenceFurigana',
|
||||||
|
'SentenceAudio',
|
||||||
|
'Picture',
|
||||||
|
'Glossary',
|
||||||
|
'IsWordAndSentenceCard',
|
||||||
|
'IsClickCard',
|
||||||
|
'IsSentenceCard',
|
||||||
|
'IsAudioCard',
|
||||||
|
'MiscInfo',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||||
|
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||||
|
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||||
|
const requests: AnkiRequest[] = [];
|
||||||
|
let nextNoteId = 1000;
|
||||||
|
|
||||||
|
const handle = (body: unknown): unknown => {
|
||||||
|
if (!isRecord(body) || typeof body.action !== 'string') return null;
|
||||||
|
const params = isRecord(body.params) ? body.params : {};
|
||||||
|
if (body.action === 'multi') {
|
||||||
|
return Array.isArray(params.actions) ? params.actions.map(handle) : [];
|
||||||
|
}
|
||||||
|
requests.push({ action: body.action, params });
|
||||||
|
switch (body.action) {
|
||||||
|
case 'version':
|
||||||
|
return 6;
|
||||||
|
case 'deckNames':
|
||||||
|
return [FAKE_ANKI_DECK];
|
||||||
|
case 'modelNames':
|
||||||
|
return [FAKE_ANKI_MODEL];
|
||||||
|
case 'modelFieldNames':
|
||||||
|
return MODEL_FIELDS;
|
||||||
|
case 'addNote':
|
||||||
|
return nextNoteId++;
|
||||||
|
case 'findNotes':
|
||||||
|
case 'notesInfo':
|
||||||
|
return [];
|
||||||
|
case 'storeMediaFile':
|
||||||
|
return params.filename ?? null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
response.setHeader('content-type', 'application/json');
|
||||||
|
// GET exposes the request log so the session CLI can read it from outside.
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
response.end(JSON.stringify(requests));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
request.on('end', () => {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
// Malformed bodies get the same null result AnkiConnect gives unknown actions.
|
||||||
|
}
|
||||||
|
response.end(JSON.stringify({ result: handle(body), error: null }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
requests,
|
||||||
|
stop: async () => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
server.close();
|
||||||
|
await once(server, 'close');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { writeStoredZipAsync } from '../../src/shared/stored-zip';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export const FIXTURE_DICTIONARY_TITLE = 'SubMiner E2E Fixture';
|
||||||
|
export const FIXTURE_CLIP_SECONDS = 15;
|
||||||
|
|
||||||
|
/** Cues of the synthetic clip. Seek to `at` to land inside a cue. */
|
||||||
|
export const FIXTURE_CUES = [
|
||||||
|
{ start: 1, end: 4, at: 2, text: '今日はいい天気ですね。' },
|
||||||
|
{ start: 5, end: 8, at: 6, text: '猫が好きです。' },
|
||||||
|
{ start: 9, end: 12, at: 10, text: '明日は学校に行きます。' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// [term, reading, part of speech, gloss] for every content word in FIXTURE_CUES.
|
||||||
|
const FIXTURE_TERMS = [
|
||||||
|
['今日', 'きょう', 'n', 'today'],
|
||||||
|
['いい', 'いい', 'adj-i', 'good'],
|
||||||
|
['天気', 'てんき', 'n', 'weather'],
|
||||||
|
['猫', 'ねこ', 'n', 'cat'],
|
||||||
|
['好き', 'すき', 'adj-na', 'liked'],
|
||||||
|
['明日', 'あした', 'n', 'tomorrow'],
|
||||||
|
['学校', 'がっこう', 'n', 'school'],
|
||||||
|
['行く', 'いく', 'v5', 'to go'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function srtTimestamp(seconds: number): string {
|
||||||
|
return `00:00:${String(seconds).padStart(2, '0')},000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFixtureSubtitles(outputPath: string): void {
|
||||||
|
const blocks = FIXTURE_CUES.map(
|
||||||
|
(cue, index) =>
|
||||||
|
`${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text}\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(outputPath, blocks.join('\n'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a deterministic test-pattern clip with a sine tone, so no real media is needed. */
|
||||||
|
export async function generateFixtureClip(outputPath: string): Promise<void> {
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
|
'-nostdin',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-y',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
`testsrc2=size=640x360:rate=24:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
`sine=frequency=440:duration=${FIXTURE_CLIP_SECONDS}`,
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'ultrafast',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
outputPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a minimal Yomitan term dictionary so the tokenizer has real entries to match. */
|
||||||
|
export async function buildFixtureDictionary(outputPath: string): Promise<void> {
|
||||||
|
const index = {
|
||||||
|
title: FIXTURE_DICTIONARY_TITLE,
|
||||||
|
revision: '1',
|
||||||
|
format: 3,
|
||||||
|
author: 'SubMiner',
|
||||||
|
description: 'Synthetic dictionary for the e2e harness.',
|
||||||
|
};
|
||||||
|
const termBank = FIXTURE_TERMS.map(([term, reading, partOfSpeech, gloss], sequence) => [
|
||||||
|
term,
|
||||||
|
reading,
|
||||||
|
'',
|
||||||
|
partOfSpeech,
|
||||||
|
0,
|
||||||
|
[gloss],
|
||||||
|
sequence + 1,
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
await writeStoredZipAsync(outputPath, [
|
||||||
|
{ name: 'index.json', data: Buffer.from(JSON.stringify(index), 'utf8') },
|
||||||
|
{ name: 'term_bank_1.json', data: Buffer.from(JSON.stringify(termBank), 'utf8') },
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import type { E2eDisplay } from './display';
|
||||||
|
import { E2E_SCREEN } from './display';
|
||||||
|
import { applyEnvDelta } from './process';
|
||||||
|
import { waitUntil } from './wait';
|
||||||
|
|
||||||
|
export type MpvClient = {
|
||||||
|
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
|
||||||
|
command: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
||||||
|
|
||||||
|
export async function startMpv(options: {
|
||||||
|
socketPath: string;
|
||||||
|
mediaPath: string;
|
||||||
|
subtitlePath: string;
|
||||||
|
logPath: string;
|
||||||
|
display: E2eDisplay;
|
||||||
|
}): Promise<ChildProcess> {
|
||||||
|
const log = fs.openSync(options.logPath, 'w');
|
||||||
|
const child = spawn(
|
||||||
|
'mpv',
|
||||||
|
[
|
||||||
|
'--no-config',
|
||||||
|
'--no-terminal',
|
||||||
|
'--pause',
|
||||||
|
'--keep-open=yes',
|
||||||
|
'--ao=null',
|
||||||
|
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
||||||
|
// Xvfb has no GPU; the software X11 output always works there.
|
||||||
|
...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []),
|
||||||
|
`--input-ipc-server=${options.socketPath}`,
|
||||||
|
`--sub-file=${options.subtitlePath}`,
|
||||||
|
options.mediaPath,
|
||||||
|
],
|
||||||
|
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
|
||||||
|
);
|
||||||
|
fs.closeSync(log);
|
||||||
|
await Promise.race([
|
||||||
|
once(child, 'spawn'),
|
||||||
|
once(child, 'error').then(([error]) => {
|
||||||
|
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConnect(socketPath: string): Promise<net.Socket | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = net.connect(socketPath);
|
||||||
|
socket.once('connect', () => resolve(socket));
|
||||||
|
socket.once('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectMpv(socketPath: string): Promise<MpvClient> {
|
||||||
|
const socket = await waitUntil(() => tryConnect(socketPath), {
|
||||||
|
description: `mpv IPC socket ${socketPath}`,
|
||||||
|
});
|
||||||
|
const pending = new Map<number, (reply: MpvReply) => void>();
|
||||||
|
let nextRequestId = 1;
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.on('data', (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const reply: MpvReply = JSON.parse(line);
|
||||||
|
if (reply.request_id === undefined) continue;
|
||||||
|
pending.get(reply.request_id)?.(reply);
|
||||||
|
pending.delete(reply.request_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: (...args) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const requestId = nextRequestId++;
|
||||||
|
pending.set(requestId, (reply) => {
|
||||||
|
if (reply.error === 'success') resolve(reply.data);
|
||||||
|
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
|
||||||
|
});
|
||||||
|
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
|
||||||
|
}),
|
||||||
|
close: () => socket.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
|
||||||
|
export type EnvDelta = { set: Record<string, string>; unset: string[] };
|
||||||
|
|
||||||
|
export function applyEnvDelta(base: NodeJS.ProcessEnv, ...deltas: EnvDelta[]): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...base };
|
||||||
|
for (const delta of deltas) {
|
||||||
|
for (const name of delta.unset) delete env[name];
|
||||||
|
Object.assign(env, delta.set);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminates a child and waits for it to exit, escalating to SIGKILL after `graceMs`. */
|
||||||
|
export async function stopProcess(child: ChildProcess, graceMs = 5_000): Promise<void> {
|
||||||
|
// A child that never spawned (missing binary) has no pid and never emits exit.
|
||||||
|
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
const exited = once(child, 'exit');
|
||||||
|
child.kill();
|
||||||
|
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
||||||
|
await exited;
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { connectCdp, connectCdpPage, listCdpTargets, type CdpPage } from './cdp';
|
||||||
|
import { startDisplay, type E2eDisplay } from './display';
|
||||||
|
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL, startFakeAnki, type FakeAnki } from './fake-anki';
|
||||||
|
import { buildFixtureDictionary, generateFixtureClip, writeFixtureSubtitles } from './fixtures';
|
||||||
|
import { connectMpv, startMpv, type MpvClient } from './mpv';
|
||||||
|
import { applyEnvDelta, stopProcess, type EnvDelta } from './process';
|
||||||
|
import { waitUntil } from './wait';
|
||||||
|
import {
|
||||||
|
shouldForceX11ElectronBackend,
|
||||||
|
X11_ELECTRON_BOOTSTRAP_ENV,
|
||||||
|
} from '../../src/core/utils/electron-backend';
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
/** URL fragments identifying the app's windows among the CDP targets. The modal
|
||||||
|
* window loads the same document with `layer=modal`, so the layer is required. */
|
||||||
|
export const OVERLAY_PAGE = 'renderer/index.html?layer=visible';
|
||||||
|
const YOMITAN_SETTINGS_PAGE = 'settings.html';
|
||||||
|
|
||||||
|
type JsonObject = { [key: string]: JsonValue };
|
||||||
|
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||||
|
|
||||||
|
export type E2eSessionOptions = {
|
||||||
|
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||||
|
config?: JsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppTarget = {
|
||||||
|
/** Environment changes that point a process at the isolated instance. */
|
||||||
|
env: EnvDelta;
|
||||||
|
/** Electron/Chromium switches every launch needs. */
|
||||||
|
electronArgs: string[];
|
||||||
|
/** Receives stdout and stderr of every launch. */
|
||||||
|
logPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type E2eSession = {
|
||||||
|
/** Temp dir holding the isolated config, fixtures, sockets, and logs. */
|
||||||
|
root: string;
|
||||||
|
display: E2eDisplay['kind'];
|
||||||
|
cdpPort: number;
|
||||||
|
/** DevTools endpoint of the Electron main process (Node inspector). */
|
||||||
|
inspectorUrl: string;
|
||||||
|
mpvSocketPath: string;
|
||||||
|
/** How to start a process that talks to this isolated instance. */
|
||||||
|
target: AppTarget;
|
||||||
|
mpv: MpvClient;
|
||||||
|
anki: FakeAnki;
|
||||||
|
/** Connects to the first window or frame whose URL contains `urlPart`, waiting for it to exist. */
|
||||||
|
openPage: (urlPart: string) => Promise<CdpPage>;
|
||||||
|
/** Evaluates in the main process; reach modules via `process.mainModule.require`. */
|
||||||
|
mainProcess: () => Promise<CdpPage>;
|
||||||
|
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
||||||
|
runAppCommand: (...args: string[]) => Promise<void>;
|
||||||
|
dispose: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: JsonObject, overrides: JsonObject): JsonObject {
|
||||||
|
const merged = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
const existing = merged[key];
|
||||||
|
merged[key] =
|
||||||
|
isJsonObject(existing) && isJsonObject(value) ? mergeConfig(existing, value) : value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything that could reach outside the sandbox (network services, the real
|
||||||
|
// Anki, ports the user's own SubMiner holds) is off or pointed at a fake.
|
||||||
|
function buildConfig(mpvSocketPath: string, ankiUrl: string): JsonObject {
|
||||||
|
return {
|
||||||
|
mpv: { socketPath: mpvSocketPath, aniskipEnabled: false },
|
||||||
|
ankiConnect: {
|
||||||
|
url: ankiUrl,
|
||||||
|
deck: FAKE_ANKI_DECK,
|
||||||
|
proxy: { enabled: false },
|
||||||
|
isLapis: { enabled: true, sentenceCardModel: FAKE_ANKI_MODEL },
|
||||||
|
},
|
||||||
|
stats: { autoStartServer: false },
|
||||||
|
updates: { enabled: false },
|
||||||
|
discordPresence: { enabled: false },
|
||||||
|
websocket: { enabled: false },
|
||||||
|
annotationWebsocket: { enabled: false },
|
||||||
|
texthooker: { launchAtStartup: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside Electron the package's entry point is the path of its binary, and
|
||||||
|
// resolving it downloads the binary when an install skipped that step (CI).
|
||||||
|
function resolveElectronBinary(): string {
|
||||||
|
const binary: unknown = require('electron');
|
||||||
|
if (typeof binary !== 'string') throw new Error('Could not resolve the Electron binary path');
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron announces its main-process inspector on stderr; `offset` skips earlier launches.
|
||||||
|
function readInspectorUrl(logPath: string, byteOffset: number): string | null {
|
||||||
|
const tail = fs.readFileSync(logPath).subarray(byteOffset).toString('utf8');
|
||||||
|
return /Debugger listening on (ws:\/\/\S+)/.exec(tail)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDevToolsPort(portFile: string): number | null {
|
||||||
|
try {
|
||||||
|
const port = Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]);
|
||||||
|
return port > 0 ? port : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnApp(target: AppTarget, electronArgs: string[], args: string[]): ChildProcess {
|
||||||
|
const log = fs.openSync(target.logPath, 'a');
|
||||||
|
const child = spawn(
|
||||||
|
resolveElectronBinary(),
|
||||||
|
[...target.electronArgs, ...electronArgs, repoRoot, ...args],
|
||||||
|
{ cwd: repoRoot, env: applyEnvDelta(process.env, target.env), stdio: ['ignore', log, log] },
|
||||||
|
);
|
||||||
|
fs.closeSync(log);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a CLI command against the running instance and waits for it to finish. */
|
||||||
|
export async function runAppCommand(target: AppTarget, args: string[]): Promise<void> {
|
||||||
|
const child = spawnApp(target, [], args);
|
||||||
|
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||||
|
const [code] = await once(child, 'exit');
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`SubMiner ${args.join(' ')} exited with ${String(code)}; see ${target.logPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
|
||||||
|
const target = await waitUntil(
|
||||||
|
async () =>
|
||||||
|
(await listCdpTargets(cdpPort)).find((candidate) => candidate.url.includes(urlPart)),
|
||||||
|
{ description: `app window matching "${urlPart}"`, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
const page = await connectCdpPage(target);
|
||||||
|
// A target is listed before its document exists; callers expect a usable DOM.
|
||||||
|
await page.waitFor(`document.readyState === "complete"`, {
|
||||||
|
description: `"${urlPart}" document to load`,
|
||||||
|
});
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
|
||||||
|
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
|
||||||
|
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-e2e-'));
|
||||||
|
const configHome = path.join(root, 'config');
|
||||||
|
const userDataDir = path.join(configHome, 'SubMiner');
|
||||||
|
const mediaPath = path.join(root, 'clip.mkv');
|
||||||
|
const subtitlePath = path.join(root, 'clip.srt');
|
||||||
|
const dictionaryPath = path.join(root, 'fixture-dictionary.zip');
|
||||||
|
const mpvSocketPath =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `\\\\.\\pipe\\${path.basename(root)}-mpv`
|
||||||
|
: path.join(root, 'mpv.sock');
|
||||||
|
|
||||||
|
// Teardown steps, run in reverse. Also used to unwind a failed startup.
|
||||||
|
const teardown: Array<() => Promise<void> | void> = [];
|
||||||
|
const dispose = async (): Promise<void> => {
|
||||||
|
for (const step of teardown.reverse()) await step();
|
||||||
|
teardown.length = 0;
|
||||||
|
if (process.env.SUBMINER_E2E_KEEP !== '1') {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
writeFixtureSubtitles(subtitlePath);
|
||||||
|
await Promise.all([generateFixtureClip(mediaPath), buildFixtureDictionary(dictionaryPath)]);
|
||||||
|
|
||||||
|
const display = await startDisplay();
|
||||||
|
teardown.push(display.stop);
|
||||||
|
const anki = await startFakeAnki();
|
||||||
|
teardown.push(anki.stop);
|
||||||
|
|
||||||
|
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||||
|
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const env: EnvDelta = {
|
||||||
|
set: {
|
||||||
|
...display.env.set,
|
||||||
|
// The app derives its config dir, Electron profile, and control socket from these.
|
||||||
|
...(process.platform === 'win32'
|
||||||
|
? { APPDATA: configHome }
|
||||||
|
: { XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: path.join(root, 'data') }),
|
||||||
|
SUBMINER_APP_LOG: path.join(root, 'app.log'),
|
||||||
|
// Stay attached instead of re-spawning detached, so the harness owns the process.
|
||||||
|
SUBMINER_BACKGROUND_CHILD: '1',
|
||||||
|
},
|
||||||
|
unset: [...display.env.unset, 'ELECTRON_RUN_AS_NODE'],
|
||||||
|
};
|
||||||
|
// --enable-logging routes renderer console output into the app log too.
|
||||||
|
const electronArgs = ['--password-store=basic', '--enable-logging=stderr'];
|
||||||
|
// Outside Hyprland/Sway the app re-spawns itself detached onto the X11
|
||||||
|
// backend. Start it there directly so the process we spawn is the app.
|
||||||
|
if (shouldForceX11ElectronBackend(applyEnvDelta(process.env, env))) {
|
||||||
|
env.set[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||||
|
electronArgs.push('--ozone-platform=x11');
|
||||||
|
}
|
||||||
|
// CI containers and runners lack the user namespaces Chromium's sandbox needs.
|
||||||
|
if (process.platform === 'linux' && process.env.CI) electronArgs.push('--no-sandbox');
|
||||||
|
const target: AppTarget = { env, electronArgs, logPath: path.join(root, 'app-stdio.log') };
|
||||||
|
|
||||||
|
const launchApp = async (
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ child: ChildProcess; cdpPort: number; inspectorUrl: string }> => {
|
||||||
|
// With port 0 Chromium and Node each pick a free port; Chromium records
|
||||||
|
// its port in the profile dir, Node prints its endpoint to stderr.
|
||||||
|
const portFile = path.join(userDataDir, 'DevToolsActivePort');
|
||||||
|
fs.rmSync(portFile, { force: true });
|
||||||
|
const logOffset = fs.existsSync(target.logPath) ? fs.statSync(target.logPath).size : 0;
|
||||||
|
const child = spawnApp(
|
||||||
|
target,
|
||||||
|
['--remote-debugging-port=0', '--inspect=0'],
|
||||||
|
[...args, '--log-level', 'debug'],
|
||||||
|
);
|
||||||
|
const stop = () => stopProcess(child);
|
||||||
|
teardown.push(stop);
|
||||||
|
const cdpPort = await waitUntil(
|
||||||
|
() => {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new Error(`SubMiner exited with code ${child.exitCode} during startup`);
|
||||||
|
}
|
||||||
|
return readDevToolsPort(portFile);
|
||||||
|
},
|
||||||
|
{ description: 'SubMiner DevTools port', timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
const inspectorUrl = await waitUntil(() => readInspectorUrl(target.logPath, logOffset), {
|
||||||
|
description: 'SubMiner main-process inspector endpoint',
|
||||||
|
});
|
||||||
|
return { child, cdpPort, inspectorUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
// First boot: import the fixture dictionary through Yomitan's settings page.
|
||||||
|
// Without one the tokenizer finds nothing and first-run setup takes over.
|
||||||
|
const seeder = await launchApp(['--yomitan']);
|
||||||
|
const settings = await openPage(seeder.cdpPort, YOMITAN_SETTINGS_PAGE);
|
||||||
|
await settings.waitFor('globalThis.__subminerYomitanSettingsAutomation?.ready === true', {
|
||||||
|
description: 'Yomitan settings automation bridge',
|
||||||
|
});
|
||||||
|
const archive = fs.readFileSync(dictionaryPath).toString('base64');
|
||||||
|
await settings.evaluate(
|
||||||
|
`globalThis.__subminerYomitanSettingsAutomation
|
||||||
|
.importDictionaryArchiveBase64(${JSON.stringify(archive)}, "fixture-dictionary.zip")
|
||||||
|
.then(() => true)`,
|
||||||
|
);
|
||||||
|
settings.close();
|
||||||
|
await stopProcess(seeder.child);
|
||||||
|
|
||||||
|
const mpvProcess = await startMpv({
|
||||||
|
socketPath: mpvSocketPath,
|
||||||
|
mediaPath,
|
||||||
|
subtitlePath,
|
||||||
|
logPath: path.join(root, 'mpv.log'),
|
||||||
|
display,
|
||||||
|
});
|
||||||
|
teardown.push(() => stopProcess(mpvProcess));
|
||||||
|
const mpv = await connectMpv(mpvSocketPath);
|
||||||
|
teardown.push(mpv.close);
|
||||||
|
|
||||||
|
const app = await launchApp(['--start']);
|
||||||
|
// mpv starts paused and the app resumes it once the overlay and tokenizer
|
||||||
|
// are ready, so that resume marks the end of startup.
|
||||||
|
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
|
||||||
|
description: 'SubMiner to resume playback after overlay startup',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
// Hand scenarios a paused player. The app retries its resume for a moment,
|
||||||
|
// so keep pausing until the pause has held for longer than that retry window.
|
||||||
|
let pausedSince: number | null = null;
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
if ((await mpv.command('get_property', 'pause')) !== true) {
|
||||||
|
pausedSince = null;
|
||||||
|
await mpv.command('set_property', 'pause', true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pausedSince ??= Date.now();
|
||||||
|
return Date.now() - pausedSince >= 1_500;
|
||||||
|
},
|
||||||
|
{ description: 'playback to stay paused after startup', timeoutMs: 40_000 },
|
||||||
|
);
|
||||||
|
const pages: CdpPage[] = [];
|
||||||
|
teardown.push(() => pages.forEach((page) => page.close()));
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
display: display.kind,
|
||||||
|
cdpPort: app.cdpPort,
|
||||||
|
inspectorUrl: app.inspectorUrl,
|
||||||
|
mpvSocketPath,
|
||||||
|
target,
|
||||||
|
mpv,
|
||||||
|
anki,
|
||||||
|
openPage: async (urlPart) => {
|
||||||
|
const page = await openPage(app.cdpPort, urlPart);
|
||||||
|
pages.push(page);
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
mainProcess: async () => {
|
||||||
|
const page = await connectCdp(app.inspectorUrl, 'main process');
|
||||||
|
pages.push(page);
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
runAppCommand: (...args) => runAppCommand(target, args),
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the logs: a failed startup is exactly when they are needed.
|
||||||
|
process.env.SUBMINER_E2E_KEEP = '1';
|
||||||
|
await dispose();
|
||||||
|
throw new Error(`E2E session failed to start (logs kept in ${root})`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type WaitOptions = {
|
||||||
|
description: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Truthy<T> = Exclude<T, false | 0 | '' | null | undefined>;
|
||||||
|
|
||||||
|
/** Polls `probe` until it returns a truthy value, then returns that value. */
|
||||||
|
export async function waitUntil<T>(
|
||||||
|
probe: () => T | Promise<T>,
|
||||||
|
{ description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions,
|
||||||
|
): Promise<Truthy<T>> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
const value = await probe();
|
||||||
|
if (value) return value as Truthy<T>;
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after } from 'node:test';
|
||||||
|
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
|
||||||
|
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
|
||||||
|
import { FIXTURE_CUES } from './harness/fixtures';
|
||||||
|
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||||
|
import { waitUntil } from './harness/wait';
|
||||||
|
|
||||||
|
type Started = { session: E2eSession; overlay: CdpPage };
|
||||||
|
|
||||||
|
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||||
|
// session boots inside whichever test runs first and every test budgets for it.
|
||||||
|
const TEST_TIMEOUT_MS = 120_000;
|
||||||
|
let started: Promise<Started> | undefined;
|
||||||
|
|
||||||
|
function useSession(): Promise<Started> {
|
||||||
|
started ??= (async () => {
|
||||||
|
const session = await startE2eSession();
|
||||||
|
try {
|
||||||
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||||
|
} catch (error) {
|
||||||
|
await session.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
const running = await started?.catch(() => undefined);
|
||||||
|
await running?.session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderedToken = { surface: string; headword: string | null };
|
||||||
|
|
||||||
|
// Parks playback inside a cue and waits for the overlay to show that line.
|
||||||
|
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||||
|
// One expression reads the line and its tokens together, so a re-render
|
||||||
|
// between two round trips cannot hand back a half-updated subtitle.
|
||||||
|
return overlay
|
||||||
|
.waitFor<RenderedToken[] | false>(
|
||||||
|
`(() => {
|
||||||
|
const root = document.getElementById('subtitleRoot');
|
||||||
|
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
|
||||||
|
surface: word.textContent,
|
||||||
|
headword: word.getAttribute('data-headword'),
|
||||||
|
}));
|
||||||
|
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
|
||||||
|
})()`,
|
||||||
|
{ description: `overlay to render "${cue.text}" tokenized` },
|
||||||
|
)
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
// Say what the overlay was showing, what main thinks the line is, and
|
||||||
|
// what the renderer logged, so a dropped update can be placed.
|
||||||
|
const state = await overlay.evaluate<string>(
|
||||||
|
`(async () => JSON.stringify({
|
||||||
|
visibility: document.visibilityState,
|
||||||
|
text: document.getElementById('subtitleRoot').textContent,
|
||||||
|
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
|
||||||
|
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
|
||||||
|
errorToast: document.getElementById('overlayErrorToast')?.textContent,
|
||||||
|
}))()`,
|
||||||
|
);
|
||||||
|
// Diagnostics must not replace the cue failure if the inspector is gone too.
|
||||||
|
const windows = await session
|
||||||
|
.mainProcess()
|
||||||
|
.then((main) =>
|
||||||
|
main.evaluate<string>(
|
||||||
|
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
title: w.getTitle(),
|
||||||
|
url: w.webContents.getURL().split('/').pop(),
|
||||||
|
visible: w.isVisible(),
|
||||||
|
loading: w.webContents.isLoading(),
|
||||||
|
documentLoaded: w.__subminerOverlayDocumentLoaded,
|
||||||
|
contentReady: w.__subminerOverlayContentReady,
|
||||||
|
})))`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
|
||||||
|
const targets = (await listCdpTargets(session.cdpPort))
|
||||||
|
.filter((t) => t.type === 'page')
|
||||||
|
.map((t) => t.url.split('/').pop());
|
||||||
|
const log = overlay.console.slice(-20).join('\n');
|
||||||
|
throw new Error(
|
||||||
|
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||||
|
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'overlay renders the current mpv subtitle line as dictionary tokens',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const tokens = await showCue(FIXTURE_CUES[0]);
|
||||||
|
|
||||||
|
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
|
||||||
|
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const tokens = await showCue(FIXTURE_CUES[2]);
|
||||||
|
|
||||||
|
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mining a sentence card sends the line with generated audio and image to Anki',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
const cue = FIXTURE_CUES[1];
|
||||||
|
await showCue(cue);
|
||||||
|
session.anki.requests.length = 0;
|
||||||
|
|
||||||
|
await session.runAppCommand('--mine-sentence');
|
||||||
|
const update = await waitUntil(
|
||||||
|
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||||
|
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||||
|
assert.equal(added.length, 1);
|
||||||
|
assert.partialDeepStrictEqual(added[0]?.params.note, {
|
||||||
|
deckName: FAKE_ANKI_DECK,
|
||||||
|
modelName: FAKE_ANKI_MODEL,
|
||||||
|
fields: { Sentence: cue.text },
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = session.anki.requests
|
||||||
|
.filter((request) => request.action === 'storeMediaFile')
|
||||||
|
.map((request) => String(request.params.filename));
|
||||||
|
assert.ok(
|
||||||
|
stored.some((name) => name.endsWith('.mp3')),
|
||||||
|
`no audio in ${stored.join(', ')}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
stored.some((name) => name.endsWith('.jpg')),
|
||||||
|
`no image in ${stored.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedFields = JSON.stringify(update.params);
|
||||||
|
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
|
test(
|
||||||
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
await showCue(FIXTURE_CUES[0]);
|
||||||
|
const word = await overlay.evaluate<{ x: number; y: number }>(
|
||||||
|
`(() => {
|
||||||
|
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
|
||||||
|
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
|
||||||
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
|
||||||
|
// so approach the word, hold Shift, then move across it.
|
||||||
|
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
||||||
|
const move = (x: number, modifiers = 0) =>
|
||||||
|
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
|
||||||
|
await move(word.x - 30);
|
||||||
|
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
|
||||||
|
await move(word.x - 2, 8);
|
||||||
|
await move(word.x, 8);
|
||||||
|
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
|
||||||
|
{ description: 'overlay to mark the Yomitan popup visible' },
|
||||||
|
);
|
||||||
|
// Yomitan keeps more than one popup frame around, so read whichever
|
||||||
|
// one holds the entry instead of trusting target order.
|
||||||
|
let seen: string[] = [];
|
||||||
|
const entry = await waitUntil(
|
||||||
|
async () => {
|
||||||
|
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
||||||
|
target.url.includes('popup.html'),
|
||||||
|
);
|
||||||
|
seen = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
// A frame can go away between listing and connecting; skip it.
|
||||||
|
const frame = await connectCdpPage(target).catch(() => undefined);
|
||||||
|
if (!frame) continue;
|
||||||
|
// textContent, not innerText: the entry counts once it is in the DOM,
|
||||||
|
// even where a GPU-less runner never lays the frame out.
|
||||||
|
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
|
||||||
|
frame.close();
|
||||||
|
seen.push(text);
|
||||||
|
}
|
||||||
|
return seen.find((text) => text.includes('weather'));
|
||||||
|
},
|
||||||
|
{ description: 'a popup frame to show the fixture dictionary entry' },
|
||||||
|
).catch((error: unknown) => {
|
||||||
|
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
|
||||||
|
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
|
||||||
|
});
|
||||||
|
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
|
||||||
|
assert.match(entry, /天気/);
|
||||||
|
},
|
||||||
|
);
|
||||||
+75
-68
@@ -1,7 +1,5 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
buildForwardedJellyfinAppArgs,
|
buildForwardedJellyfinAppArgs,
|
||||||
@@ -11,25 +9,16 @@ import {
|
|||||||
hasStoredJellyfinSession,
|
hasStoredJellyfinSession,
|
||||||
parseEpisodePathFromDisplay,
|
parseEpisodePathFromDisplay,
|
||||||
parseJellyfinErrorFromAppOutput,
|
parseJellyfinErrorFromAppOutput,
|
||||||
parseJellyfinItemsFromAppOutput,
|
parseJellyfinAppReply,
|
||||||
parseJellyfinLibrariesFromAppOutput,
|
parseJellyfinItemsReply,
|
||||||
|
parseJellyfinLibrariesReply,
|
||||||
parseJellyfinPreviewAuthResponse,
|
parseJellyfinPreviewAuthResponse,
|
||||||
readUtf8FileAppendedSince,
|
|
||||||
runJellyfinPlayMenuWithDeps,
|
runJellyfinPlayMenuWithDeps,
|
||||||
shouldRetryWithStartForNoRunningInstance,
|
shouldRetryWithStartForNoRunningInstance,
|
||||||
} from './jellyfin.js';
|
} from './jellyfin.js';
|
||||||
import { makeLauncherArgs } from './test-support/args.js';
|
import { makeLauncherArgs } from './test-support/args.js';
|
||||||
import { withEnv } from './test-support/env.js';
|
import { withEnv } from './test-support/env.js';
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
|
|
||||||
try {
|
|
||||||
return fn(dir);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HandoffComplete extends Error {}
|
class HandoffComplete extends Error {}
|
||||||
|
|
||||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||||
@@ -68,6 +57,16 @@ async function runPlayMenu(options: {
|
|||||||
handoff.appArgs = appArgs;
|
handoff.appArgs = appArgs;
|
||||||
throw new HandoffComplete();
|
throw new HandoffComplete();
|
||||||
},
|
},
|
||||||
|
isAppManagedByLauncher: () => false,
|
||||||
|
runAppCommandCaptureOutput: () => {
|
||||||
|
throw new Error('unexpected owned-app handoff');
|
||||||
|
},
|
||||||
|
waitForUnixSocketClosed: async () => {
|
||||||
|
throw new Error('unexpected wait for mpv to close');
|
||||||
|
},
|
||||||
|
exitProcess: () => {
|
||||||
|
throw new Error('unexpected exit');
|
||||||
|
},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
...options.overrides,
|
...options.overrides,
|
||||||
};
|
};
|
||||||
@@ -105,41 +104,71 @@ test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv'
|
|||||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
|
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
|
||||||
const parsed = parseJellyfinLibrariesFromAppOutput(`
|
const events: string[] = [];
|
||||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
|
const { appArgs } = await runPlayMenu({
|
||||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
|
socketReady: true,
|
||||||
`);
|
overrides: {
|
||||||
|
isAppManagedByLauncher: () => true,
|
||||||
|
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||||
|
events.push(`handoff ${appArgs.join(' ')}`);
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
waitForUnixSocketClosed: async (socketPath) => {
|
||||||
|
events.push(`wait ${socketPath}`);
|
||||||
|
},
|
||||||
|
exitProcess: (code) => {
|
||||||
|
events.push(`exit ${code}`);
|
||||||
|
throw new HandoffComplete();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
assert.deepEqual(parsed, [
|
assert.equal(appArgs, null);
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
|
||||||
|
'wait /tmp/subminer.sock',
|
||||||
|
'exit 0',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
|
||||||
|
const libraries = parseJellyfinAppReply(
|
||||||
|
JSON.stringify({
|
||||||
|
libraries: [
|
||||||
|
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
|
||||||
|
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
|
||||||
|
{ name: 'No id' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(libraries?.ok);
|
||||||
|
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
|
||||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const items = parseJellyfinAppReply(
|
||||||
|
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
|
||||||
|
);
|
||||||
|
assert.ok(items?.ok);
|
||||||
|
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
|
||||||
|
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
|
||||||
|
]);
|
||||||
|
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
test('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
|
||||||
const parsed = parseJellyfinItemsFromAppOutput(`
|
assert.deepEqual(
|
||||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
|
parseJellyfinAppReply(
|
||||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
|
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
|
||||||
`);
|
),
|
||||||
|
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
|
||||||
assert.deepEqual(parsed, [
|
);
|
||||||
{
|
assert.equal(parseJellyfinAppReply(''), null);
|
||||||
id: 'item-10',
|
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
|
||||||
name: 'Solo Leveling S01E10',
|
|
||||||
type: 'Episode',
|
|
||||||
display: 'Solo Leveling S01E10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'movie-1',
|
|
||||||
name: 'Movie [Alt]',
|
|
||||||
type: 'Movie',
|
|
||||||
display: 'Movie [Alt]',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
|
||||||
const forwarded = buildForwardedJellyfinAppArgs(
|
const forwarded = buildForwardedJellyfinAppArgs(
|
||||||
{
|
{
|
||||||
jellyfinServer: 'https://jf.example.test/',
|
jellyfinServer: 'https://jf.example.test/',
|
||||||
@@ -184,13 +213,11 @@ test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||||
const parsed = parseJellyfinPreviewAuthResponse(
|
const parsed = parseJellyfinPreviewAuthResponse({
|
||||||
JSON.stringify({
|
|
||||||
serverUrl: 'http://pve-main:8096/',
|
serverUrl: 'http://pve-main:8096/',
|
||||||
accessToken: 'token-123',
|
accessToken: 'token-123',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(parsed, {
|
assert.deepEqual(parsed, {
|
||||||
serverUrl: 'http://pve-main:8096',
|
serverUrl: 'http://pve-main:8096',
|
||||||
@@ -199,17 +226,13 @@ test('parseJellyfinPreviewAuthResponse parses valid structured response payload'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
test('parseJellyfinPreviewAuthResponse returns null without a token', () => {
|
||||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
|
||||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseJellyfinPreviewAuthResponse(
|
parseJellyfinPreviewAuthResponse({
|
||||||
JSON.stringify({
|
|
||||||
serverUrl: 'http://pve-main:8096',
|
serverUrl: 'http://pve-main:8096',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -238,22 +261,6 @@ test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle er
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const logPath = path.join(root, 'SubMiner.log');
|
|
||||||
const prefix = '[subminer] こんにちは\n';
|
|
||||||
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
|
|
||||||
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
|
|
||||||
|
|
||||||
const byteOffset = Buffer.byteLength(prefix, 'utf8');
|
|
||||||
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
|
|
||||||
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
|
||||||
|
|
||||||
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
|
|
||||||
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
||||||
|
|||||||
+192
-219
@@ -10,7 +10,8 @@ import type {
|
|||||||
JellyfinGroupEntry,
|
JellyfinGroupEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { shouldForwardLogLevel } from './types.js';
|
import { shouldForwardLogLevel } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js';
|
||||||
|
import { log, fail } from './log.js';
|
||||||
import { nowMs } from './time.js';
|
import { nowMs } from './time.js';
|
||||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +24,14 @@ import {
|
|||||||
import { loadLauncherJellyfinConfig } from './config.js';
|
import { loadLauncherJellyfinConfig } from './config.js';
|
||||||
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
|
||||||
import {
|
import {
|
||||||
|
adoptLauncherStartedApp,
|
||||||
|
isAppManagedByLauncher,
|
||||||
runAppCommandWithInheritLogged,
|
runAppCommandWithInheritLogged,
|
||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
launchAppStartDetached,
|
launchAppBackgroundDetached,
|
||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
|
waitForUnixSocketClosed,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from './mpv.js';
|
} from './mpv.js';
|
||||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||||
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
|
|||||||
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||||
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||||
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||||
|
isAppManagedByLauncher: typeof isAppManagedByLauncher;
|
||||||
|
runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput;
|
||||||
|
waitForUnixSocketClosed: typeof waitForUnixSocketClosed;
|
||||||
|
exitProcess: (code: number) => never;
|
||||||
log: typeof log;
|
log: typeof log;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
|||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
runAppCommandWithInheritLogged,
|
runAppCommandWithInheritLogged,
|
||||||
|
isAppManagedByLauncher,
|
||||||
|
runAppCommandCaptureOutput,
|
||||||
|
waitForUnixSocketClosed,
|
||||||
|
exitProcess: (code) => process.exit(code),
|
||||||
log,
|
log,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,76 +183,77 @@ function stripAnsi(value: string): string {
|
|||||||
return value.replace(ANSI_ESCAPE_PATTERN, '');
|
return value.replace(ANSI_ESCAPE_PATTERN, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNamedJellyfinRecord(payload: string): {
|
// Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts.
|
||||||
name: string;
|
export type JellyfinAppReply =
|
||||||
id: string;
|
| { ok: true; payload: Record<string, unknown> }
|
||||||
type: string;
|
| { ok: false; error: string };
|
||||||
} | null {
|
|
||||||
const typeClose = payload.lastIndexOf(')');
|
|
||||||
if (typeClose !== payload.length - 1) return null;
|
|
||||||
|
|
||||||
const typeOpen = payload.lastIndexOf(' (');
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
const idClose = payload.lastIndexOf(']', typeOpen);
|
|
||||||
if (idClose <= 0) return null;
|
|
||||||
|
|
||||||
const idOpen = payload.lastIndexOf(' [', idClose);
|
|
||||||
if (idOpen <= 0 || idOpen >= idClose) return null;
|
|
||||||
|
|
||||||
const name = payload.slice(0, idOpen).trim();
|
|
||||||
const id = payload.slice(idOpen + 2, idClose).trim();
|
|
||||||
const type = payload.slice(typeOpen + 2, typeClose).trim();
|
|
||||||
if (!name || !id || !type) return null;
|
|
||||||
|
|
||||||
return { name, id, type };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
|
function readString(record: Record<string, unknown>, key: string): string {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(parsed)) return null;
|
||||||
|
const error = readString(parsed, 'error');
|
||||||
|
if (error) return { ok: false, error: normalizeJellyfinAppError(error) };
|
||||||
|
return { ok: true, payload: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJellyfinLibrariesReply(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): JellyfinLibraryEntry[] | null {
|
||||||
|
if (!Array.isArray(payload.libraries)) return null;
|
||||||
const libraries: JellyfinLibraryEntry[] = [];
|
const libraries: JellyfinLibraryEntry[] = [];
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
for (const entry of payload.libraries) {
|
||||||
for (const rawLine of output.split(/\r?\n/)) {
|
if (!isRecord(entry)) continue;
|
||||||
const line = stripAnsi(rawLine);
|
const id = readString(entry, 'id');
|
||||||
const markerIndex = line.indexOf('Jellyfin library:');
|
if (!id || seenIds.has(id)) continue;
|
||||||
if (markerIndex < 0) continue;
|
seenIds.add(id);
|
||||||
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
|
|
||||||
const parsed = parseNamedJellyfinRecord(payload);
|
|
||||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
|
||||||
seenIds.add(parsed.id);
|
|
||||||
libraries.push({
|
libraries.push({
|
||||||
id: parsed.id,
|
id,
|
||||||
name: parsed.name,
|
name: readString(entry, 'name') || 'Untitled',
|
||||||
kind: parsed.type,
|
kind: readString(entry, 'collectionType') || 'unknown',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return libraries;
|
return libraries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
|
export function parseJellyfinItemsReply(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): JellyfinItemEntry[] | null {
|
||||||
|
if (!Array.isArray(payload.items)) return null;
|
||||||
const items: JellyfinItemEntry[] = [];
|
const items: JellyfinItemEntry[] = [];
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
for (const entry of payload.items) {
|
||||||
for (const rawLine of output.split(/\r?\n/)) {
|
if (!isRecord(entry)) continue;
|
||||||
const line = stripAnsi(rawLine);
|
const id = readString(entry, 'id');
|
||||||
const markerIndex = line.indexOf('Jellyfin item:');
|
if (!id || seenIds.has(id)) continue;
|
||||||
if (markerIndex < 0) continue;
|
seenIds.add(id);
|
||||||
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
|
const title = readString(entry, 'title') || 'Untitled';
|
||||||
const parsed = parseNamedJellyfinRecord(payload);
|
items.push({ id, name: title, type: readString(entry, 'type'), display: title });
|
||||||
if (!parsed || seenIds.has(parsed.id)) continue;
|
|
||||||
seenIds.add(parsed.id);
|
|
||||||
items.push({
|
|
||||||
id: parsed.id,
|
|
||||||
name: parsed.name,
|
|
||||||
type: parsed.type,
|
|
||||||
display: parsed.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeJellyfinAppError(message: string): string {
|
||||||
|
if (message.includes('Missing Jellyfin session')) {
|
||||||
|
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseJellyfinErrorFromAppOutput(output: string): string {
|
export function parseJellyfinErrorFromAppOutput(output: string): string {
|
||||||
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
|
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
|
||||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||||
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (line.includes('Missing Jellyfin session')) {
|
if (line.includes('Missing Jellyfin session')) {
|
||||||
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
|
return normalizeJellyfinAppError(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
type JellyfinPreviewAuthResponse = {
|
export function parseJellyfinPreviewAuthResponse(
|
||||||
serverUrl: string;
|
payload: Record<string, unknown>,
|
||||||
accessToken: string;
|
): JellyfinPreviewAuthPayload | null {
|
||||||
userId: string;
|
const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl'));
|
||||||
};
|
const accessToken = readString(payload, 'accessToken');
|
||||||
|
const userId = readString(payload, 'userId');
|
||||||
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
|
|
||||||
if (!raw || raw.trim().length === 0) return null;
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== 'object') return null;
|
|
||||||
|
|
||||||
const candidate = parsed as Record<string, unknown>;
|
|
||||||
const serverUrl = sanitizeServerUrl(
|
|
||||||
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
|
|
||||||
);
|
|
||||||
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
|
|
||||||
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
|
|
||||||
if (!serverUrl || !accessToken) return null;
|
if (!serverUrl || !accessToken) return null;
|
||||||
|
return { serverUrl, accessToken, userId };
|
||||||
return {
|
|
||||||
serverUrl,
|
|
||||||
accessToken,
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
|
||||||
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
|
|||||||
return exists(deriveJellyfinTokenStorePath(configPath));
|
return exists(deriveJellyfinTokenStorePath(configPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
|
|
||||||
try {
|
|
||||||
const buffer = fs.readFileSync(logPath);
|
|
||||||
if (buffer.length === 0) return '';
|
|
||||||
const normalizedOffset =
|
|
||||||
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
|
|
||||||
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
|
|
||||||
return buffer.subarray(startOffset).toString('utf8');
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEpisodePathFromDisplay(
|
export function parseEpisodePathFromDisplay(
|
||||||
display: string,
|
display: string,
|
||||||
): { seriesName: string; seasonNumber: number } | null {
|
): { seriesName: string; seasonNumber: number } | null {
|
||||||
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
|
|||||||
return forwarded;
|
return forwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAppJellyfinListCommand(
|
const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000;
|
||||||
appPath: string,
|
const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000;
|
||||||
args: Args,
|
|
||||||
appArgs: string[],
|
function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null {
|
||||||
label: string,
|
try {
|
||||||
): Promise<string> {
|
return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8'));
|
||||||
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
|
} catch {
|
||||||
if (attempt.status !== 0) {
|
return null;
|
||||||
const message = attempt.output.trim();
|
|
||||||
fail(message || `${label} failed.`);
|
|
||||||
}
|
}
|
||||||
if (attempt.error) {
|
|
||||||
fail(attempt.error);
|
|
||||||
}
|
|
||||||
return attempt.output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAppJellyfinCommand(
|
// Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper
|
||||||
|
// process usually hands the command to an already-running instance and exits at once with no
|
||||||
|
// output, so the reply file is the only result channel that works in both cases.
|
||||||
|
async function requestJellyfinReplyFromApp(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
args: Args,
|
args: Args,
|
||||||
appArgs: string[],
|
appArgs: string[],
|
||||||
label: string,
|
label: string,
|
||||||
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
|
timeoutMs: number,
|
||||||
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
|
): Promise<JellyfinAppReply> {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-'));
|
||||||
|
const responsePath = path.join(tmpDir, 'response.json');
|
||||||
|
const forwarded = buildForwardedJellyfinAppArgs(args, [
|
||||||
|
...appArgs,
|
||||||
|
`--jellyfin-response-path=${responsePath}`,
|
||||||
|
]);
|
||||||
|
|
||||||
const readLogAppendedSince = (offset: number): string => {
|
const runOnce = (): { status: number; output: string; error: string } => {
|
||||||
const logPath = getMpvLogPath();
|
|
||||||
return readUtf8FileAppendedSince(logPath, offset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasCommandSignal = (output: string): boolean => {
|
|
||||||
if (label === 'jellyfin-libraries') {
|
|
||||||
return (
|
|
||||||
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (label === 'jellyfin-items') {
|
|
||||||
return (
|
|
||||||
output.includes('Jellyfin item:') ||
|
|
||||||
output.includes('No Jellyfin items found for the selected library/search.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (label === 'jellyfin-preview-auth') {
|
|
||||||
return output.includes('Jellyfin preview auth written.');
|
|
||||||
}
|
|
||||||
return output.trim().length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
|
|
||||||
const forwarded = [...forwardedBase];
|
|
||||||
const logPath = getMpvLogPath();
|
|
||||||
let logOffset = 0;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(logPath)) {
|
|
||||||
logOffset = fs.statSync(logPath).size;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logOffset = 0;
|
|
||||||
}
|
|
||||||
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
|
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
|
||||||
const result = runAppCommandCaptureOutput(appPath, forwarded);
|
const result = runAppCommandCaptureOutput(appPath, forwarded);
|
||||||
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
|
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
|
||||||
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
|
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||||
let error = parseJellyfinErrorFromAppOutput(output);
|
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
|
||||||
|
|
||||||
return { status: result.status, output, error, logOffset };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let retriedAfterStart = false;
|
try {
|
||||||
let attempt = runOnce();
|
let attempt = runOnce();
|
||||||
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
|
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
|
||||||
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
|
// Start it the way `subminer app` does: the app outlives this picker and the playback, so it
|
||||||
launchAppStartDetached(appPath, args.logLevel);
|
// needs its tray to be visible and quittable.
|
||||||
await sleep(1000);
|
log(
|
||||||
retriedAfterStart = true;
|
'debug',
|
||||||
attempt = runOnce();
|
args.logLevel,
|
||||||
}
|
`${label}: starting app in the background, then retrying command`,
|
||||||
|
|
||||||
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
|
|
||||||
// When app is already running, command handling happens in the primary process and log
|
|
||||||
// lines can land slightly after the helper process exits.
|
|
||||||
const settleWindowMs = (() => {
|
|
||||||
if (label === 'jellyfin-items') {
|
|
||||||
return retriedAfterStart ? 45000 : 30000;
|
|
||||||
}
|
|
||||||
return retriedAfterStart ? 12000 : 4000;
|
|
||||||
})();
|
|
||||||
const settleDeadline = nowMs() + settleWindowMs;
|
|
||||||
const settleOffset = attempt.logOffset;
|
|
||||||
while (nowMs() < settleDeadline) {
|
|
||||||
await sleep(100);
|
|
||||||
const settledOutput = readLogAppendedSince(settleOffset);
|
|
||||||
if (!settledOutput.trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
attempt.output = `${attempt.output}\n${settledOutput}`;
|
|
||||||
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
|
|
||||||
if (attempt.error || hasCommandSignal(attempt.output)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return attempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJellyfinPreviewAuthFromApp(
|
|
||||||
appPath: string,
|
|
||||||
args: Args,
|
|
||||||
): Promise<JellyfinPreviewAuthResponse | null> {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
|
|
||||||
const responsePath = path.join(tmpDir, 'response.json');
|
|
||||||
try {
|
|
||||||
const attempt = await runAppJellyfinCommand(
|
|
||||||
appPath,
|
|
||||||
args,
|
|
||||||
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
|
|
||||||
'jellyfin-preview-auth',
|
|
||||||
);
|
);
|
||||||
if (attempt.status !== 0 || attempt.error) {
|
launchAppBackgroundDetached(appPath, args.logLevel);
|
||||||
return null;
|
adoptLauncherStartedApp(appPath, args);
|
||||||
|
// A cold start (an AppImage mount, first-run setup) can take a while to claim the instance
|
||||||
|
// lock, so keep retrying until the app takes the command or the request deadline passes.
|
||||||
|
const startDeadline = nowMs() + timeoutMs;
|
||||||
|
do {
|
||||||
|
await sleep(1000);
|
||||||
|
attempt = runOnce();
|
||||||
|
} while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline);
|
||||||
|
}
|
||||||
|
// A written reply is authoritative; the exit status and log lines only explain a missing one.
|
||||||
|
const earlyReply = readJellyfinAppReply(responsePath);
|
||||||
|
if (earlyReply) return earlyReply;
|
||||||
|
if (attempt.status !== 0) {
|
||||||
|
return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` };
|
||||||
|
}
|
||||||
|
if (attempt.error) {
|
||||||
|
return { ok: false, error: attempt.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadline = nowMs() + 4000;
|
const deadline = nowMs() + timeoutMs;
|
||||||
while (nowMs() < deadline) {
|
while (nowMs() < deadline) {
|
||||||
try {
|
const reply = readJellyfinAppReply(responsePath);
|
||||||
if (fs.existsSync(responsePath)) {
|
if (reply) return reply;
|
||||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
|
||||||
const parsed = parseJellyfinPreviewAuthResponse(raw);
|
|
||||||
if (parsed) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// retry until timeout
|
|
||||||
}
|
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
return null;
|
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestJellyfinPreviewAuthFromApp(
|
||||||
|
appPath: string,
|
||||||
|
args: Args,
|
||||||
|
): Promise<JellyfinPreviewAuthPayload | null> {
|
||||||
|
const reply = await requestJellyfinReplyFromApp(
|
||||||
|
appPath,
|
||||||
|
args,
|
||||||
|
['--jellyfin-preview-auth'],
|
||||||
|
'jellyfin-preview-auth',
|
||||||
|
JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!reply.ok) {
|
||||||
|
log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseJellyfinPreviewAuthResponse(reply.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJellyfinListFromApp<T>(
|
||||||
|
appPath: string,
|
||||||
|
args: Args,
|
||||||
|
appArgs: string[],
|
||||||
|
label: string,
|
||||||
|
parse: (payload: Record<string, unknown>) => T[] | null,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const reply = await requestJellyfinReplyFromApp(
|
||||||
|
appPath,
|
||||||
|
args,
|
||||||
|
appArgs,
|
||||||
|
label,
|
||||||
|
JELLYFIN_APP_REPLY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!reply.ok) fail(reply.error);
|
||||||
|
const entries = parse(reply.payload);
|
||||||
|
if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveJellyfinSelectionViaApp(
|
async function resolveJellyfinSelectionViaApp(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
args: Args,
|
args: Args,
|
||||||
session: JellyfinSessionConfig,
|
session: JellyfinSessionConfig,
|
||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const listLibrariesOutput = await runAppJellyfinListCommand(
|
const libraries = await requestJellyfinListFromApp(
|
||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
['--jellyfin-libraries'],
|
['--jellyfin-libraries'],
|
||||||
'jellyfin-libraries',
|
'jellyfin-libraries',
|
||||||
|
parseJellyfinLibrariesReply,
|
||||||
);
|
);
|
||||||
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
|
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
fail('No Jellyfin libraries found.');
|
fail('No Jellyfin libraries found.');
|
||||||
}
|
}
|
||||||
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
|
|||||||
if (includeItemTypes) {
|
if (includeItemTypes) {
|
||||||
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
|
||||||
}
|
}
|
||||||
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
|
return requestJellyfinListFromApp(
|
||||||
return parseJellyfinItemsFromAppOutput(output);
|
appPath,
|
||||||
|
args,
|
||||||
|
itemArgs,
|
||||||
|
'jellyfin-items',
|
||||||
|
parseJellyfinItemsReply,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let rootItems =
|
let rootItems =
|
||||||
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
|
|||||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
|
if (!deps.isAppManagedByLauncher()) {
|
||||||
|
// A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app
|
||||||
|
// quit with mpv on its own.
|
||||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
|
}
|
||||||
|
// The picker started the app, so this playback owns it, as with local playback: hand over the
|
||||||
|
// item, wait for mpv to close, then exit, which stops the app.
|
||||||
|
const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded);
|
||||||
|
if (handoff.status !== 0) {
|
||||||
|
fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.');
|
||||||
|
}
|
||||||
|
deps.log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'jellyfin-play: waiting for mpv to close before stopping the app',
|
||||||
|
);
|
||||||
|
await deps.waitForUnixSocketClosed(mpvSocketPath);
|
||||||
|
return deps.exitProcess(0);
|
||||||
}
|
}
|
||||||
@@ -1121,6 +1121,21 @@ function clearOverlayManagedByLauncher(): void {
|
|||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For commands that start the app ahead of playback (the Jellyfin picker): the launcher now owns
|
||||||
|
// it, as with local playback, and stops it when the launcher exits for any reason, including a
|
||||||
|
// cancelled picker or Ctrl+C.
|
||||||
|
export function adoptLauncherStartedApp(appPath: string, args: Args): void {
|
||||||
|
if (state.overlayManagedByLauncher) return;
|
||||||
|
markOverlayManagedByLauncher(appPath);
|
||||||
|
process.once('exit', () => stopManagedOverlayApp(args));
|
||||||
|
process.once('SIGINT', () => process.exit(130));
|
||||||
|
process.once('SIGTERM', () => process.exit(143));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppManagedByLauncher(): boolean {
|
||||||
|
return state.overlayManagedByLauncher;
|
||||||
|
}
|
||||||
|
|
||||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -1723,6 +1738,13 @@ export async function canConnectUnixSocket(socketPath: string): Promise<boolean>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves once mpv stops accepting connections on its IPC socket, i.e. the player was closed.
|
||||||
|
export async function waitForUnixSocketClosed(socketPath: string, pollMs = 1000): Promise<void> {
|
||||||
|
while (await canConnectUnixSocket(socketPath)) {
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForUnixSocketReady(
|
export async function waitForUnixSocketReady(
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
|||||||
+19
-3
@@ -119,11 +119,27 @@ export async function promptOptionalJellyfinSearch(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (useRofi && commandExists('rofi')) {
|
if (useRofi && commandExists('rofi')) {
|
||||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
// Input-only prompt: the shipped theme pins a tall window and has no message widget, so
|
||||||
|
// drop the listview (an empty row there reads as "no results") and show the hint instead.
|
||||||
|
const inputOnlyTheme =
|
||||||
|
'window { height: 0; } mainbox { children: [inputbar, message]; } ' +
|
||||||
|
'message { background-color: transparent; padding: 10px 18px 4px 18px; } ' +
|
||||||
|
'textbox { background-color: transparent; }';
|
||||||
|
const rofiArgs = [
|
||||||
|
'-dmenu',
|
||||||
|
'-i',
|
||||||
|
'-p',
|
||||||
|
formatRofiPrompt('Jellyfin Search (optional)'),
|
||||||
|
'-mesg',
|
||||||
|
'Type to search, or press Enter to browse all',
|
||||||
|
];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath, '-theme-str', inputOnlyTheme);
|
||||||
} else {
|
} else {
|
||||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
rofiArgs.push(
|
||||||
|
'-theme-str',
|
||||||
|
`configuration { font: "Noto Sans CJK JP Regular 8";} ${inputOnlyTheme}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const result = spawnSync('rofi', rofiArgs, {
|
const result = spawnSync('rofi', rofiArgs, {
|
||||||
input: '\n',
|
input: '\n',
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||||
|
"test:e2e": "bun scripts/run-test-lane.mjs e2e --jobs 1",
|
||||||
|
"e2e": "bun e2e/cli.ts",
|
||||||
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||||
"test:runtime:compat": "bun run test:smoke:dist",
|
"test:runtime:compat": "bun run test:smoke:dist",
|
||||||
"test": "bun run test:fast",
|
"test": "bun run test:fast",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ paths=(
|
|||||||
".prettierrc.json"
|
".prettierrc.json"
|
||||||
".github"
|
".github"
|
||||||
"build"
|
"build"
|
||||||
|
"e2e"
|
||||||
"launcher"
|
"launcher"
|
||||||
"scripts"
|
"scripts"
|
||||||
"src"
|
"src"
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export const testLanes: Record<string, TestLane> = {
|
|||||||
roots: ['scripts'],
|
roots: ['scripts'],
|
||||||
include: ['.test.ts'],
|
include: ['.test.ts'],
|
||||||
},
|
},
|
||||||
|
// Boots the built app, mpv, and a fake AnkiConnect on an offscreen display.
|
||||||
|
// Slow and dependent on mpv/ffmpeg/Xvfb, so it stays out of test:fast.
|
||||||
|
e2e: {
|
||||||
|
roots: ['e2e'],
|
||||||
|
include: ['.e2e.test.ts'],
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
roots: ['stats/src'],
|
roots: ['stats/src'],
|
||||||
include: ['.test.ts', '.test.tsx'],
|
include: ['.test.ts', '.test.tsx'],
|
||||||
|
|||||||
@@ -929,9 +929,10 @@ test('handleCliCommand runs jellyfin command dispatcher', async () => {
|
|||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps);
|
||||||
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps);
|
||||||
|
handleCliCommand(makeArgs({ jellyfinPreviewAuth: true }), 'second-instance', deps);
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
|
const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length;
|
||||||
assert.equal(runJellyfinCallCount, 2);
|
assert.equal(runJellyfinCallCount, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
|
test('handleCliCommand reports jellyfin command errors to OSD', async () => {
|
||||||
|
|||||||
@@ -771,7 +771,8 @@ export function handleCliCommand(
|
|||||||
args.jellyfinItems ||
|
args.jellyfinItems ||
|
||||||
args.jellyfinSubtitles ||
|
args.jellyfinSubtitles ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.jellyfinRemoteAnnounce
|
args.jellyfinRemoteAnnounce ||
|
||||||
|
args.jellyfinPreviewAuth
|
||||||
) {
|
) {
|
||||||
runAsyncWithOsd(
|
runAsyncWithOsd(
|
||||||
() => deps.runJellyfinCommand(args),
|
() => deps.runJellyfinCommand(args),
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ export {
|
|||||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||||
} from './jellyfin';
|
} from './jellyfin';
|
||||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
export { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||||
|
export {
|
||||||
|
buildJellyfinTimelinePayload,
|
||||||
|
JellyfinPlaybackReporter,
|
||||||
|
} from './jellyfin-playback-reporter';
|
||||||
export {
|
export {
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
broadcastRuntimeOptionsChangedRuntime,
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildJellyfinTimelinePayload,
|
||||||
|
JellyfinPlaybackReporter,
|
||||||
|
} from './jellyfin-playback-reporter';
|
||||||
|
|
||||||
|
type FetchCall = { input: string; init: RequestInit };
|
||||||
|
|
||||||
|
function createReporter(respond: (input: string) => Response, logWarn?: (message: string) => void) {
|
||||||
|
const calls: FetchCall[] = [];
|
||||||
|
const reporter = new JellyfinPlaybackReporter({
|
||||||
|
serverUrl: 'http://jellyfin.local/',
|
||||||
|
accessToken: 'token-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
clientName: 'SubMiner',
|
||||||
|
clientVersion: '1.2.3',
|
||||||
|
deviceName: 'host',
|
||||||
|
fetchImpl: (async (input, init) => {
|
||||||
|
calls.push({ input: String(input), init: init ?? {} });
|
||||||
|
return respond(String(input));
|
||||||
|
}) as typeof fetch,
|
||||||
|
logWarn,
|
||||||
|
});
|
||||||
|
return { reporter, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reportProgress posts the timeline payload as this device and treats failure as non-fatal', async () => {
|
||||||
|
let fail = false;
|
||||||
|
const { reporter, calls } = createReporter(
|
||||||
|
() => new Response(fail ? 'boom' : null, { status: fail ? 500 : 200 }),
|
||||||
|
);
|
||||||
|
const state = {
|
||||||
|
itemId: 'movie-2',
|
||||||
|
positionTicks: 123456,
|
||||||
|
isPaused: true,
|
||||||
|
volumeLevel: 33,
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
subtitleStreamIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await reporter.reportProgress(state), true);
|
||||||
|
fail = true;
|
||||||
|
assert.equal(await reporter.reportProgress({ itemId: 'movie-2', positionTicks: 999 }), false);
|
||||||
|
|
||||||
|
const call = calls[0]!;
|
||||||
|
assert.equal(call.input, 'http://jellyfin.local/Sessions/Playing/Progress');
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(String(call.init.body)),
|
||||||
|
JSON.parse(JSON.stringify(buildJellyfinTimelinePayload(state))),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(call.init.headers as Record<string, string>).Authorization,
|
||||||
|
'MediaBrowser Client="SubMiner", Device="host", DeviceId="device-1", Version="1.2.3", Token="token-1"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeline payload omits websocket-only event names', () => {
|
||||||
|
const payload = buildJellyfinTimelinePayload({
|
||||||
|
itemId: 'movie-2',
|
||||||
|
positionTicks: 123456,
|
||||||
|
eventName: 'TimeUpdate',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal('EventName' in payload, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||||
|
const { reporter, calls } = createReporter(() => new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await reporter.reportStopped({ itemId: 'movie-stop', positionTicks: 7654321 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls[0]!.input, 'http://jellyfin.local/Sessions/Playing/Stopped');
|
||||||
|
const posted = JSON.parse(String(calls[0]!.init.body));
|
||||||
|
assert.equal(posted.PositionTicks, 7654321);
|
||||||
|
assert.equal(posted.Failed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns once per failing endpoint until it recovers', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let status = 400;
|
||||||
|
const { reporter } = createReporter(
|
||||||
|
() => new Response(null, { status }),
|
||||||
|
(message) => warnings.push(message),
|
||||||
|
);
|
||||||
|
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||||
|
|
||||||
|
assert.equal(await reporter.reportStopped(state), false);
|
||||||
|
assert.equal(await reporter.reportStopped(state), false);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||||
|
|
||||||
|
status = 200;
|
||||||
|
assert.equal(await reporter.reportStopped(state), true);
|
||||||
|
status = 500;
|
||||||
|
assert.equal(await reporter.reportStopped(state), false);
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
export interface JellyfinTimelinePlaybackState {
|
||||||
|
itemId: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
positionTicks?: number;
|
||||||
|
playbackStartTimeTicks?: number;
|
||||||
|
isPaused?: boolean;
|
||||||
|
isMuted?: boolean;
|
||||||
|
canSeek?: boolean;
|
||||||
|
volumeLevel?: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
playMethod?: string;
|
||||||
|
audioStreamIndex?: number | null;
|
||||||
|
subtitleStreamIndex?: number | null;
|
||||||
|
playlistItemId?: string | null;
|
||||||
|
eventName?: string;
|
||||||
|
failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinTimelinePayload {
|
||||||
|
ItemId: string;
|
||||||
|
MediaSourceId?: string;
|
||||||
|
PositionTicks: number;
|
||||||
|
PlaybackStartTimeTicks: number;
|
||||||
|
IsPaused: boolean;
|
||||||
|
IsMuted: boolean;
|
||||||
|
CanSeek: boolean;
|
||||||
|
VolumeLevel: number;
|
||||||
|
PlaybackRate: number;
|
||||||
|
PlayMethod: string;
|
||||||
|
AudioStreamIndex?: number | null;
|
||||||
|
SubtitleStreamIndex?: number | null;
|
||||||
|
PlaylistItemId?: string | null;
|
||||||
|
Failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinPlaybackReporterOptions {
|
||||||
|
serverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
deviceId: string;
|
||||||
|
clientName?: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
logWarn?: (message: string, details?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JELLYFIN_REPORT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
function clampVolume(value: number | undefined): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTicks(value: number | undefined): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNullableInteger(value: number | null | undefined): number | null {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJellyfinTimelinePayload(
|
||||||
|
state: JellyfinTimelinePlaybackState,
|
||||||
|
): JellyfinTimelinePayload {
|
||||||
|
return {
|
||||||
|
ItemId: state.itemId,
|
||||||
|
MediaSourceId: state.mediaSourceId,
|
||||||
|
PositionTicks: normalizeTicks(state.positionTicks),
|
||||||
|
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
||||||
|
IsPaused: state.isPaused === true,
|
||||||
|
IsMuted: state.isMuted === true,
|
||||||
|
CanSeek: state.canSeek !== false,
|
||||||
|
VolumeLevel: clampVolume(state.volumeLevel),
|
||||||
|
PlaybackRate:
|
||||||
|
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
||||||
|
? state.playbackRate
|
||||||
|
: 1,
|
||||||
|
PlayMethod: state.playMethod || 'DirectPlay',
|
||||||
|
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||||
|
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||||
|
PlaylistItemId: state.playlistItemId,
|
||||||
|
Failed: state.failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated JSON POSTs as this device. Playback timeline reports (start, progress, stop)
|
||||||
|
// are plain HTTP, so they work whether or not the cast-control websocket is connected; that
|
||||||
|
// is how Jellyfin learns the resume position and marks items played for every playback.
|
||||||
|
export class JellyfinPlaybackReporter {
|
||||||
|
readonly authHeader: string;
|
||||||
|
private readonly serverUrl: string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||||
|
private readonly failedRequestPaths = new Set<string>();
|
||||||
|
|
||||||
|
constructor(options: JellyfinPlaybackReporterOptions) {
|
||||||
|
this.serverUrl = options.serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
this.logWarn = options.logWarn;
|
||||||
|
const clientName = options.clientName || 'SubMiner';
|
||||||
|
const clientVersion = options.clientVersion || '0.1.0';
|
||||||
|
const deviceName = options.deviceName || clientName;
|
||||||
|
this.authHeader = `MediaBrowser Client="${clientName}", Device="${deviceName}", DeviceId="${options.deviceId}", Version="${clientVersion}", Token="${options.accessToken}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
|
return this.postJson('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
|
return this.postJson('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||||
|
return this.postJson('/Sessions/Playing/Stopped', {
|
||||||
|
...buildJellyfinTimelinePayload(state),
|
||||||
|
Failed: state.failed === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: this.authHeader,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
// The stop report waits for in-flight progress reports, so a hung request must not
|
||||||
|
// hold it back indefinitely.
|
||||||
|
signal: AbortSignal.timeout(JELLYFIN_REPORT_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
this.noteRequestOutcome(path, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||||
|
// log without a warning per progress tick.
|
||||||
|
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||||
|
if (failure === null) {
|
||||||
|
this.failedRequestPaths.delete(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.failedRequestPaths.has(path)) return;
|
||||||
|
this.failedRequestPaths.add(path);
|
||||||
|
this.logWarn?.(`Jellyfin request failed: POST ${path}`, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
import { JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||||
|
|
||||||
class FakeWebSocket {
|
class FakeWebSocket {
|
||||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||||
@@ -240,105 +240,6 @@ test('Jellyfin remote stop prevents further reconnect/network activity', () => {
|
|||||||
assert.equal(service.isConnected(), false);
|
assert.equal(service.isConnected(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reportProgress posts timeline payload and treats failure as non-fatal', async () => {
|
|
||||||
const sockets: FakeWebSocket[] = [];
|
|
||||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
||||||
let shouldFailTimeline = false;
|
|
||||||
|
|
||||||
const service = new JellyfinRemoteSessionService({
|
|
||||||
serverUrl: 'http://jellyfin.local',
|
|
||||||
accessToken: 'token-4',
|
|
||||||
deviceId: 'device-4',
|
|
||||||
webSocketFactory: () => {
|
|
||||||
const socket = new FakeWebSocket();
|
|
||||||
sockets.push(socket);
|
|
||||||
return socket as unknown as any;
|
|
||||||
},
|
|
||||||
fetchImpl: (async (input, init) => {
|
|
||||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
||||||
if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) {
|
|
||||||
return new Response('boom', { status: 500 });
|
|
||||||
}
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}) as typeof fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
service.start();
|
|
||||||
sockets[0]!.emit('open');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const expectedPayload = buildJellyfinTimelinePayload({
|
|
||||||
itemId: 'movie-2',
|
|
||||||
positionTicks: 123456,
|
|
||||||
isPaused: true,
|
|
||||||
volumeLevel: 33,
|
|
||||||
audioStreamIndex: 1,
|
|
||||||
subtitleStreamIndex: 2,
|
|
||||||
});
|
|
||||||
const expectedPostedPayload = Object.fromEntries(
|
|
||||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ok = await service.reportProgress({
|
|
||||||
itemId: 'movie-2',
|
|
||||||
positionTicks: 123456,
|
|
||||||
isPaused: true,
|
|
||||||
volumeLevel: 33,
|
|
||||||
audioStreamIndex: 1,
|
|
||||||
subtitleStreamIndex: 2,
|
|
||||||
});
|
|
||||||
shouldFailTimeline = true;
|
|
||||||
const failed = await service.reportProgress({
|
|
||||||
itemId: 'movie-2',
|
|
||||||
positionTicks: 999,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress'));
|
|
||||||
assert.ok(timelineCall);
|
|
||||||
assert.equal(ok, true);
|
|
||||||
assert.equal(failed, false);
|
|
||||||
assert.ok(typeof timelineCall.init.body === 'string');
|
|
||||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('timeline payload omits websocket-only event names', () => {
|
|
||||||
const payload = buildJellyfinTimelinePayload({
|
|
||||||
itemId: 'movie-2',
|
|
||||||
positionTicks: 123456,
|
|
||||||
eventName: 'TimeUpdate',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal('EventName' in payload, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
|
||||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
|
||||||
const service = new JellyfinRemoteSessionService({
|
|
||||||
serverUrl: 'http://jellyfin.local',
|
|
||||||
accessToken: 'token-stop-payload',
|
|
||||||
deviceId: 'device-stop-payload',
|
|
||||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
|
||||||
fetchImpl: (async (input, init) => {
|
|
||||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}) as typeof fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ok = await service.reportStopped({
|
|
||||||
itemId: 'movie-stop',
|
|
||||||
positionTicks: 7654321,
|
|
||||||
failed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
|
||||||
assert.equal(ok, true);
|
|
||||||
assert.ok(stoppedCall);
|
|
||||||
assert.ok(typeof stoppedCall.init.body === 'string');
|
|
||||||
const posted = JSON.parse(String(stoppedCall.init.body));
|
|
||||||
assert.equal(posted.PositionTicks, 7654321);
|
|
||||||
assert.equal(posted.Failed, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||||
const sockets: FakeWebSocket[] = [];
|
const sockets: FakeWebSocket[] = [];
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -451,34 +352,6 @@ test('reconnects when the server stops answering keep-alives', () => {
|
|||||||
assert.equal(sockets.length, 2);
|
assert.equal(sockets.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('warns once per failing timeline endpoint until it recovers', async () => {
|
|
||||||
const warnings: string[] = [];
|
|
||||||
let status = 400;
|
|
||||||
|
|
||||||
const service = new JellyfinRemoteSessionService({
|
|
||||||
serverUrl: 'http://jellyfin.local',
|
|
||||||
accessToken: 'token-warn',
|
|
||||||
deviceId: 'device-warn',
|
|
||||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
|
||||||
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
|
||||||
logWarn: (message) => {
|
|
||||||
warnings.push(message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
|
||||||
|
|
||||||
assert.equal(await service.reportStopped(state), false);
|
|
||||||
assert.equal(await service.reportStopped(state), false);
|
|
||||||
assert.equal(warnings.length, 1);
|
|
||||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
|
||||||
|
|
||||||
status = 200;
|
|
||||||
assert.equal(await service.reportStopped(state), true);
|
|
||||||
status = 500;
|
|
||||||
assert.equal(await service.reportStopped(state), false);
|
|
||||||
assert.equal(warnings.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores messages from a superseded socket', () => {
|
test('ignores messages from a superseded socket', () => {
|
||||||
const sockets: FakeWebSocket[] = [];
|
const sockets: FakeWebSocket[] = [];
|
||||||
const playPayloads: unknown[] = [];
|
const playPayloads: unknown[] = [];
|
||||||
|
|||||||
@@ -1,45 +1,11 @@
|
|||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
import { JellyfinPlaybackReporter } from './jellyfin-playback-reporter';
|
||||||
|
|
||||||
export interface JellyfinRemoteSessionMessage {
|
export interface JellyfinRemoteSessionMessage {
|
||||||
MessageType?: string;
|
MessageType?: string;
|
||||||
Data?: unknown;
|
Data?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinTimelinePlaybackState {
|
|
||||||
itemId: string;
|
|
||||||
mediaSourceId?: string;
|
|
||||||
positionTicks?: number;
|
|
||||||
playbackStartTimeTicks?: number;
|
|
||||||
isPaused?: boolean;
|
|
||||||
isMuted?: boolean;
|
|
||||||
canSeek?: boolean;
|
|
||||||
volumeLevel?: number;
|
|
||||||
playbackRate?: number;
|
|
||||||
playMethod?: string;
|
|
||||||
audioStreamIndex?: number | null;
|
|
||||||
subtitleStreamIndex?: number | null;
|
|
||||||
playlistItemId?: string | null;
|
|
||||||
eventName?: string;
|
|
||||||
failed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JellyfinTimelinePayload {
|
|
||||||
ItemId: string;
|
|
||||||
MediaSourceId?: string;
|
|
||||||
PositionTicks: number;
|
|
||||||
PlaybackStartTimeTicks: number;
|
|
||||||
IsPaused: boolean;
|
|
||||||
IsMuted: boolean;
|
|
||||||
CanSeek: boolean;
|
|
||||||
VolumeLevel: number;
|
|
||||||
PlaybackRate: number;
|
|
||||||
PlayMethod: string;
|
|
||||||
AudioStreamIndex?: number | null;
|
|
||||||
SubtitleStreamIndex?: number | null;
|
|
||||||
PlaylistItemId?: string | null;
|
|
||||||
Failed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JellyfinRemoteSocket {
|
interface JellyfinRemoteSocket {
|
||||||
on(event: 'open', listener: () => void): this;
|
on(event: 'open', listener: () => void): this;
|
||||||
on(event: 'close', listener: () => void): this;
|
on(event: 'close', listener: () => void): this;
|
||||||
@@ -99,16 +65,6 @@ function normalizeServerUrl(serverUrl: string): string {
|
|||||||
return serverUrl.trim().replace(/\/+$/, '');
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampVolume(value: number | undefined): number {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 100;
|
|
||||||
return Math.max(0, Math.min(100, Math.round(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTicks(value: number | undefined): number {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
|
||||||
return Math.max(0, Math.floor(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMessageData(value: unknown): unknown {
|
function parseMessageData(value: unknown): unknown {
|
||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -137,11 +93,6 @@ function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asNullableInteger(value: number | null | undefined): number | null {
|
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) return null;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultCapabilities(): {
|
function createDefaultCapabilities(): {
|
||||||
PlayableMediaTypes: string;
|
PlayableMediaTypes: string;
|
||||||
SupportedCommands: string;
|
SupportedCommands: string;
|
||||||
@@ -155,40 +106,6 @@ function createDefaultCapabilities(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAuthorizationHeader(params: {
|
|
||||||
clientName: string;
|
|
||||||
deviceName: string;
|
|
||||||
clientVersion: string;
|
|
||||||
deviceId: string;
|
|
||||||
accessToken: string;
|
|
||||||
}): string {
|
|
||||||
return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildJellyfinTimelinePayload(
|
|
||||||
state: JellyfinTimelinePlaybackState,
|
|
||||||
): JellyfinTimelinePayload {
|
|
||||||
return {
|
|
||||||
ItemId: state.itemId,
|
|
||||||
MediaSourceId: state.mediaSourceId,
|
|
||||||
PositionTicks: normalizeTicks(state.positionTicks),
|
|
||||||
PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks),
|
|
||||||
IsPaused: state.isPaused === true,
|
|
||||||
IsMuted: state.isMuted === true,
|
|
||||||
CanSeek: state.canSeek !== false,
|
|
||||||
VolumeLevel: clampVolume(state.volumeLevel),
|
|
||||||
PlaybackRate:
|
|
||||||
typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate)
|
|
||||||
? state.playbackRate
|
|
||||||
: 1,
|
|
||||||
PlayMethod: state.playMethod || 'DirectPlay',
|
|
||||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
|
||||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
|
||||||
PlaylistItemId: state.playlistItemId,
|
|
||||||
Failed: state.failed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JellyfinRemoteSessionService {
|
export class JellyfinRemoteSessionService {
|
||||||
private readonly serverUrl: string;
|
private readonly serverUrl: string;
|
||||||
private readonly accessToken: string;
|
private readonly accessToken: string;
|
||||||
@@ -210,6 +127,8 @@ export class JellyfinRemoteSessionService {
|
|||||||
SupportsMediaControl: boolean;
|
SupportsMediaControl: boolean;
|
||||||
};
|
};
|
||||||
private readonly authHeader: string;
|
private readonly authHeader: string;
|
||||||
|
// Shares this device's identity for capability posts; playback reports use their own reporter.
|
||||||
|
private readonly http: JellyfinPlaybackReporter;
|
||||||
private readonly onConnected?: () => void;
|
private readonly onConnected?: () => void;
|
||||||
private readonly onDisconnected?: () => void;
|
private readonly onDisconnected?: () => void;
|
||||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||||
@@ -217,7 +136,6 @@ export class JellyfinRemoteSessionService {
|
|||||||
private keepAliveTimeoutMs: number;
|
private keepAliveTimeoutMs: number;
|
||||||
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private lastInboundAtMs = 0;
|
private lastInboundAtMs = 0;
|
||||||
private readonly failedRequestPaths = new Set<string>();
|
|
||||||
|
|
||||||
private readonly reconnectBaseDelayMs: number;
|
private readonly reconnectBaseDelayMs: number;
|
||||||
private readonly reconnectMaxDelayMs: number;
|
private readonly reconnectMaxDelayMs: number;
|
||||||
@@ -243,19 +161,20 @@ export class JellyfinRemoteSessionService {
|
|||||||
...createDefaultCapabilities(),
|
...createDefaultCapabilities(),
|
||||||
...(options.capabilities ?? {}),
|
...(options.capabilities ?? {}),
|
||||||
};
|
};
|
||||||
const clientName = options.clientName || 'SubMiner';
|
this.logWarn = options.logWarn;
|
||||||
const clientVersion = options.clientVersion || '0.1.0';
|
this.http = new JellyfinPlaybackReporter({
|
||||||
const deviceName = options.deviceName || clientName;
|
serverUrl: this.serverUrl,
|
||||||
this.authHeader = buildAuthorizationHeader({
|
|
||||||
clientName,
|
|
||||||
deviceName,
|
|
||||||
clientVersion,
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
accessToken: this.accessToken,
|
accessToken: this.accessToken,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
clientName: options.clientName,
|
||||||
|
clientVersion: options.clientVersion,
|
||||||
|
deviceName: options.deviceName,
|
||||||
|
fetchImpl: this.fetchImpl,
|
||||||
|
logWarn: this.logWarn,
|
||||||
});
|
});
|
||||||
|
this.authHeader = this.http.authHeader;
|
||||||
this.onConnected = options.onConnected;
|
this.onConnected = options.onConnected;
|
||||||
this.onDisconnected = options.onDisconnected;
|
this.onDisconnected = options.onDisconnected;
|
||||||
this.logWarn = options.logWarn;
|
|
||||||
this.now = options.getNow ?? Date.now;
|
this.now = options.getNow ?? Date.now;
|
||||||
this.keepAliveTimeoutMs = Math.max(
|
this.keepAliveTimeoutMs = Math.max(
|
||||||
1000,
|
1000,
|
||||||
@@ -298,21 +217,6 @@ export class JellyfinRemoteSessionService {
|
|||||||
return this.isRegisteredOnServer();
|
return this.isRegisteredOnServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
|
||||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
|
||||||
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
|
||||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
|
||||||
...buildJellyfinTimelinePayload(state),
|
|
||||||
Failed: state.failed === true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private connectSocket(): void {
|
private connectSocket(): void {
|
||||||
if (!this.running) return;
|
if (!this.running) return;
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
@@ -441,9 +345,9 @@ export class JellyfinRemoteSessionService {
|
|||||||
|
|
||||||
private async postCapabilities(): Promise<void> {
|
private async postCapabilities(): Promise<void> {
|
||||||
const payload = this.capabilities;
|
const payload = this.capabilities;
|
||||||
const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload);
|
const fullEndpointOk = await this.http.postJson('/Sessions/Capabilities/Full', payload);
|
||||||
if (fullEndpointOk) return;
|
if (fullEndpointOk) return;
|
||||||
await this.postJson('/Sessions/Capabilities', payload);
|
await this.http.postJson('/Sessions/Capabilities', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isRegisteredOnServer(): Promise<boolean> {
|
private async isRegisteredOnServer(): Promise<boolean> {
|
||||||
@@ -462,40 +366,6 @@ export class JellyfinRemoteSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
|
|
||||||
return this.postJson(path, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async postJson(path: string, payload: unknown): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await this.fetchImpl(`${this.serverUrl}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: this.authHeader,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
this.noteRequestOutcome(path, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
|
||||||
// log without a warning per progress tick.
|
|
||||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
|
||||||
if (failure === null) {
|
|
||||||
this.failedRequestPaths.delete(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.failedRequestPaths.has(path)) return;
|
|
||||||
this.failedRequestPaths.add(path);
|
|
||||||
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||||
const message = parseInboundMessage(rawData);
|
const message = parseInboundMessage(rawData);
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|||||||
+16
-8
@@ -58,6 +58,7 @@ import {
|
|||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||||
} from './main/runtime/jellyfin-remote-playback';
|
} from './main/runtime/jellyfin-remote-playback';
|
||||||
import { getAppControlSocketPath } from './shared/app-control';
|
import { getAppControlSocketPath } from './shared/app-control';
|
||||||
|
import { writeJellyfinCliResponse } from './shared/jellyfin-cli-response';
|
||||||
import {
|
import {
|
||||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||||
@@ -331,6 +332,7 @@ import {
|
|||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
|
JellyfinPlaybackReporter,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
@@ -409,6 +411,7 @@ import {
|
|||||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||||
createHostDerivedJellyfinDeviceId,
|
createHostDerivedJellyfinDeviceId,
|
||||||
|
resolveJellyfinRemoteDeviceName,
|
||||||
} from './main/runtime/jellyfin-device-identity';
|
} from './main/runtime/jellyfin-device-identity';
|
||||||
import {
|
import {
|
||||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||||
@@ -3395,9 +3398,17 @@ const {
|
|||||||
setLastProgressAtMs: (value) => {
|
setLastProgressAtMs: (value) => {
|
||||||
jellyfinRemoteLastProgressAtMs = value;
|
jellyfinRemoteLastProgressAtMs = value;
|
||||||
},
|
},
|
||||||
reportPlaying: (payload) => {
|
// Same device identity as the cast session, so Jellyfin attributes reports to one session.
|
||||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
createPlaybackReporter: ({ session, clientInfo }) =>
|
||||||
},
|
new JellyfinPlaybackReporter({
|
||||||
|
serverUrl: session.serverUrl,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
deviceId: clientInfo.deviceId,
|
||||||
|
clientName: clientInfo.clientName,
|
||||||
|
clientVersion: clientInfo.clientVersion,
|
||||||
|
deviceName: resolveJellyfinRemoteDeviceName({ hostName: os.hostname() }),
|
||||||
|
logWarn: (message, details) => logger.warn(message, details),
|
||||||
|
}),
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||||
},
|
},
|
||||||
@@ -3419,7 +3430,6 @@ const {
|
|||||||
clearActivePlayback: () => {
|
clearActivePlayback: () => {
|
||||||
activeJellyfinRemotePlayback = null;
|
activeJellyfinRemotePlayback = null;
|
||||||
},
|
},
|
||||||
getSession: () => appState.jellyfinRemoteSession,
|
|
||||||
getNow: () => Date.now(),
|
getNow: () => Date.now(),
|
||||||
getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs,
|
getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs,
|
||||||
setLastProgressAtMs: (value) => {
|
setLastProgressAtMs: (value) => {
|
||||||
@@ -3448,10 +3458,7 @@ const {
|
|||||||
listJellyfinItemsRuntime(session, clientInfo, params),
|
listJellyfinItemsRuntime(session, clientInfo, params),
|
||||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||||
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId),
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
|
||||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
},
|
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
},
|
},
|
||||||
handleJellyfinPlayCommandMainDeps: {
|
handleJellyfinPlayCommandMainDeps: {
|
||||||
@@ -3488,6 +3495,7 @@ const {
|
|||||||
},
|
},
|
||||||
runJellyfinCommandMainDeps: {
|
runJellyfinCommandMainDeps: {
|
||||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||||
|
writeJellyfinResponse: writeJellyfinCliResponse,
|
||||||
},
|
},
|
||||||
maybeFocusExistingJellyfinSetupWindowMainDeps: {
|
maybeFocusExistingJellyfinSetupWindowMainDeps: {
|
||||||
getSetupWindow: () => appState.jellyfinSetupWindow,
|
getSetupWindow: () => appState.jellyfinSetupWindow,
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export type JellyfinRemoteComposerOptions = ComposerInputs<{
|
|||||||
>[0]['jellyfinTicksToSeconds'];
|
>[0]['jellyfinTicksToSeconds'];
|
||||||
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
|
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
|
||||||
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
|
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
|
||||||
getSession: JellyfinRemoteProgressMainDeps['getSession'];
|
|
||||||
getNow: JellyfinRemoteProgressMainDeps['getNow'];
|
getNow: JellyfinRemoteProgressMainDeps['getNow'];
|
||||||
getLastProgressAtMs: Parameters<
|
getLastProgressAtMs: Parameters<
|
||||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||||
@@ -74,7 +73,6 @@ export function composeJellyfinRemoteHandlers(
|
|||||||
createBuildReportJellyfinRemoteProgressMainDepsHandler({
|
createBuildReportJellyfinRemoteProgressMainDepsHandler({
|
||||||
getActivePlayback: options.getActivePlayback,
|
getActivePlayback: options.getActivePlayback,
|
||||||
clearActivePlayback: options.clearActivePlayback,
|
clearActivePlayback: options.clearActivePlayback,
|
||||||
getSession: options.getSession,
|
|
||||||
getMpvClient: options.getMpvClient,
|
getMpvClient: options.getMpvClient,
|
||||||
getNow: options.getNow,
|
getNow: options.getNow,
|
||||||
getLastProgressAtMs: options.getLastProgressAtMs,
|
getLastProgressAtMs: options.getLastProgressAtMs,
|
||||||
@@ -87,7 +85,6 @@ export function composeJellyfinRemoteHandlers(
|
|||||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
|
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
|
||||||
getActivePlayback: options.getActivePlayback,
|
getActivePlayback: options.getActivePlayback,
|
||||||
clearActivePlayback: options.clearActivePlayback,
|
clearActivePlayback: options.clearActivePlayback,
|
||||||
getSession: options.getSession,
|
|
||||||
getMpvClient: options.getMpvClient,
|
getMpvClient: options.getMpvClient,
|
||||||
getNow: options.getNow,
|
getNow: options.getNow,
|
||||||
ticksPerSecond: options.ticksPerSecond,
|
ticksPerSecond: options.ticksPerSecond,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createHandleJellyfinListCommands } from './jellyfin-cli-list';
|
import { createHandleJellyfinListCommands } from './jellyfin-cli-list';
|
||||||
|
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||||
|
|
||||||
const baseSession = {
|
const baseSession = {
|
||||||
serverUrl: 'http://localhost',
|
serverUrl: 'http://localhost',
|
||||||
@@ -24,7 +25,7 @@ test('list handler no-ops when no list command is set', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,13 +43,14 @@ test('list handler no-ops when no list command is set', async () => {
|
|||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list handler logs libraries', async () => {
|
test('list handler logs libraries and writes them to the response path', async () => {
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
const writes: Array<{ path: string; response: JellyfinCliResponse }> = [];
|
||||||
const handler = createHandleJellyfinListCommands({
|
const handler = createHandleJellyfinListCommands({
|
||||||
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ test('list handler logs libraries', async () => {
|
|||||||
jellyfinLibraries: true,
|
jellyfinLibraries: true,
|
||||||
jellyfinItems: false,
|
jellyfinItems: false,
|
||||||
jellyfinSubtitles: false,
|
jellyfinSubtitles: false,
|
||||||
|
jellyfinResponsePath: '/tmp/reply.json',
|
||||||
} as never,
|
} as never,
|
||||||
session: baseSession,
|
session: baseSession,
|
||||||
clientInfo: baseClientInfo,
|
clientInfo: baseClientInfo,
|
||||||
@@ -65,6 +68,12 @@ test('list handler logs libraries', async () => {
|
|||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)')));
|
assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)')));
|
||||||
|
assert.deepEqual(writes, [
|
||||||
|
{
|
||||||
|
path: '/tmp/reply.json',
|
||||||
|
response: { libraries: [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list handler resolves items using default library id', async () => {
|
test('list handler resolves items using default library id', async () => {
|
||||||
@@ -72,6 +81,7 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
let usedRecursive: boolean | undefined;
|
let usedRecursive: boolean | undefined;
|
||||||
let usedIncludeItemTypes: string | undefined;
|
let usedIncludeItemTypes: string | undefined;
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
const writes: JellyfinCliResponse[] = [];
|
||||||
const handler = createHandleJellyfinListCommands({
|
const handler = createHandleJellyfinListCommands({
|
||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async (_session, _clientInfo, params) => {
|
listJellyfinItems: async (_session, _clientInfo, params) => {
|
||||||
@@ -81,7 +91,7 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
|
||||||
},
|
},
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: (_path, response) => writes.push(response),
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +105,7 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
jellyfinLimit: 10,
|
jellyfinLimit: 10,
|
||||||
jellyfinRecursive: false,
|
jellyfinRecursive: false,
|
||||||
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
|
||||||
|
jellyfinResponsePath: '/tmp/reply.json',
|
||||||
} as never,
|
} as never,
|
||||||
session: baseSession,
|
session: baseSession,
|
||||||
clientInfo: baseClientInfo,
|
clientInfo: baseClientInfo,
|
||||||
@@ -108,6 +119,7 @@ test('list handler resolves items using default library id', async () => {
|
|||||||
assert.equal(usedRecursive, false);
|
assert.equal(usedRecursive, false);
|
||||||
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
|
||||||
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
|
||||||
|
assert.deepEqual(writes, [{ items: [{ id: 'item1', title: 'Episode 1', type: 'Episode' }] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list handler throws when items command has no library id', async () => {
|
test('list handler throws when items command has no library id', async () => {
|
||||||
@@ -115,7 +127,7 @@ test('list handler throws when items command has no library id', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +156,7 @@ test('list handler logs subtitle urls only when requested', async () => {
|
|||||||
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
|
||||||
{ index: 2, language: 'jpn' },
|
{ index: 2, language: 'jpn' },
|
||||||
],
|
],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: () => {},
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +182,7 @@ test('list handler throws when subtitle command has no item id', async () => {
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,16 +202,13 @@ test('list handler throws when subtitle command has no item id', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('list handler writes preview auth payload to response path', async () => {
|
test('list handler writes preview auth payload to response path', async () => {
|
||||||
const writes: Array<{
|
const writes: Array<{ path: string; payload: JellyfinCliResponse }> = [];
|
||||||
path: string;
|
|
||||||
payload: { serverUrl: string; accessToken: string; userId: string };
|
|
||||||
}> = [];
|
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const handler = createHandleJellyfinListCommands({
|
const handler = createHandleJellyfinListCommands({
|
||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
writeJellyfinResponse: (responsePath, payload) => {
|
||||||
writes.push({ path: responsePath, payload });
|
writes.push({ path: responsePath, payload });
|
||||||
},
|
},
|
||||||
logInfo: (message) => logs.push(message),
|
logInfo: (message) => logs.push(message),
|
||||||
@@ -234,7 +243,7 @@ test('list handler throws when preview auth command has no response path', async
|
|||||||
listJellyfinLibraries: async () => [],
|
listJellyfinLibraries: async () => [],
|
||||||
listJellyfinItems: async () => [],
|
listJellyfinItems: async () => [],
|
||||||
listJellyfinSubtitleTracks: async () => [],
|
listJellyfinSubtitleTracks: async () => [],
|
||||||
writeJellyfinPreviewAuth: () => {},
|
writeJellyfinResponse: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||||
|
|
||||||
type JellyfinSession = {
|
type JellyfinSession = {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -17,12 +18,6 @@ type JellyfinConfig = {
|
|||||||
defaultLibraryId: string;
|
defaultLibraryId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JellyfinPreviewAuthPayload = {
|
|
||||||
serverUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createHandleJellyfinListCommands(deps: {
|
export function createHandleJellyfinListCommands(deps: {
|
||||||
listJellyfinLibraries: (
|
listJellyfinLibraries: (
|
||||||
session: JellyfinSession,
|
session: JellyfinSession,
|
||||||
@@ -56,7 +51,7 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
deliveryUrl?: string | null;
|
deliveryUrl?: string | null;
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
|
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (params: {
|
return async (params: {
|
||||||
@@ -66,13 +61,13 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
jellyfinConfig: JellyfinConfig;
|
jellyfinConfig: JellyfinConfig;
|
||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
const { args, session, clientInfo, jellyfinConfig } = params;
|
const { args, session, clientInfo, jellyfinConfig } = params;
|
||||||
|
const responsePath = args.jellyfinResponsePath?.trim();
|
||||||
|
|
||||||
if (args.jellyfinPreviewAuth) {
|
if (args.jellyfinPreviewAuth) {
|
||||||
const responsePath = args.jellyfinResponsePath?.trim();
|
|
||||||
if (!responsePath) {
|
if (!responsePath) {
|
||||||
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
|
||||||
}
|
}
|
||||||
deps.writeJellyfinPreviewAuth(responsePath, {
|
deps.writeJellyfinResponse(responsePath, {
|
||||||
serverUrl: session.serverUrl,
|
serverUrl: session.serverUrl,
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
@@ -83,6 +78,15 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
|
|
||||||
if (args.jellyfinLibraries) {
|
if (args.jellyfinLibraries) {
|
||||||
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
|
||||||
|
if (responsePath) {
|
||||||
|
deps.writeJellyfinResponse(responsePath, {
|
||||||
|
libraries: libraries.map((library) => ({
|
||||||
|
id: library.id,
|
||||||
|
name: library.name,
|
||||||
|
collectionType: library.collectionType || library.type || 'unknown',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
deps.logInfo('No Jellyfin libraries found.');
|
deps.logInfo('No Jellyfin libraries found.');
|
||||||
return true;
|
return true;
|
||||||
@@ -109,6 +113,11 @@ export function createHandleJellyfinListCommands(deps: {
|
|||||||
recursive: args.jellyfinRecursive,
|
recursive: args.jellyfinRecursive,
|
||||||
includeItemTypes: args.jellyfinIncludeItemTypes,
|
includeItemTypes: args.jellyfinIncludeItemTypes,
|
||||||
});
|
});
|
||||||
|
if (responsePath) {
|
||||||
|
deps.writeJellyfinResponse(responsePath, {
|
||||||
|
items: items.map((item) => ({ id: item.id, title: item.title, type: item.type })),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
deps.logInfo('No Jellyfin items found for the selected library/search.');
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
|
|||||||
deps.listJellyfinItems(session, clientInfo, params),
|
deps.listJellyfinItems(session, clientInfo, params),
|
||||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||||
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
|
||||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
writeJellyfinResponse: (responsePath, response) =>
|
||||||
deps.writeJellyfinPreviewAuth(responsePath, payload),
|
deps.writeJellyfinResponse(responsePath, response),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||||
|
|
||||||
type JellyfinConfigBase = {
|
type JellyfinConfigBase = {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@@ -37,6 +38,7 @@ export type RunJellyfinCommandMainDeps<TClientInfo, TConfig extends JellyfinConf
|
|||||||
clientInfo: TClientInfo;
|
clientInfo: TClientInfo;
|
||||||
jellyfinConfig: TConfig;
|
jellyfinConfig: TConfig;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBuildRunJellyfinCommandMainDepsHandler<
|
export function createBuildRunJellyfinCommandMainDepsHandler<
|
||||||
@@ -51,5 +53,7 @@ export function createBuildRunJellyfinCommandMainDepsHandler<
|
|||||||
handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args),
|
handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args),
|
||||||
handleListCommands: (params) => deps.handleListCommands(params),
|
handleListCommands: (params) => deps.handleListCommands(params),
|
||||||
handlePlayCommand: (params) => deps.handlePlayCommand(params),
|
handlePlayCommand: (params) => deps.handlePlayCommand(params),
|
||||||
|
writeJellyfinResponse: (responsePath, response) =>
|
||||||
|
deps.writeJellyfinResponse(responsePath, response),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,7 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
|||||||
calls.push('list');
|
calls.push('list');
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
writeJellyfinResponse: () => {},
|
||||||
handlePlayCommand: async () => {
|
handlePlayCommand: async () => {
|
||||||
calls.push('play');
|
calls.push('play');
|
||||||
return false;
|
return false;
|
||||||
@@ -76,7 +77,8 @@ test('run jellyfin command returns after auth branch handles command', async ()
|
|||||||
assert.deepEqual(calls, ['auth']);
|
assert.deepEqual(calls, ['auth']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('run jellyfin command throws when session missing after auth', async () => {
|
test('run jellyfin command throws when session missing and writes the error reply', async () => {
|
||||||
|
const writes: Array<{ path: string; response: unknown }> = [];
|
||||||
const run = createRunJellyfinCommandHandler({
|
const run = createRunJellyfinCommandHandler({
|
||||||
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
|
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
|
||||||
defaultServerUrl: '',
|
defaultServerUrl: '',
|
||||||
@@ -84,10 +86,20 @@ test('run jellyfin command throws when session missing after auth', async () =>
|
|||||||
handleAuthCommands: async () => false,
|
handleAuthCommands: async () => false,
|
||||||
handleRemoteAnnounceCommand: async () => false,
|
handleRemoteAnnounceCommand: async () => false,
|
||||||
handleListCommands: async () => false,
|
handleListCommands: async () => false,
|
||||||
|
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
|
||||||
handlePlayCommand: async () => false,
|
handlePlayCommand: async () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
|
await assert.rejects(
|
||||||
|
() => run(createArgs({ jellyfinResponsePath: '/tmp/reply.json' })),
|
||||||
|
/Missing Jellyfin session/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(writes, [
|
||||||
|
{
|
||||||
|
path: '/tmp/reply.json',
|
||||||
|
response: { error: 'Missing Jellyfin session. Run --jellyfin-login first.' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
|
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
|
||||||
@@ -116,6 +128,7 @@ test('run jellyfin command dispatches remote/list/play in order until handled',
|
|||||||
seenServerUrls.push(session.serverUrl);
|
seenServerUrls.push(session.serverUrl);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
writeJellyfinResponse: () => {},
|
||||||
handlePlayCommand: async () => {
|
handlePlayCommand: async () => {
|
||||||
calls.push('play');
|
calls.push('play');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
|
||||||
|
|
||||||
type JellyfinConfigBase = {
|
type JellyfinConfigBase = {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@@ -40,8 +41,9 @@ export function createRunJellyfinCommandHandler<
|
|||||||
clientInfo: TClientInfo;
|
clientInfo: TClientInfo;
|
||||||
jellyfinConfig: TConfig;
|
jellyfinConfig: TConfig;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (args: CliArgs): Promise<void> => {
|
const run = async (args: CliArgs): Promise<void> => {
|
||||||
const jellyfinConfig = deps.getJellyfinConfig();
|
const jellyfinConfig = deps.getJellyfinConfig();
|
||||||
const serverUrl =
|
const serverUrl =
|
||||||
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
|
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
|
||||||
@@ -97,4 +99,23 @@ export function createRunJellyfinCommandHandler<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A launcher waiting on --jellyfin-response-path gets the failure reason instead of a timeout.
|
||||||
|
return async (args: CliArgs): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await run(args);
|
||||||
|
} catch (error) {
|
||||||
|
const responsePath = args.jellyfinResponsePath?.trim();
|
||||||
|
if (responsePath) {
|
||||||
|
try {
|
||||||
|
deps.writeJellyfinResponse(responsePath, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep the original failure; the launcher falls back to its timeout.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
|||||||
preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params),
|
preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params),
|
||||||
setActivePlayback: (state) => deps.setActivePlayback(state),
|
setActivePlayback: (state) => deps.setActivePlayback(state),
|
||||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
createPlaybackReporter: (params) => deps.createPlaybackReporter(params),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||||
|
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||||
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
|
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
|
||||||
|
|
||||||
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
|
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
|
||||||
@@ -46,7 +47,13 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
|||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
|
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
|
||||||
const reports: Array<Parameters<Deps['reportPlaying']>[0]> = [];
|
const reports: JellyfinTimelinePlaybackState[] = [];
|
||||||
|
const reporterParams: Array<Parameters<Deps['createPlaybackReporter']>[0]> = [];
|
||||||
|
const reporter = {
|
||||||
|
reportPlaying: async (state: JellyfinTimelinePlaybackState) => void reports.push(state),
|
||||||
|
reportProgress: async () => {},
|
||||||
|
reportStopped: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
const handler = createPlayJellyfinItemInMpvHandler({
|
const handler = createPlayJellyfinItemInMpvHandler({
|
||||||
ensureMpvConnectedForPlayback: async () => true,
|
ensureMpvConnectedForPlayback: async () => true,
|
||||||
@@ -67,7 +74,10 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
|||||||
events.push(`active:${String(state.loadedMediaPath)}`);
|
events.push(`active:${String(state.loadedMediaPath)}`);
|
||||||
},
|
},
|
||||||
setLastProgressAtMs: () => {},
|
setLastProgressAtMs: () => {},
|
||||||
reportPlaying: (payload) => reports.push(payload),
|
createPlaybackReporter: (params) => {
|
||||||
|
reporterParams.push(params);
|
||||||
|
return reporter;
|
||||||
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
...options.deps,
|
...options.deps,
|
||||||
});
|
});
|
||||||
@@ -77,6 +87,8 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
|
|||||||
events,
|
events,
|
||||||
activeStates,
|
activeStates,
|
||||||
reports,
|
reports,
|
||||||
|
reporter,
|
||||||
|
reporterParams,
|
||||||
play: (params: Partial<PlayParams> = {}) =>
|
play: (params: Partial<PlayParams> = {}) =>
|
||||||
handler({
|
handler({
|
||||||
session: baseSession,
|
session: baseSession,
|
||||||
@@ -141,6 +153,8 @@ test('playback handler disables mpv subtitle selection, then loads media and rep
|
|||||||
assert.equal(harness.activeStates.length, 1);
|
assert.equal(harness.activeStates.length, 1);
|
||||||
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
|
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
|
||||||
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
||||||
|
assert.equal(harness.activeStates[0]?.reporter, harness.reporter);
|
||||||
|
assert.deepEqual(harness.reporterParams, [{ session: baseSession, clientInfo: baseClientInfo }]);
|
||||||
assert.equal(harness.reports.length, 1);
|
assert.equal(harness.reports.length, 1);
|
||||||
assert.equal(harness.reports[0]?.eventName, 'start');
|
assert.equal(harness.reports[0]?.eventName, 'start');
|
||||||
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
|
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
|
||||||
import type { JellyfinConfig } from '../../types';
|
import type { JellyfinConfig } from '../../types';
|
||||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||||
|
import type { JellyfinPlaybackReporterLike } from './jellyfin-remote-commands';
|
||||||
|
|
||||||
type JellyfinClientInfo = {
|
type JellyfinClientInfo = {
|
||||||
clientName: string;
|
clientName: string;
|
||||||
@@ -10,6 +11,7 @@ type JellyfinClientInfo = {
|
|||||||
|
|
||||||
type ActivePlaybackState = {
|
type ActivePlaybackState = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
reporter: JellyfinPlaybackReporterLike;
|
||||||
mediaSourceId: undefined;
|
mediaSourceId: undefined;
|
||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
@@ -134,16 +136,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||||
setLastProgressAtMs: (value: number) => void;
|
setLastProgressAtMs: (value: number) => void;
|
||||||
reportPlaying: (payload: {
|
// Reports go out as the device and account that started this playback, for CLI play and
|
||||||
itemId: string;
|
// casting alike, so Jellyfin keeps the resume point and played state either way.
|
||||||
mediaSourceId: undefined;
|
createPlaybackReporter: (params: {
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
session: JellyfinAuthSession;
|
||||||
positionTicks?: number;
|
clientInfo: JellyfinClientInfo;
|
||||||
isPaused?: boolean;
|
}) => JellyfinPlaybackReporterLike;
|
||||||
audioStreamIndex?: number | null;
|
|
||||||
subtitleStreamIndex?: number | null;
|
|
||||||
eventName: 'start';
|
|
||||||
}) => void;
|
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
recordJellyfinPlaybackMetadata?: (
|
recordJellyfinPlaybackMetadata?: (
|
||||||
metadata: JellyfinPlaybackStatsMetadata,
|
metadata: JellyfinPlaybackStatsMetadata,
|
||||||
@@ -210,8 +208,13 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const reporter = deps.createPlaybackReporter({
|
||||||
|
session: params.session,
|
||||||
|
clientInfo: params.clientInfo,
|
||||||
|
});
|
||||||
deps.setActivePlayback({
|
deps.setActivePlayback({
|
||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
|
reporter,
|
||||||
mediaSourceId: undefined,
|
mediaSourceId: undefined,
|
||||||
audioStreamIndex: plan.audioStreamIndex,
|
audioStreamIndex: plan.audioStreamIndex,
|
||||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||||
@@ -237,7 +240,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
);
|
);
|
||||||
deps.showVisibleOverlay();
|
deps.showVisibleOverlay();
|
||||||
|
|
||||||
deps.reportPlaying({
|
void reporter.reportPlaying({
|
||||||
itemId: params.itemId,
|
itemId: params.itemId,
|
||||||
mediaSourceId: undefined,
|
mediaSourceId: undefined,
|
||||||
playMethod,
|
playMethod,
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
type JellyfinRemotePlayHandlerDeps,
|
type JellyfinRemotePlayHandlerDeps,
|
||||||
} from './jellyfin-remote-commands';
|
} from './jellyfin-remote-commands';
|
||||||
|
|
||||||
|
const noopReporter = {
|
||||||
|
reportPlaying: async () => {},
|
||||||
|
reportProgress: async () => {},
|
||||||
|
reportStopped: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getConfiguredJellyfinSession({
|
getConfiguredJellyfinSession({
|
||||||
@@ -122,7 +128,11 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
|
|||||||
|
|
||||||
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
||||||
const { handlePlay, played } = makePlayHandler({
|
const { handlePlay, played } = makePlayHandler({
|
||||||
getActivePlayback: () => ({ itemId: 'item-1', playMethod: 'DirectPlay' }),
|
getActivePlayback: () => ({
|
||||||
|
itemId: 'item-1',
|
||||||
|
playMethod: 'DirectPlay',
|
||||||
|
reporter: noopReporter,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handlePlay({ ItemIds: ['item-1'] });
|
await handlePlay({ ItemIds: ['item-1'] });
|
||||||
@@ -207,6 +217,7 @@ test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices',
|
|||||||
const playback: ActiveJellyfinRemotePlaybackState = {
|
const playback: ActiveJellyfinRemotePlaybackState = {
|
||||||
itemId: 'item-1',
|
itemId: 'item-1',
|
||||||
playMethod: 'DirectPlay',
|
playMethod: 'DirectPlay',
|
||||||
|
reporter: noopReporter,
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
subtitleStreamIndex: null,
|
subtitleStreamIndex: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
|
||||||
|
|
||||||
|
type JellyfinTimelineReport = (state: JellyfinTimelinePlaybackState) => Promise<unknown>;
|
||||||
|
|
||||||
|
export type JellyfinPlaybackReporterLike = {
|
||||||
|
reportPlaying: JellyfinTimelineReport;
|
||||||
|
reportProgress: JellyfinTimelineReport;
|
||||||
|
reportStopped: JellyfinTimelineReport;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActiveJellyfinRemotePlaybackState = {
|
export type ActiveJellyfinRemotePlaybackState = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
// Bound at launch to the session that started playback, so reports flow for CLI play and
|
||||||
|
// casting alike, independent of the cast-control websocket.
|
||||||
|
reporter: JellyfinPlaybackReporterLike;
|
||||||
mediaSourceId?: string;
|
mediaSourceId?: string;
|
||||||
audioStreamIndex?: number | null;
|
audioStreamIndex?: number | null;
|
||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
|
|||||||
return (): JellyfinRemoteProgressReporterDeps => ({
|
return (): JellyfinRemoteProgressReporterDeps => ({
|
||||||
getActivePlayback: () => deps.getActivePlayback(),
|
getActivePlayback: () => deps.getActivePlayback(),
|
||||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||||
getSession: () => deps.getSession(),
|
|
||||||
getMpvClient: () => deps.getMpvClient(),
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
getNow: () => deps.getNow(),
|
getNow: () => deps.getNow(),
|
||||||
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
|
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
|
||||||
@@ -70,7 +69,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
|||||||
return (): JellyfinRemoteStoppedReporterDeps => ({
|
return (): JellyfinRemoteStoppedReporterDeps => ({
|
||||||
getActivePlayback: () => deps.getActivePlayback(),
|
getActivePlayback: () => deps.getActivePlayback(),
|
||||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||||
getSession: () => deps.getSession(),
|
|
||||||
getMpvClient: () => deps.getMpvClient(),
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||||
ticksPerSecond: deps.ticksPerSecond,
|
ticksPerSecond: deps.ticksPerSecond,
|
||||||
|
|||||||
@@ -8,26 +8,31 @@ import {
|
|||||||
secondsToJellyfinTicks,
|
secondsToJellyfinTicks,
|
||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||||
} from './jellyfin-remote-playback';
|
} from './jellyfin-remote-playback';
|
||||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
import type {
|
||||||
|
ActiveJellyfinRemotePlaybackState,
|
||||||
|
JellyfinPlaybackReporterLike,
|
||||||
|
} from './jellyfin-remote-commands';
|
||||||
|
|
||||||
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
|
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
|
||||||
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
|
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
|
||||||
type Session = NonNullable<ReturnType<ProgressDeps['getSession']>>;
|
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10_000_000;
|
const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
|
||||||
|
const makeReporter = (
|
||||||
|
overrides: Partial<JellyfinPlaybackReporterLike> = {},
|
||||||
|
): JellyfinPlaybackReporterLike => ({
|
||||||
|
reportPlaying: async () => {},
|
||||||
|
reportProgress: async () => {},
|
||||||
|
reportStopped: async () => {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
const makePlayback = (
|
const makePlayback = (
|
||||||
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
|
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
|
||||||
): ActiveJellyfinRemotePlaybackState => ({
|
): ActiveJellyfinRemotePlaybackState => ({
|
||||||
itemId: 'item-1',
|
itemId: 'item-1',
|
||||||
playMethod: 'DirectPlay',
|
playMethod: 'DirectPlay',
|
||||||
...overrides,
|
reporter: makeReporter(),
|
||||||
});
|
|
||||||
|
|
||||||
const makeSession = (overrides: Partial<Session> = {}): Session => ({
|
|
||||||
isConnected: () => true,
|
|
||||||
reportProgress: async () => {},
|
|
||||||
reportStopped: async () => {},
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +40,6 @@ function makeProgressDeps(overrides: Partial<ProgressDeps> = {}): ProgressDeps {
|
|||||||
return {
|
return {
|
||||||
getActivePlayback: () => makePlayback(),
|
getActivePlayback: () => makePlayback(),
|
||||||
clearActivePlayback: () => {},
|
clearActivePlayback: () => {},
|
||||||
getSession: () => makeSession(),
|
|
||||||
getMpvClient: () => ({ requestProperty: async () => 1 }),
|
getMpvClient: () => ({ requestProperty: async () => 1 }),
|
||||||
getNow: () => 5000,
|
getNow: () => 5000,
|
||||||
getLastProgressAtMs: () => 0,
|
getLastProgressAtMs: () => 0,
|
||||||
@@ -51,7 +55,6 @@ function makeStoppedDeps(overrides: Partial<StoppedDeps> = {}): StoppedDeps {
|
|||||||
return {
|
return {
|
||||||
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
|
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
|
||||||
clearActivePlayback: () => {},
|
clearActivePlayback: () => {},
|
||||||
getSession: () => makeSession(),
|
|
||||||
getMpvClient: () => ({ currentTimePos: 0 }),
|
getMpvClient: () => ({ currentTimePos: 0 }),
|
||||||
ticksPerSecond: TICKS_PER_SECOND,
|
ticksPerSecond: TICKS_PER_SECOND,
|
||||||
logDebug: () => {},
|
logDebug: () => {},
|
||||||
@@ -69,29 +72,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
|||||||
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||||
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
makePlayback({ loadedMediaPath: null }),
|
||||||
'http://pve-main:8096/Videos/item/stream',
|
'http://pve-main:8096/Videos/item/stream',
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||||
{
|
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||||
itemId: 'item-1',
|
|
||||||
playMethod: 'DirectPlay',
|
|
||||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
|
||||||
},
|
|
||||||
'http://pve-main:8096/Videos/item/stream',
|
'http://pve-main:8096/Videos/item/stream',
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||||
{
|
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
|
||||||
itemId: 'item-1',
|
|
||||||
playMethod: 'DirectPlay',
|
|
||||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
|
||||||
},
|
|
||||||
'/tmp/local.mkv',
|
'/tmp/local.mkv',
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
@@ -100,12 +95,13 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
|
|||||||
|
|
||||||
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
||||||
let lastProgressAtMs = 0;
|
let lastProgressAtMs = 0;
|
||||||
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
const reportPayloads: Array<{ itemId: string; positionTicks?: number; isPaused?: boolean }> = [];
|
||||||
|
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
makeProgressDeps({
|
makeProgressDeps({
|
||||||
getSession: () =>
|
getActivePlayback: () =>
|
||||||
makeSession({
|
makePlayback({
|
||||||
|
reporter: makeReporter({
|
||||||
reportProgress: async (payload) => {
|
reportProgress: async (payload) => {
|
||||||
reportPayloads.push({
|
reportPayloads.push({
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
@@ -114,6 +110,7 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({
|
||||||
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
|
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
|
||||||
}),
|
}),
|
||||||
@@ -135,44 +132,19 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
|||||||
assert.equal(lastProgressAtMs, 5000);
|
assert.equal(lastProgressAtMs, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
|
||||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
|
||||||
|
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
|
||||||
makeProgressDeps({
|
|
||||||
getSession: () =>
|
|
||||||
makeSession({
|
|
||||||
isConnected: () => false,
|
|
||||||
reportProgress: async (payload) => {
|
|
||||||
reportPayloads.push({
|
|
||||||
positionTicks: payload.positionTicks,
|
|
||||||
isPaused: payload.isPaused,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
currentTimePos: 42,
|
|
||||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await reportProgress(true);
|
|
||||||
|
|
||||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
const reportPayloads: Array<{ isPaused?: boolean }> = [];
|
||||||
|
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
makeProgressDeps({
|
makeProgressDeps({
|
||||||
getSession: () =>
|
getActivePlayback: () =>
|
||||||
makeSession({
|
makePlayback({
|
||||||
|
reporter: makeReporter({
|
||||||
reportProgress: async (payload) => {
|
reportProgress: async (payload) => {
|
||||||
reportPayloads.push({ isPaused: payload.isPaused });
|
reportPayloads.push({ isPaused: payload.isPaused });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({
|
||||||
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
||||||
}),
|
}),
|
||||||
@@ -188,12 +160,14 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
|||||||
let called = false;
|
let called = false;
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
makeProgressDeps({
|
makeProgressDeps({
|
||||||
getSession: () =>
|
getActivePlayback: () =>
|
||||||
makeSession({
|
makePlayback({
|
||||||
|
reporter: makeReporter({
|
||||||
reportProgress: async () => {
|
reportProgress: async () => {
|
||||||
called = true;
|
called = true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
getNow: () => 4000,
|
getNow: () => 4000,
|
||||||
getLastProgressAtMs: () => 3500,
|
getLastProgressAtMs: () => 3500,
|
||||||
}),
|
}),
|
||||||
@@ -207,12 +181,13 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
|
|||||||
let now = 5000;
|
let now = 5000;
|
||||||
let lastProgressAtMs = 0;
|
let lastProgressAtMs = 0;
|
||||||
let position = 10;
|
let position = 10;
|
||||||
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
const reportPayloads: Array<{ positionTicks?: number; eventName?: string }> = [];
|
||||||
|
|
||||||
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
const reportProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
makeProgressDeps({
|
makeProgressDeps({
|
||||||
getSession: () =>
|
getActivePlayback: () =>
|
||||||
makeSession({
|
makePlayback({
|
||||||
|
reporter: makeReporter({
|
||||||
reportProgress: async (payload) => {
|
reportProgress: async (payload) => {
|
||||||
reportPayloads.push({
|
reportPayloads.push({
|
||||||
positionTicks: payload.positionTicks,
|
positionTicks: payload.positionTicks,
|
||||||
@@ -220,6 +195,7 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({
|
||||||
currentTimePos: position,
|
currentTimePos: position,
|
||||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||||
@@ -253,12 +229,11 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||||
makeStoppedDeps({
|
makeStoppedDeps({
|
||||||
getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode' }),
|
getActivePlayback: () =>
|
||||||
clearActivePlayback: () => {
|
makePlayback({
|
||||||
cleared = true;
|
itemId: 'item-2',
|
||||||
},
|
playMethod: 'Transcode',
|
||||||
getSession: () =>
|
reporter: makeReporter({
|
||||||
makeSession({
|
|
||||||
reportStopped: async (payload) => {
|
reportStopped: async (payload) => {
|
||||||
stoppedPayload = {
|
stoppedPayload = {
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
@@ -267,6 +242,10 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({
|
||||||
currentTimePos: 12.5,
|
currentTimePos: 12.5,
|
||||||
requestProperty: async () => {
|
requestProperty: async () => {
|
||||||
@@ -289,16 +268,18 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
|||||||
let cleared = false;
|
let cleared = false;
|
||||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
||||||
makeStoppedDeps({
|
makeStoppedDeps({
|
||||||
getActivePlayback: () => makePlayback({ loadedMediaPath: null }),
|
getActivePlayback: () =>
|
||||||
clearActivePlayback: () => {
|
makePlayback({
|
||||||
cleared = true;
|
loadedMediaPath: null,
|
||||||
},
|
reporter: makeReporter({
|
||||||
getSession: () =>
|
|
||||||
makeSession({
|
|
||||||
reportStopped: async () => {
|
reportStopped: async () => {
|
||||||
throw new Error('should not report stopped for unloaded media');
|
throw new Error('should not report stopped for unloaded media');
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -308,43 +289,6 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
|
|||||||
assert.equal(cleared, true);
|
assert.equal(cleared, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
|
||||||
let cleared = false;
|
|
||||||
let stoppedPayload: {
|
|
||||||
itemId: string;
|
|
||||||
positionTicks?: number;
|
|
||||||
failed?: boolean;
|
|
||||||
} | null = null;
|
|
||||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(
|
|
||||||
makeStoppedDeps({
|
|
||||||
clearActivePlayback: () => {
|
|
||||||
cleared = true;
|
|
||||||
},
|
|
||||||
getSession: () =>
|
|
||||||
makeSession({
|
|
||||||
isConnected: () => false,
|
|
||||||
reportStopped: async (payload) => {
|
|
||||||
stoppedPayload = {
|
|
||||||
itemId: payload.itemId,
|
|
||||||
positionTicks: payload.positionTicks,
|
|
||||||
failed: payload.failed,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getMpvClient: () => ({ currentTimePos: 12.5 }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await reportStopped();
|
|
||||||
|
|
||||||
assert.deepEqual(stoppedPayload, {
|
|
||||||
itemId: 'item-1',
|
|
||||||
positionTicks: 125_000_000,
|
|
||||||
failed: false,
|
|
||||||
});
|
|
||||||
assert.equal(cleared, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
||||||
let cleared = false;
|
let cleared = false;
|
||||||
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
||||||
@@ -354,12 +298,7 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
|
|||||||
makePlayback({
|
makePlayback({
|
||||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||||
lastKnownPositionSeconds: 72.25,
|
lastKnownPositionSeconds: 72.25,
|
||||||
}),
|
reporter: makeReporter({
|
||||||
clearActivePlayback: () => {
|
|
||||||
cleared = true;
|
|
||||||
},
|
|
||||||
getSession: () =>
|
|
||||||
makeSession({
|
|
||||||
reportProgress: async (payload) => {
|
reportProgress: async (payload) => {
|
||||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||||
},
|
},
|
||||||
@@ -368,6 +307,10 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await reportStopped();
|
await reportStopped();
|
||||||
@@ -423,16 +366,15 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
|||||||
makePlayback({
|
makePlayback({
|
||||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||||
stopReportsAfterMs: 20_000,
|
stopReportsAfterMs: 20_000,
|
||||||
}),
|
reporter: makeReporter({
|
||||||
clearActivePlayback: () => {
|
|
||||||
cleared = true;
|
|
||||||
},
|
|
||||||
getSession: () =>
|
|
||||||
makeSession({
|
|
||||||
reportStopped: async () => {
|
reportStopped: async () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
clearActivePlayback: () => {
|
||||||
|
cleared = true;
|
||||||
|
},
|
||||||
getNow: () => 12_000,
|
getNow: () => 12_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -445,34 +387,28 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
|||||||
|
|
||||||
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
||||||
const tracker = createJellyfinRemoteReportTracker();
|
const tracker = createJellyfinRemoteReportTracker();
|
||||||
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
|
||||||
itemId: 'item-1',
|
|
||||||
playMethod: 'DirectPlay',
|
|
||||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
|
||||||
};
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let releaseProgress: () => void = () => undefined;
|
let releaseProgress: () => void = () => undefined;
|
||||||
const progressGate = new Promise<void>((resolve) => {
|
const progressGate = new Promise<void>((resolve) => {
|
||||||
releaseProgress = resolve;
|
releaseProgress = resolve;
|
||||||
});
|
});
|
||||||
const session = {
|
let playback: ActiveJellyfinRemotePlaybackState | null = makePlayback({
|
||||||
isConnected: () => true,
|
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||||
reportProgress: async ({ eventName }: { eventName: string }) => {
|
reporter: makeReporter({
|
||||||
|
reportProgress: async ({ eventName }) => {
|
||||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||||
if (calls.length === 1) await progressGate;
|
if (calls.length === 1) await progressGate;
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
reportStopped: async () => {
|
reportStopped: async () => {
|
||||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
};
|
}),
|
||||||
|
});
|
||||||
const shared = {
|
const shared = {
|
||||||
getActivePlayback: () => playback,
|
getActivePlayback: () => playback,
|
||||||
clearActivePlayback: () => {
|
clearActivePlayback: () => {
|
||||||
playback = null;
|
playback = null;
|
||||||
},
|
},
|
||||||
getSession: () => session,
|
|
||||||
getMpvClient: () => ({ currentTimePos: 42 }),
|
getMpvClient: () => ({ currentTimePos: 42 }),
|
||||||
ticksPerSecond: 10_000_000,
|
ticksPerSecond: 10_000_000,
|
||||||
logDebug: () => undefined,
|
logDebug: () => undefined,
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
|
||||||
|
|
||||||
type JellyfinRemoteSessionLike = {
|
|
||||||
isConnected: () => boolean;
|
|
||||||
reportProgress: (payload: {
|
|
||||||
itemId: string;
|
|
||||||
mediaSourceId?: string;
|
|
||||||
positionTicks: number;
|
|
||||||
isPaused: boolean;
|
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
|
||||||
audioStreamIndex?: number | null;
|
|
||||||
subtitleStreamIndex?: number | null;
|
|
||||||
eventName: 'TimeUpdate';
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
reportStopped: (payload: {
|
|
||||||
itemId: string;
|
|
||||||
mediaSourceId?: string;
|
|
||||||
positionTicks?: number;
|
|
||||||
failed?: boolean;
|
|
||||||
playMethod: 'DirectPlay' | 'Transcode';
|
|
||||||
audioStreamIndex?: number | null;
|
|
||||||
subtitleStreamIndex?: number | null;
|
|
||||||
eventName: 'stop';
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MpvClientLike = {
|
type MpvClientLike = {
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
@@ -160,7 +136,6 @@ export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker
|
|||||||
export type JellyfinRemoteProgressReporterDeps = {
|
export type JellyfinRemoteProgressReporterDeps = {
|
||||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
getSession: () => JellyfinRemoteSessionLike | null;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
getNow: () => number;
|
getNow: () => number;
|
||||||
getLastProgressAtMs: () => number;
|
getLastProgressAtMs: () => number;
|
||||||
@@ -179,9 +154,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
|||||||
const report = async (force: boolean): Promise<void> => {
|
const report = async (force: boolean): Promise<void> => {
|
||||||
const playback = deps.getActivePlayback();
|
const playback = deps.getActivePlayback();
|
||||||
if (!playback) return;
|
if (!playback) return;
|
||||||
const session = deps.getSession();
|
|
||||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
|
||||||
if (!session) return;
|
|
||||||
const now = deps.getNow();
|
const now = deps.getNow();
|
||||||
try {
|
try {
|
||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
@@ -201,7 +173,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paused = await mpvClient?.requestProperty?.('pause');
|
const paused = await mpvClient?.requestProperty?.('pause');
|
||||||
await session.reportProgress({
|
await playback.reporter.reportProgress({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||||
@@ -228,7 +200,6 @@ export function createReportJellyfinRemoteProgressHandler(
|
|||||||
export type JellyfinRemoteStoppedReporterDeps = {
|
export type JellyfinRemoteStoppedReporterDeps = {
|
||||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||||
clearActivePlayback: () => void;
|
clearActivePlayback: () => void;
|
||||||
getSession: () => JellyfinRemoteSessionLike | null;
|
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
getNow?: () => number;
|
getNow?: () => number;
|
||||||
ticksPerSecond: number;
|
ticksPerSecond: number;
|
||||||
@@ -252,12 +223,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = deps.getSession();
|
|
||||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
|
||||||
if (!session) {
|
|
||||||
deps.clearActivePlayback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Clear before any network call so progress ticks fired during the stop find nothing to
|
// Clear before any network call so progress ticks fired during the stop find nothing to
|
||||||
// report, then let reports already in flight finish so none can arrive after the stop.
|
// report, then let reports already in flight finish so none can arrive after the stop.
|
||||||
deps.clearActivePlayback();
|
deps.clearActivePlayback();
|
||||||
@@ -267,7 +232,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||||
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
||||||
try {
|
try {
|
||||||
await session.reportProgress({
|
await playback.reporter.reportProgress({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
@@ -280,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||||
}
|
}
|
||||||
const reported = await session.reportStopped({
|
const reported = await playback.reporter.reportStopped({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
|
|||||||
@@ -316,8 +316,9 @@ function installKeyboardTestGlobals() {
|
|||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
repeat?: boolean;
|
repeat?: boolean;
|
||||||
target?: unknown;
|
target?: unknown;
|
||||||
}): void {
|
}): boolean {
|
||||||
const listeners = documentListeners.get('keydown') ?? [];
|
const listeners = documentListeners.get('keydown') ?? [];
|
||||||
|
let prevented = false;
|
||||||
const keyboardEvent = {
|
const keyboardEvent = {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
code: event.code,
|
code: event.code,
|
||||||
@@ -326,12 +327,15 @@ function installKeyboardTestGlobals() {
|
|||||||
altKey: event.altKey ?? false,
|
altKey: event.altKey ?? false,
|
||||||
shiftKey: event.shiftKey ?? false,
|
shiftKey: event.shiftKey ?? false,
|
||||||
repeat: event.repeat ?? false,
|
repeat: event.repeat ?? false,
|
||||||
preventDefault: () => {},
|
preventDefault: () => {
|
||||||
|
prevented = true;
|
||||||
|
},
|
||||||
target: event.target ?? null,
|
target: event.target ?? null,
|
||||||
};
|
};
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener(keyboardEvent);
|
listener(keyboardEvent);
|
||||||
}
|
}
|
||||||
|
return prevented;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchMousedown(event: {
|
function dispatchMousedown(event: {
|
||||||
@@ -485,6 +489,12 @@ function installKeyboardTestGlobals() {
|
|||||||
: null;
|
: null;
|
||||||
return target;
|
return target;
|
||||||
},
|
},
|
||||||
|
createTextInputTarget: () => {
|
||||||
|
const target = new TestElement();
|
||||||
|
target.tagName = 'INPUT';
|
||||||
|
target.closest = (selector: string) => (selector.includes('input') ? target : null);
|
||||||
|
return target;
|
||||||
|
},
|
||||||
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
||||||
getSessionBindingsImpl = value;
|
getSessionBindingsImpl = value;
|
||||||
},
|
},
|
||||||
@@ -514,6 +524,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
let openControllerDebugCount = 0;
|
let openControllerDebugCount = 0;
|
||||||
let playlistBrowserKeydownCount = 0;
|
let playlistBrowserKeydownCount = 0;
|
||||||
let changelogKeydownCount = 0;
|
let changelogKeydownCount = 0;
|
||||||
|
let jimakuKeydownCount = 0;
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -549,7 +560,10 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleCharacterDictionaryKeydown: () => false,
|
handleCharacterDictionaryKeydown: () => false,
|
||||||
handleSubsyncKeydown: () => false,
|
handleSubsyncKeydown: () => false,
|
||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => {
|
||||||
|
jimakuKeydownCount += 1;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
handleTsukihimeKeydown: () => false,
|
handleTsukihimeKeydown: () => false,
|
||||||
handleMediaTimingReviewKeydown: () => {
|
handleMediaTimingReviewKeydown: () => {
|
||||||
mediaTimingReviewKeydownCount += 1;
|
mediaTimingReviewKeydownCount += 1;
|
||||||
@@ -590,6 +604,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
openControllerDebugCount: () => openControllerDebugCount,
|
openControllerDebugCount: () => openControllerDebugCount,
|
||||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||||
changelogKeydownCount: () => changelogKeydownCount,
|
changelogKeydownCount: () => changelogKeydownCount,
|
||||||
|
jimakuKeydownCount: () => jimakuKeydownCount,
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||||
},
|
},
|
||||||
@@ -1482,6 +1497,59 @@ test('keyboard mode: changelog modal handles h/l fold keys before lookup control
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const KEYBOARD_MODE_LOOKUP_KEYS = [
|
||||||
|
{ key: 'h', code: 'KeyH' },
|
||||||
|
{ key: 'l', code: 'KeyL' },
|
||||||
|
{ key: 'ArrowLeft', code: 'ArrowLeft' },
|
||||||
|
{ key: 'ArrowRight', code: 'ArrowRight' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('keyboard mode: an open modal receives h/l/arrows instead of lookup controls', async () => {
|
||||||
|
const { ctx, testGlobals, handlers, jimakuKeydownCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
ctx.state.jimakuModalOpen = true;
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
const titleInput = testGlobals.createTextInputTarget();
|
||||||
|
|
||||||
|
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
|
||||||
|
const prevented = testGlobals.dispatchKeydown({ ...key, target: titleInput });
|
||||||
|
assert.equal(prevented, false, `${key.code} must reach the focused modal input`);
|
||||||
|
}
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.equal(jimakuKeydownCount(), KEYBOARD_MODE_LOOKUP_KEYS.length);
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h/l/arrows typed into a text field are not consumed by lookup controls', async () => {
|
||||||
|
const { ctx, testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
// No words means lookup controls would seek mpv to the adjacent subtitle.
|
||||||
|
setWordCount(0);
|
||||||
|
const input = testGlobals.createTextInputTarget();
|
||||||
|
|
||||||
|
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
|
||||||
|
const prevented = testGlobals.dispatchKeydown({ ...key, target: input });
|
||||||
|
assert.equal(prevented, false, `${key.code} must reach the text field`);
|
||||||
|
}
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -1748,7 +1816,7 @@ test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by rendere
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown', async () => {
|
test('keyboard mode: modal layer never enables keyboard mode', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1756,9 +1824,10 @@ test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown
|
|||||||
ctx.platform.isModalLayer = true;
|
ctx.platform.isModalLayer = true;
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
// Controller toggle path.
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||||
} finally {
|
} finally {
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
|||||||
@@ -408,11 +408,6 @@ export function createKeyboardHandlers(
|
|||||||
return e.ctrlKey || e.metaKey;
|
return e.ctrlKey || e.metaKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
|
|
||||||
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
|
||||||
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLookupWindowToggle(e: KeyboardEvent): boolean {
|
function isLookupWindowToggle(e: KeyboardEvent): boolean {
|
||||||
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||||
@@ -728,7 +723,10 @@ export function createKeyboardHandlers(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard mode drives the visible overlay's subtitle words. The modal layer hides
|
||||||
|
// subtitles, so enabling it there only lets arrows/H/L seek mpv from behind a modal.
|
||||||
function handleKeyboardModeToggleRequested(): void {
|
function handleKeyboardModeToggleRequested(): void {
|
||||||
|
if (ctx.platform.isModalLayer) return;
|
||||||
toggleKeyboardDrivenMode();
|
toggleKeyboardDrivenMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,12 +1147,6 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleKeyboardModeToggleRequested();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLookupWindowToggle(e)) {
|
if (isLookupWindowToggle(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleLookupWindowToggleRequested();
|
handleLookupWindowToggleRequested();
|
||||||
@@ -1167,19 +1159,11 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
|
||||||
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
|
||||||
// selection (and seek mpv) behind the open modal.
|
|
||||||
if (ctx.state.changelogModalOpen) {
|
if (ctx.state.changelogModalOpen) {
|
||||||
options.handleChangelogKeydown(e);
|
options.handleChangelogKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
return;
|
return;
|
||||||
@@ -1227,6 +1211,13 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After open modals and text fields, which own arrows/H/L; before Yomitan popup
|
||||||
|
// keybinds so word navigation keeps working while the popup is open.
|
||||||
|
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (handlePendingNumericSelection(e)) {
|
if (handlePendingNumericSelection(e)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Structured replies the app writes to `--jellyfin-response-path`. The launcher passes a temp
|
||||||
|
// path and waits for the file instead of scraping logs, because the command usually runs in an
|
||||||
|
// already-running app instance whose output never reaches the launcher's helper process.
|
||||||
|
|
||||||
|
export type JellyfinCliLibrary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
collectionType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JellyfinCliItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JellyfinPreviewAuthPayload = {
|
||||||
|
serverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JellyfinCliResponse =
|
||||||
|
| { libraries: JellyfinCliLibrary[] }
|
||||||
|
| { items: JellyfinCliItem[] }
|
||||||
|
| { error: string }
|
||||||
|
| JellyfinPreviewAuthPayload;
|
||||||
|
|
||||||
|
// Writes via rename so a polling reader never parses a half-written file.
|
||||||
|
export function writeJellyfinCliResponse(
|
||||||
|
responsePath: string,
|
||||||
|
response: JellyfinCliResponse,
|
||||||
|
): void {
|
||||||
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
|
const tempPath = `${responsePath}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(response), 'utf-8');
|
||||||
|
fs.renameSync(tempPath, responsePath);
|
||||||
|
}
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
"declarationMap": false,
|
"declarationMap": false,
|
||||||
"sourceMap": false
|
"sourceMap": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"],
|
"include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts", "e2e/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"]
|
"exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user