@groovenet/player 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MPRIS.md +97 -0
- package/OWNTONE.md +145 -0
- package/README.md +29 -0
- package/SERVER_PLAYERS.md +215 -0
- package/build/daemon.d.ts.map +1 -1
- package/build/daemon.js +42 -16
- package/build/daemon.js.map +1 -1
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/mpris.d.ts +11 -0
- package/build/mpris.d.ts.map +1 -0
- package/build/mpris.js +145 -0
- package/build/mpris.js.map +1 -0
- package/build/mprisBin.d.ts +3 -0
- package/build/mprisBin.d.ts.map +1 -0
- package/build/mprisBin.js +26 -0
- package/build/mprisBin.js.map +1 -0
- package/build/mprisMapping.d.ts +12 -0
- package/build/mprisMapping.d.ts.map +1 -0
- package/build/mprisMapping.js +35 -0
- package/build/mprisMapping.js.map +1 -0
- package/build/mpvBackend.d.ts +3 -0
- package/build/mpvBackend.d.ts.map +1 -1
- package/build/mpvBackend.js +6 -0
- package/build/mpvBackend.js.map +1 -1
- package/build/ownToneBackend.d.ts +62 -0
- package/build/ownToneBackend.d.ts.map +1 -0
- package/build/ownToneBackend.js +349 -0
- package/build/ownToneBackend.js.map +1 -0
- package/build/paths.d.ts +4 -0
- package/build/paths.d.ts.map +1 -0
- package/build/paths.js +8 -0
- package/build/paths.js.map +1 -0
- package/build/playLogger.d.ts +39 -0
- package/build/playLogger.d.ts.map +1 -0
- package/build/playLogger.js +59 -0
- package/build/playLogger.js.map +1 -0
- package/build/playThreshold.d.ts +31 -0
- package/build/playThreshold.d.ts.map +1 -0
- package/build/playThreshold.js +40 -0
- package/build/playThreshold.js.map +1 -0
- package/build/player.d.ts +1 -0
- package/build/player.d.ts.map +1 -1
- package/build/player.js +11 -0
- package/build/player.js.map +1 -1
- package/build/protocol.d.ts.map +1 -1
- package/build/protocol.js +1 -0
- package/build/protocol.js.map +1 -1
- package/build/resolver.d.ts +3 -1
- package/build/resolver.d.ts.map +1 -1
- package/build/resolver.js +30 -1
- package/build/resolver.js.map +1 -1
- package/build/server/bin.d.ts +3 -0
- package/build/server/bin.d.ts.map +1 -0
- package/build/server/bin.js +7 -0
- package/build/server/bin.js.map +1 -0
- package/build/server/http.d.ts +5 -0
- package/build/server/http.d.ts.map +1 -0
- package/build/server/http.js +129 -0
- package/build/server/http.js.map +1 -0
- package/build/server/manager.d.ts +45 -0
- package/build/server/manager.d.ts.map +1 -0
- package/build/server/manager.js +423 -0
- package/build/server/manager.js.map +1 -0
- package/build/server/observer.d.ts +9 -0
- package/build/server/observer.d.ts.map +1 -0
- package/build/server/observer.js +29 -0
- package/build/server/observer.js.map +1 -0
- package/build/server/runtime.d.ts +16 -0
- package/build/server/runtime.d.ts.map +1 -0
- package/build/server/runtime.js +78 -0
- package/build/server/runtime.js.map +1 -0
- package/build/server/store.d.ts +33 -0
- package/build/server/store.d.ts.map +1 -0
- package/build/server/store.js +108 -0
- package/build/server/store.js.map +1 -0
- package/build/types.d.ts +3 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/owntone/Dockerfile +27 -0
- package/owntone/smoke.mjs +135 -0
- package/owntone/terminal-v1.patch +96 -0
- package/package.json +24 -4
- package/scripts/mpris-smoke.mjs +54 -0
package/MPRIS.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Desktop controls — issue #650 design and smoke tests
|
|
2
|
+
|
|
3
|
+
## Linux design
|
|
4
|
+
|
|
5
|
+
`groovenet-mpris` is an opt-in foreground companion to an already running
|
|
6
|
+
`groovenet player start --detach` daemon. Install `@groovenet/player` with optional
|
|
7
|
+
dependencies enabled (the CLI also depends on this package). From a checkout,
|
|
8
|
+
build contracts → client → player, then run:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
node packages/groovenet-player/build/mprisBin.js
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The bridge uses `dbus-next` 0.10.2 directly, with native JavaScript BigInt for
|
|
15
|
+
MPRIS's signed 64-bit microsecond values. The dependency is optional and dynamically
|
|
16
|
+
loaded only on Linux with `DBUS_SESSION_BUS_ADDRESS`. No display is needed for
|
|
17
|
+
MPRIS; a user session bus is sufficient. The library's optional native Unix FD
|
|
18
|
+
addon is unnecessary for these interfaces; no audio, file descriptors or credentials
|
|
19
|
+
are sent over D-Bus. A missing dependency or unavailable bus reports a bridge error
|
|
20
|
+
and never changes daemon lifetime. Installing with `--omit=optional` leaves the
|
|
21
|
+
headless daemon usable. Nothing starts this bridge automatically.
|
|
22
|
+
|
|
23
|
+
The service owns `org.mpris.MediaPlayer2.groovenet` and exports the root and
|
|
24
|
+
Player interfaces at `/org/mpris/MediaPlayer2`. A second bridge refuses to take
|
|
25
|
+
over that name. It takes an initial `status` snapshot over the existing private
|
|
26
|
+
Unix socket, then subscribes to `state` events. Those already contain both
|
|
27
|
+
track-change and position information, so consuming their separate notifications
|
|
28
|
+
would duplicate updates. Metadata uses the queue-entry ID (hex encoded into a
|
|
29
|
+
valid object path), title, artist, album and known duration. It never publishes
|
|
30
|
+
authenticated stream URLs. Normal position updates do not emit PropertiesChanged.
|
|
31
|
+
|
|
32
|
+
Play, pause, toggle, next, previous and seeks forward to daemon RPCs. Absolute
|
|
33
|
+
seeks reject stale track IDs; relative seeks clamp to zero or skip beyond the
|
|
34
|
+
known duration. The additive `stop` RPC pauses the backend, clears current playback
|
|
35
|
+
state and retains the queue and daemon. It differs from `shutdown` and the CLI's
|
|
36
|
+
`player stop`, which still shuts down the daemon. A later play reloads the stopped
|
|
37
|
+
queue entry. Transport errors return through D-Bus rather than becoming unhandled
|
|
38
|
+
promise rejections. SIGINT/SIGTERM close the bridge connection and bus. Loss of the
|
|
39
|
+
daemon or bus closes the bridge; restart it after restarting the daemon.
|
|
40
|
+
|
|
41
|
+
References: [dbus-next](https://github.com/dbusjs/node-dbus-next),
|
|
42
|
+
[MPRIS Player specification](https://specifications.freedesktop.org/mpris/latest/Player_Interface.html).
|
|
43
|
+
|
|
44
|
+
## macOS decision and scope
|
|
45
|
+
|
|
46
|
+
macOS is deferred. Apple's
|
|
47
|
+
[MPNowPlayingInfoCenter](https://developer.apple.com/documentation/mediaplayer/mpnowplayinginfocenter)
|
|
48
|
+
and [MPRemoteCommandCenter](https://developer.apple.com/documentation/mediaplayer/mpremotecommandcenter)
|
|
49
|
+
are native MediaPlayer framework APIs. Node's standard library does not expose
|
|
50
|
+
them. Supporting them here would require a native binding or a separately built
|
|
51
|
+
Swift/Objective-C helper with its own run loop, packaging and lifecycle. That
|
|
52
|
+
exceeds the issue's lightweight integration constraint. The socket-subscriber
|
|
53
|
+
boundary allows such a helper later without changing the playback core. Running
|
|
54
|
+
this bridge on macOS prints that it is disabled and exits successfully.
|
|
55
|
+
|
|
56
|
+
Also out of scope: artwork fetching, graphical UI raising, URI loading, queue
|
|
57
|
+
editing over D-Bus, shuffle/repeat, volume control, playback rate changes,
|
|
58
|
+
automatic bridge startup/reconnection, and desktop hotkey binding installation.
|
|
59
|
+
Desktop environments usually route media keys to MPRIS; minimal window managers
|
|
60
|
+
can bind them to the `playerctl` commands below.
|
|
61
|
+
|
|
62
|
+
## Manual acceptance smoke test
|
|
63
|
+
|
|
64
|
+
In a Linux desktop session, with mpv, dbus and playerctl installed:
|
|
65
|
+
|
|
66
|
+
1. Start `groovenet player start --detach`, add two downloaded tracks with
|
|
67
|
+
`groovenet player queue add '<query>'`, and run `groovenet-mpris` in another
|
|
68
|
+
terminal (or the checkout command above).
|
|
69
|
+
2. `playerctl --player=groovenet play`; `playerctl --player=groovenet status`
|
|
70
|
+
should print `Playing`.
|
|
71
|
+
3. `playerctl --player=groovenet metadata` should show `xesam:title`,
|
|
72
|
+
`xesam:artist`, `xesam:album` and a valid `mpris:trackid`.
|
|
73
|
+
4. `playerctl --player=groovenet play-pause` should pause, then resume on a
|
|
74
|
+
second call. Verify the desktop's play/pause media key does the same.
|
|
75
|
+
5. `playerctl --player=groovenet next` and `previous` should update metadata
|
|
76
|
+
and audible playback. Verify the desktop's next/previous media keys.
|
|
77
|
+
6. `playerctl --player=groovenet position` reports seconds;
|
|
78
|
+
`position 12.5` seeks absolutely and `position 2+` seeks relatively.
|
|
79
|
+
7. `playerctl --player=groovenet stop` reports Stopped; `play` restarts the
|
|
80
|
+
retained entry. Ctrl-C the bridge; the daemon should keep playing.
|
|
81
|
+
8. Stop the daemon while the bridge is running: the bridge should exit and
|
|
82
|
+
release its bus name. Start two bridges: the second should fail cleanly.
|
|
83
|
+
9. `env -u DBUS_SESSION_BUS_ADDRESS groovenet-mpris` should exit successfully
|
|
84
|
+
with a disabled message. Repeat daemon startup without a desktop/session bus
|
|
85
|
+
and with optional npm dependencies omitted: playback must still work.
|
|
86
|
+
|
|
87
|
+
An automated Linux wire smoke test uses an in-memory audio backend and the real
|
|
88
|
+
D-Bus daemon/playerctl, requiring no server or audio device:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
dbus-run-session -- node packages/groovenet-player/scripts/mpris-smoke.mjs
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This checks status, play/pause, metadata, next/previous, absolute/relative position,
|
|
95
|
+
stop, and that closing the bridge leaves playback usable. Unit tests cover mapping,
|
|
96
|
+
property notifications, stale/invalid seeks, failures and cleanup; use
|
|
97
|
+
`npm run test:coverage --workspace @groovenet/player`.
|
package/OWNTONE.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# OwnTone backend (single output)
|
|
2
|
+
|
|
3
|
+
`OwnToneBackend` controls a **dedicated** OwnTone engine. Groovenet owns the
|
|
4
|
+
queue; OwnTone holds one indexed track at a time. mpv remains the default.
|
|
5
|
+
Persistent named players, server APIs and independent listening sessions are
|
|
6
|
+
#693; remote TUI control is #694. Synchronized speaker groups are deferred.
|
|
7
|
+
|
|
8
|
+
## Engine requirements
|
|
9
|
+
|
|
10
|
+
Stock OwnTone 29.3 does not expose a completion reason through its JSON API.
|
|
11
|
+
The backend requires the additive `groovenet_terminal` v1 extension in
|
|
12
|
+
[`owntone/terminal-v1.patch`](owntone/terminal-v1.patch). It refuses an unpatched
|
|
13
|
+
engine with `ENGINE_INCOMPATIBLE` before changing outputs or playback.
|
|
14
|
+
|
|
15
|
+
This introduces a maintained engine patch, rather than depending only on the
|
|
16
|
+
upstream container. The Dockerfile pins upstream 29.3 commit
|
|
17
|
+
`d6fb3edf5831de38134ebd92fcf09a730ddd37aa` and verifies its source archive hash.
|
|
18
|
+
The upstream license is GPL-2.0. The image includes the corresponding source,
|
|
19
|
+
license and patch at `/usr/src/owntone`; retain these when distributing it.
|
|
20
|
+
Alpine packages are resolved at build time; this is not a bit-reproducible image.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
# From the repository root:
|
|
24
|
+
docker build -t groovenet-owntone:692-test packages/groovenet-player/owntone
|
|
25
|
+
npm run build --workspace @groovenet/contracts
|
|
26
|
+
npm run build --workspace @groovenet/client
|
|
27
|
+
npm run build --workspace @groovenet/player
|
|
28
|
+
node packages/groovenet-player/owntone/smoke.mjs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The smoke test creates and removes a network-isolated container with generated
|
|
32
|
+
files and a dummy output. It publishes no ports and never uses real speakers.
|
|
33
|
+
The engine image does not start Avahi/D-Bus itself: a deployment must supply
|
|
34
|
+
those services. The smoke script shows an isolated startup sequence, **not** a
|
|
35
|
+
production Compose service. Production worker/Compose integration is #693.
|
|
36
|
+
|
|
37
|
+
For AirPlay discovery on Debian, use the LAN network/interface with Avahi and
|
|
38
|
+
D-Bus available. `bind_address = "127.0.0.1"` also restricts discovery to
|
|
39
|
+
loopback, so it is suitable only for the isolated test. Restrict engine access
|
|
40
|
+
with firewall/trusted networks and an admin password when it binds to the LAN;
|
|
41
|
+
do not expose an unauthenticated control API. The CLI's `insecure_tls` setting
|
|
42
|
+
applies to the Groovenet client/mpv, not the OwnTone connection.
|
|
43
|
+
|
|
44
|
+
Configure `speaker_autoselect = no`. The patch disables automatic speaker
|
|
45
|
+
resurrection after a failed streaming connection; only an explicit play or
|
|
46
|
+
output-selection request can select a receiver again. This does not prevent
|
|
47
|
+
another application from taking over an AirPlay receiver. Busy/auth status is
|
|
48
|
+
best-effort engine information, not a cross-application speaker reservation.
|
|
49
|
+
|
|
50
|
+
## Shared audio delivery
|
|
51
|
+
|
|
52
|
+
Mount Groovenet's audio volume **read-only** into OwnTone and configure that
|
|
53
|
+
path in its `library.directories`. Set `owntone_library` to the absolute Linux
|
|
54
|
+
path **inside the engine**, for example `/srv/media`. The CLI does not read
|
|
55
|
+
that local path. It maps each track's `local_audio_url` to an engine-side file
|
|
56
|
+
URL, then looks up the exact indexed path using `/api/library/files`.
|
|
57
|
+
Directory contents are read afresh on each load; no stale track-ID cache or
|
|
58
|
+
whole-library scan is required by the adapter.
|
|
59
|
+
|
|
60
|
+
OwnTone must finish indexing before a file can play; a missing/unindexed or
|
|
61
|
+
ambiguous file is refused. The backend does not force a library rescan. It
|
|
62
|
+
accepts finite indexed files with a known positive duration, not Internet radio
|
|
63
|
+
or HTTP streams. Standard relative names, `/audio/` and `/app/audio/` prefixes,
|
|
64
|
+
and legacy app audio URLs are supported. Traversal, backslashes, NULs and
|
|
65
|
+
ambiguous path segments are refused. There is no basename fallback.
|
|
66
|
+
|
|
67
|
+
Groovenet credentials remain on Groovenet API requests. Indexed file delivery
|
|
68
|
+
needs no bearer token; sources containing HTTP headers or non-file URLs are
|
|
69
|
+
explicitly rejected, never forwarded with authentication silently removed.
|
|
70
|
+
|
|
71
|
+
## CLI setup
|
|
72
|
+
|
|
73
|
+
Run the daemon on the host that should control OwnTone. These settings apply
|
|
74
|
+
when a daemon starts; stop an existing daemon before changing backends.
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
groovenet config set owntone_url http://127.0.0.1:3689/api
|
|
78
|
+
groovenet config set owntone_library /srv/media
|
|
79
|
+
groovenet config set player_backend owntone
|
|
80
|
+
groovenet player start --detach
|
|
81
|
+
groovenet player devices list
|
|
82
|
+
groovenet player devices select <output-id>
|
|
83
|
+
groovenet player queue add "miles davis"
|
|
84
|
+
groovenet player play
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Alternatively keep `player_backend` unset and pass `--backend owntone` to
|
|
88
|
+
`player` or `player start`. Set `owntone_output` to remember one stable engine
|
|
89
|
+
output ID for subsequent starts. `owntone_password` supplies HTTP Basic admin
|
|
90
|
+
authentication; keep the CLI config private. Human config output masks it;
|
|
91
|
+
`config show --json`, like the existing API-key behavior, includes credentials.
|
|
92
|
+
`--backend mpv` overrides the persisted choice. Artwork is an mpv-only setting.
|
|
93
|
+
|
|
94
|
+
The Unix socket and its v1 protocol are unchanged. A TUI connects to an existing
|
|
95
|
+
daemon regardless of the requested backend; restart it to change engines.
|
|
96
|
+
This is still a local daemon, not the remote server-player API from #693.
|
|
97
|
+
|
|
98
|
+
## Events, interruption and cleanup
|
|
99
|
+
|
|
100
|
+
The patch snapshots a random per-process instance ID, sequence, queue item ID
|
|
101
|
+
and terminal reason on the engine player thread. EOF is recorded only at the
|
|
102
|
+
playback boundary; input failures and failed/lost output callbacks mark the
|
|
103
|
+
source as failed, suppressing successful EOF. Stop and skip are not inferred
|
|
104
|
+
from elapsed time, play counts or an empty engine queue. The adapter emits
|
|
105
|
+
`ended` only for a new EOF of its owned item in the same engine instance, with
|
|
106
|
+
no foreign replacement queue. Duplicate/stale events cannot advance twice.
|
|
107
|
+
|
|
108
|
+
All adapter commands and polls are serialized. API outages trigger read-only
|
|
109
|
+
reconciliation: no load, resume or output selection is retried. A still-owned
|
|
110
|
+
active item can be controlled explicitly after recovery. The core retains its
|
|
111
|
+
error state until an explicit playback command. A completion observed across
|
|
112
|
+
a connection gap is treated as uncertain and **does not advance**. A restarted
|
|
113
|
+
engine, replaced queue or terminal interruption drops ownership and requires
|
|
114
|
+
an explicit new play. This favors preserving the queue over guessing EOF.
|
|
115
|
+
|
|
116
|
+
`close()` stops/clears/releases only a still-owned or completed session (or an
|
|
117
|
+
idle explicit output selection), and leaves a foreign session/output alone.
|
|
118
|
+
After ownership is lost or the API remains unreachable, cleanup cannot promise
|
|
119
|
+
to stop the engine; use the engine controls if necessary. The engine must not
|
|
120
|
+
be shared with another queue controller. Separate engines are needed for
|
|
121
|
+
independent player queues; concurrent instances remain unvalidated.
|
|
122
|
+
|
|
123
|
+
Typed errors include `ENGINE_UNAVAILABLE`, `ENGINE_AUTH`, `ENGINE_TIMEOUT`,
|
|
124
|
+
`ENGINE_API`, `ENGINE_INCOMPATIBLE`, `DEVICE_UNAVAILABLE`, `DEVICE_BUSY`,
|
|
125
|
+
`DEVICE_AUTH`, `NO_AUDIO`, `STREAM_FAILED` and `PLAYBACK_INTERRUPTED`.
|
|
126
|
+
Authentication of AirPlay receivers is managed in OwnTone, outside Groovenet.
|
|
127
|
+
|
|
128
|
+
## Volume and verification limits
|
|
129
|
+
|
|
130
|
+
`setVolume` sends 0–100 to the selected engine output. It cannot guarantee an
|
|
131
|
+
acoustic level. Lounge Room Audio uses Shairport's
|
|
132
|
+
`ignore_volume_control = "yes"`, so sender volume does **not** attenuate its
|
|
133
|
+
USB output. The snapshot volume is the last requested Groovenet value, not a
|
|
134
|
+
measured receiver level; OwnTone may report -1 when no output is selected.
|
|
135
|
+
Generate attenuated audio for hardware tests, rather than relying on volume 5.
|
|
136
|
+
|
|
137
|
+
The #691 prototype confirmed audible single-speaker AirPlay on Lounge Room
|
|
138
|
+
Audio, plus engine pause/seek/resume and USB PCM activity. That used the stock
|
|
139
|
+
29.3 image, not this patched build. The new adapter/patch has isolated dummy
|
|
140
|
+
output validation for natural EOF, external stop and queue replacement,
|
|
141
|
+
pause/resume/seek/volume API acceptance, and engine restart handling. Fake API
|
|
142
|
+
tests cover transport/auth/timeouts, ownership, stale events and reconciliation.
|
|
143
|
+
Patched-engine AirPlay takeover, receiver disconnect, effective volume and
|
|
144
|
+
audible seek accuracy still need hardware verification. No synchronized or
|
|
145
|
+
concurrent-engine playback is claimed.
|
package/README.md
CHANGED
|
@@ -33,10 +33,39 @@ Tracks without downloaded audio are skipped with a typed `NO_AUDIO` error.
|
|
|
33
33
|
`insecureTls` is forwarded as mpv's `--tls-verify=no` setting for installations
|
|
34
34
|
using a self-signed server certificate.
|
|
35
35
|
|
|
36
|
+
By default mpv is started with `--vid=no`, so embedded cover art does not open
|
|
37
|
+
a window (the player has no artwork view, and the daemon may have no display).
|
|
38
|
+
Pass `artwork: true` to `MpvBackend` to keep mpv's cover-art window. The
|
|
39
|
+
Groovenet CLI exposes this as the `player_artwork` config key and the
|
|
40
|
+
`--artwork` flag; a running daemon must be restarted to pick up a change.
|
|
41
|
+
|
|
42
|
+
`PlayLogger` is an opt-in subscriber that turns a `PlayerCore`'s events into
|
|
43
|
+
play records without the core knowing about it. `playThreshold`, `advanceProgress`
|
|
44
|
+
and `isPlay` are the pure rules: a track of 30 seconds or more counts after
|
|
45
|
+
`min(50% of its duration, 240 s)` of accumulated play time, where only forward
|
|
46
|
+
progress while playing is counted. The CLI wires it to `POST /api/spins` with
|
|
47
|
+
`medium: "digital"` (`player_log_plays` / `--log-plays`).
|
|
48
|
+
|
|
36
49
|
`MpvBackend` does not install mpv. Install it separately and make it available
|
|
37
50
|
on `PATH`, or pass an executable path in the constructor. `close()` stops the
|
|
38
51
|
owned process. The `TestPlaybackBackend` from `@groovenet/player/testing` is a small in-memory
|
|
39
52
|
implementation for consumers that need to exercise the core without audio.
|
|
40
53
|
|
|
54
|
+
`defaultPlayerPaths()` returns the daemon's socket and pidfile paths (`~/.groovenet/player/`), shared by the CLI and the MCP server.
|
|
55
|
+
|
|
41
56
|
Build with `npm run build --workspace=@groovenet/player` after building
|
|
42
57
|
`@groovenet/client`.
|
|
58
|
+
|
|
59
|
+
Linux desktop media controls are available through the optional `groovenet-mpris`
|
|
60
|
+
companion process. It subscribes to an existing daemon over its socket and exposes
|
|
61
|
+
MPRIS for `playerctl` and desktop media keys. It is off unless explicitly started,
|
|
62
|
+
and exits disabled without a Linux session bus. See [MPRIS.md](MPRIS.md) for setup,
|
|
63
|
+
the platform design (including the macOS deferral), and smoke tests.
|
|
64
|
+
|
|
65
|
+
Persistent Debian server players with independent queues and a shared control
|
|
66
|
+
API are documented in [SERVER_PLAYERS.md](SERVER_PLAYERS.md).
|
|
67
|
+
|
|
68
|
+
An opt-in `OwnToneBackend` supports one selected AirPlay output through a
|
|
69
|
+
dedicated patched OwnTone engine. See [OWNTONE.md](OWNTONE.md) for the pinned
|
|
70
|
+
engine build, shared read-only audio mapping, CLI setup, completion semantics
|
|
71
|
+
and verification limits. mpv remains the default.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Persistent server players
|
|
2
|
+
|
|
3
|
+
Server players run on the Debian Docker host. Closing a laptop, TUI or event
|
|
4
|
+
subscription leaves playback running. Each named player has its own persistent
|
|
5
|
+
queue and one dedicated OwnTone engine. Different players can use different
|
|
6
|
+
speakers concurrently; synchronized groups are deferred. This is the backend
|
|
7
|
+
for #693; remote TUI controls are tracked in #694. The web UI can control these sessions through an optional household browser
|
|
8
|
+
session; see [web controls](../../my-collection-search/docs/server-player-web.md).
|
|
9
|
+
There is no iOS UI yet.
|
|
10
|
+
|
|
11
|
+
## Deployment
|
|
12
|
+
|
|
13
|
+
Use the repository's existing Debian Compose stack and apply migrations first
|
|
14
|
+
(`just migrate-up` with the stack's normal environment). Do not start a second
|
|
15
|
+
stack against the production database volume. Add `docker-compose.players.yml`
|
|
16
|
+
after your usual Compose files and enable profile `players`. This extension is
|
|
17
|
+
optional; the ordinary stack does not start any player engines.
|
|
18
|
+
|
|
19
|
+
For production, put the following player settings in a private root `.env.players`
|
|
20
|
+
(`chmod 600 .env.players`), using `.env.example` as the reference. Its presence
|
|
21
|
+
opts the production deploy script into the player Compose overlay/profile and
|
|
22
|
+
builds the two local images before starting services. It survives the normal
|
|
23
|
+
`.env` rendering step. Keep the existing database/collection secrets in `.env`.
|
|
24
|
+
Player secrets and private worktree files are excluded from Docker build contexts.
|
|
25
|
+
|
|
26
|
+
The optional `.env.players.tpl` uses the existing `GROOVENET_OP_VAULT` /
|
|
27
|
+
`GROOVENET_OP_ITEM` selection (defaults `Homelab` / `groovenet`). Add three fields
|
|
28
|
+
to that 1Password item: `PLAYER_API_KEY`, `PLAYER_ENGINE_A_PASSWORD`, and
|
|
29
|
+
`PLAYER_ENGINE_B_PASSWORD`. API keys must be at least 32 characters on one line;
|
|
30
|
+
engine passwords must be distinct hex/base64url values. To render locally:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
mkdir -p .worktree
|
|
34
|
+
op run --env-file .env.players.tpl -- node my-collection-search/scripts/render-player-env.mjs .worktree/players.env
|
|
35
|
+
# Copy to the production checkout as .env.players with mode 0600.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The renderer validates credentials before replacing any existing file, writes
|
|
39
|
+
atomically with private permissions, and constructs the engine JSON from the same
|
|
40
|
+
passwords. It quotes literal dollar signs/backslashes so Compose preserves secret
|
|
41
|
+
values. Never commit the rendered file. `just deploy` refreshes `.env.players`
|
|
42
|
+
from 1Password only on a host that already has that file; other hosts stay opted
|
|
43
|
+
out. The ordinary collection credential template is unchanged.
|
|
44
|
+
|
|
45
|
+
For the laptop CLI, load only the API key (never the engine passwords):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
PLAYER_API_KEY='op://Homelab/groovenet/PLAYER_API_KEY' op run -- groovenet player list
|
|
49
|
+
PLAYER_API_KEY='op://Homelab/groovenet/PLAYER_API_KEY' op run --no-masking -- groovenet player --remote lounge
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use the installed executable or the built Node entrypoint inside `op run`; shell
|
|
53
|
+
functions such as `gn` are not executables that `op run` can invoke directly.
|
|
54
|
+
Existing CLI collection credentials remain independent. For the interactive TUI,
|
|
55
|
+
`op run` needs `--no-masking`: its default output-masking pipe makes stdout cease
|
|
56
|
+
to be a TTY. This option passes terminal output through directly; the player UI
|
|
57
|
+
does not print credentials. Keep default masking for noninteractive commands.
|
|
58
|
+
|
|
59
|
+
Configure player settings:
|
|
60
|
+
|
|
61
|
+
* Generate `PLAYER_API_KEY` with `openssl rand -hex 32`. It is a **shared
|
|
62
|
+
household/admin credential**, with access to every player and friend’s queued
|
|
63
|
+
track references. All reads, commands and SSE require it. Existing browser
|
|
64
|
+
cookies and the ordinary app API key do not grant player access. The opt-in
|
|
65
|
+
web bridge issues a separate player-only session after web-passphrase
|
|
66
|
+
authorization; it never shares this worker key with browsers. Give it only
|
|
67
|
+
to trusted controllers and use HTTPS when reaching the app across a network.
|
|
68
|
+
* Generate distinct hex/base64url `PLAYER_ENGINE_A_PASSWORD` and
|
|
69
|
+
`PLAYER_ENGINE_B_PASSWORD` values (`openssl rand -hex 24`).
|
|
70
|
+
* Set `PLAYER_LAN_INTERFACE` to the Debian host's actual speaker-facing interface.
|
|
71
|
+
Engines and the shared clock use host networking for discovery/AirPlay.
|
|
72
|
+
Reserve API ports 13689/13690, WebSocket ports 13688/13691 and PTP UDP 319/320.
|
|
73
|
+
Restrict engine APIs to trusted host/network access; never expose them publicly.
|
|
74
|
+
* Set `PLAYER_AUDIO_MOUNT=music_data` with the production overlay: this logical
|
|
75
|
+
Compose volume maps to the existing `PROD_MUSIC_DATA_VOLUME`. Alternatively use
|
|
76
|
+
an absolute directory visible to Docker. Do not supply an undeclared Docker
|
|
77
|
+
volume name. The mount is read-only. OwnTone must finish indexing before play.
|
|
78
|
+
* Set `PLAYER_ENGINES_JSON` to the following single-line JSON, replacing passwords
|
|
79
|
+
to match the engine environment variables. These credentials stay server-side.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{"primary":{"apiUrl":"http://host.docker.internal:13689/api","password":"A_PASSWORD","libraryRoot":"/srv/media"},"secondary":{"apiUrl":"http://host.docker.internal:13690/api","password":"B_PASSWORD","libraryRoot":"/srv/media"}}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For example, with the existing production environment loaded:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
docker compose --env-file .env --env-file .env.players -p dj-playlist -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.players.yml --profile players build player-clock player-worker
|
|
89
|
+
docker compose --env-file .env --env-file .env.players -p dj-playlist -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.players.yml --profile players up -d app player-clock player-engine-a player-engine-b player-worker
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The worker has no published port: the app proxies to `http://player-worker:13700`.
|
|
93
|
+
The two engine cache databases use separate persistent volumes. Both engines
|
|
94
|
+
must share the clock's IPC namespace (`ipc: service:player-clock`): AirPlay 2
|
|
95
|
+
requires `/dev/shm/airptp_shm`, as well as network access to the clock. The engine
|
|
96
|
+
entrypoint refuses startup without it. Run only one such shared clock per host.
|
|
97
|
+
|
|
98
|
+
Outside Compose, build packages with `just build-packages`, then run
|
|
99
|
+
`groovenet-player-server` with `DATABASE_URL`, `PLAYER_API_KEY` and
|
|
100
|
+
`PLAYER_ENGINES_JSON`. Set the app's `PLAYER_WORKER_URL` to that private worker.
|
|
101
|
+
The standalone worker binds `0.0.0.0:13700` (override `PLAYER_WORKER_PORT`), so
|
|
102
|
+
restrict access at the host/network boundary.
|
|
103
|
+
|
|
104
|
+
## Control API
|
|
105
|
+
|
|
106
|
+
All public paths start with `/api/players`. Supply
|
|
107
|
+
`Authorization: Bearer <PLAYER_API_KEY>` on every request.
|
|
108
|
+
|
|
109
|
+
| Method and path | Purpose |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `GET /api/players` | List persistent players |
|
|
112
|
+
| `GET /api/players/engines` | List configured engine IDs |
|
|
113
|
+
| `POST /api/players` | Create `{ "id": "lounge", "name": "Lounge", "engine_id": "primary" }` |
|
|
114
|
+
| `GET /api/players/lounge` | Get full state and revision |
|
|
115
|
+
| `GET /api/players/lounge/devices` | Discovered outputs, assignments and observations |
|
|
116
|
+
| `POST /api/players/lounge/commands` | Apply a revisioned command |
|
|
117
|
+
| `GET /api/players/lounge/events` | SSE snapshots and lightweight position updates |
|
|
118
|
+
|
|
119
|
+
Each engine can belong to only one named player. Names can be changed; player
|
|
120
|
+
IDs and engine assignments are fixed. Engine configuration is an operator
|
|
121
|
+
allowlist, never a client-provided URL. Player deletion is not implemented.
|
|
122
|
+
|
|
123
|
+
Commands include `expected_revision` from the most recent snapshot. A stale
|
|
124
|
+
revision returns `REVISION_CONFLICT`; fetch fresh state before deciding whether
|
|
125
|
+
to retry. Position ticks do not change the edit revision. For example:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{"type":"queue.add","expected_revision":0,"tracks":[{"track_id":"TRACK_ID","friend_id":1}]}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
After each command, use its returned player revision for the next command:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{"type":"output.select","expected_revision":1,"output_id":"OUTPUT_ID"}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{"type":"play","expected_revision":2,"allow_unknown":true}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Selecting an output remembers intent; it does not connect or reserve the
|
|
142
|
+
speaker. Playback requires indexed local audio, resolved afresh from Postgres.
|
|
143
|
+
Queue entries get stable UUIDs, including repeated copies of the same track.
|
|
144
|
+
Use those entry IDs for `queue.remove`, `queue.move` and an explicit `play`.
|
|
145
|
+
Other commands are `rename`, `queue.clear`, `pause`, `resume`, `stop`, `next`,
|
|
146
|
+
`previous`, `seek` (seconds) and `volume` (0–100). Stop clears the current entry
|
|
147
|
+
and position while preserving the queue. Natural EOF advances only that player's
|
|
148
|
+
queue; interruption/error preserves the current entry and requires a command.
|
|
149
|
+
|
|
150
|
+
SSE begins with a complete `snapshot`. `position` events include player ID,
|
|
151
|
+
revision, current entry ID and observation time; apply them only to a matching
|
|
152
|
+
snapshot revision/entry. Each event also carries worker instance and sequence.
|
|
153
|
+
Reconnect for a fresh snapshot; there is no event replay. Slow clients are
|
|
154
|
+
disconnected, and client disconnection never stops audio. The SDK exposes the
|
|
155
|
+
list/create/get/command/devices/engines requests; SSE can be consumed directly.
|
|
156
|
+
|
|
157
|
+
## Speaker activity and transfers
|
|
158
|
+
|
|
159
|
+
Optionally configure `PLAYER_HA_URL`, `PLAYER_HA_TOKEN` and an output-to-entity
|
|
160
|
+
map such as `PLAYER_HA_ENTITIES_JSON={"OUTPUT_ID":"media_player.lounge"}`.
|
|
161
|
+
The observer only reads HA state, with a 2-second timeout and 5-second cache.
|
|
162
|
+
`idle`, `off`, `standby` mean observed idle; active/paused states mean occupied;
|
|
163
|
+
unavailable, missing configuration and failed observations mean unknown.
|
|
164
|
+
HA does not prove which app is sending audio. Observations can race another app.
|
|
165
|
+
|
|
166
|
+
Connecting to unknown activity requires explicit `allow_unknown: true`.
|
|
167
|
+
Occupied activity requires `takeover: true`. A paused engine may release its
|
|
168
|
+
AirPlay transport, so resuming may require another availability decision.
|
|
169
|
+
Neither acknowledgement can steal another Groovenet player's assignment.
|
|
170
|
+
For that, send `output.transfer` to the destination player:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{"type":"output.transfer","expected_revision":7,"from_player_id":"lounge","from_revision":12,"output_id":"OUTPUT_ID","play":true,"allow_unknown":true}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Transfer detaches the source first and keeps each player's own queue and
|
|
177
|
+
checkpoint. `play: false` only changes the destination's desired output.
|
|
178
|
+
Inspect `transfer.detached`, `attached` and `error` even when HTTP status is 200:
|
|
179
|
+
the source can be detached while destination playback fails. It is never
|
|
180
|
+
automatically reconnected to hide that partial result.
|
|
181
|
+
|
|
182
|
+
## Restarts and recovery
|
|
183
|
+
|
|
184
|
+
Postgres holds names, independent queues, current entry, volume, desired output
|
|
185
|
+
and position checkpoints (at most once per five seconds, plus command/shutdown
|
|
186
|
+
writes). Only one worker holds the database lifecycle lease. Commands and engine
|
|
187
|
+
events share a serial lane; a slow engine can delay commands to other players.
|
|
188
|
+
|
|
189
|
+
A worker restart restores **stopped checkpoints**, clears transient assignments
|
|
190
|
+
and creates no engine connections. It never resumes or reclaims a speaker
|
|
191
|
+
automatically. `checkpoint: true` means saved position, not observed live audio.
|
|
192
|
+
An abrupt crash can leave an old engine playing; stop that engine explicitly
|
|
193
|
+
before issuing new playback if its ownership cannot be established. Do not use
|
|
194
|
+
the dedicated engines from other controller apps.
|
|
195
|
+
|
|
196
|
+
When a receiver or input fails, the worker stops, clears and releases only a
|
|
197
|
+
verified failed engine session. It rechecks the engine instance, queue version,
|
|
198
|
+
item and terminal failure sequence, and refuses to clean up a resumed/replaced
|
|
199
|
+
session or another selected output. The named player's queue/current entry and
|
|
200
|
+
checkpoint remain intact; it never automatically resumes or advances after a
|
|
201
|
+
failure. Once the receiver is ready, explicitly play again (with any required
|
|
202
|
+
availability acknowledgement). Queue and settings edits preserve the playback
|
|
203
|
+
error until a successful transport action leaves the error state.
|
|
204
|
+
|
|
205
|
+
If engine cleanup fails, an uncertain assignment remains reserved and commands
|
|
206
|
+
return `RELEASE_UNCERTAIN`. Stop/restart the affected engine, then restart the
|
|
207
|
+
worker to restore stopped checkpoints and clear the claim. Investigate DB/engine
|
|
208
|
+
connectivity before retrying. Do not run a second worker to bypass a lease error.
|
|
209
|
+
|
|
210
|
+
Some receivers ignore AirPlay volume. The saved volume is the requested sender
|
|
211
|
+
value, not a measured loudness guarantee. The implementation does not promise
|
|
212
|
+
synchronization, automatic recovery after receiver outages, or Sonos validation.
|
|
213
|
+
|
|
214
|
+
Validation and the successful two-room hardware results are recorded in
|
|
215
|
+
[evaluations/693](../../../evaluations/693/README.md).
|
package/build/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAe,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAGpF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,iBAAiB,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,YAAY,CAAC;IACnC,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CAC3C;AAED,6EAA6E;AAC7E,qBAAa,YAAY;
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAe,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAGpF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,iBAAiB,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,YAAY,CAAC;IACnC,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CAC3C;AAED,6EAA6E;AAC7E,qBAAa,YAAY;IAuBX,OAAO,CAAC,QAAQ,CAAC,OAAO;IAtBpC,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA2B;IACzD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2B;IAC1D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAU;IAC9B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAO9B;IACF,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA6D;IACjG,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA4D;IAC9F,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAGjC;IAEF,YAA6B,OAAO,EAAE,mBAAmB,EAMxD;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAiB3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAqB1B;YAEa,iBAAiB;YAkBjB,cAAc;IAiB5B,OAAO,CAAC,MAAM;YAeA,UAAU;YAuCV,QAAQ;IAiDtB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,SAAS;CAIlB"}
|
package/build/daemon.js
CHANGED
|
@@ -24,7 +24,10 @@ export class PlayerDaemon {
|
|
|
24
24
|
};
|
|
25
25
|
broadcastTrackStart = (entry) => this.broadcast("track-change", entry);
|
|
26
26
|
broadcastTrackEnd = (entry) => this.broadcast("track-ended", entry);
|
|
27
|
-
broadcastTrackSkip = (entry, error) =>
|
|
27
|
+
broadcastTrackSkip = (entry, error) => {
|
|
28
|
+
const playerError = error;
|
|
29
|
+
this.broadcast("error", { entry, error: { code: playerError.code, message: playerError.message } });
|
|
30
|
+
};
|
|
28
31
|
constructor(options) {
|
|
29
32
|
this.options = options;
|
|
30
33
|
this.createServer = options.createServer ?? createServer;
|
|
@@ -72,11 +75,15 @@ export class PlayerDaemon {
|
|
|
72
75
|
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
73
76
|
}
|
|
74
77
|
this.server = null;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
try {
|
|
79
|
+
await this.options.player.close();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await Promise.all([
|
|
83
|
+
unlink(this.options.paths.socketPath).catch(() => undefined),
|
|
84
|
+
unlink(this.options.paths.pidPath).catch(() => undefined),
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
80
87
|
}
|
|
81
88
|
async recoverStaleFiles() {
|
|
82
89
|
let pid = null;
|
|
@@ -86,8 +93,11 @@ export class PlayerDaemon {
|
|
|
86
93
|
pid = parsed;
|
|
87
94
|
}
|
|
88
95
|
catch { /* no pidfile */ }
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
// The socket is authoritative. A missing, malformed, or stale pidfile must
|
|
97
|
+
// never cause us to unlink a live daemon's socket.
|
|
98
|
+
if (await this.socketIsActive()) {
|
|
99
|
+
const owner = pid !== null && this.isProcessAlive(pid) ? ` (pid ${pid})` : "";
|
|
100
|
+
throw new Error(`Player daemon already appears to be running${owner}.`);
|
|
91
101
|
}
|
|
92
102
|
await Promise.all([
|
|
93
103
|
unlink(this.options.paths.pidPath).catch(() => undefined),
|
|
@@ -126,32 +136,45 @@ export class PlayerDaemon {
|
|
|
126
136
|
socket.on("close", () => this.clients.delete(socket));
|
|
127
137
|
}
|
|
128
138
|
async handleLine(socket, line) {
|
|
129
|
-
let
|
|
139
|
+
let value;
|
|
130
140
|
try {
|
|
131
|
-
|
|
141
|
+
value = JSON.parse(line);
|
|
132
142
|
}
|
|
133
143
|
catch {
|
|
134
144
|
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id: 0, error: { code: "INVALID_REQUEST", message: "Request must be a JSON object." } }));
|
|
135
145
|
return;
|
|
136
146
|
}
|
|
137
|
-
if (
|
|
147
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
148
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id: 0, error: { code: "INVALID_REQUEST", message: "Request must be a JSON object." } }));
|
|
138
149
|
return;
|
|
150
|
+
}
|
|
151
|
+
const request = value;
|
|
152
|
+
const id = Number.isInteger(request.id) ? request.id : 0;
|
|
139
153
|
if (request.jsonrpc !== "2.0") {
|
|
140
|
-
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id
|
|
154
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, error: { code: "INVALID_REQUEST", message: "Expected JSON-RPC version 2.0." } }));
|
|
141
155
|
return;
|
|
142
156
|
}
|
|
143
157
|
if (request.version !== PLAYER_PROTOCOL_VERSION) {
|
|
144
|
-
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id
|
|
158
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, error: { code: "VERSION_MISMATCH", message: `Unsupported player protocol version ${request.version}; this daemon supports version ${PLAYER_PROTOCOL_VERSION}.` } }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isInteger(request.id) || typeof request.method !== "string" || !request.method) {
|
|
162
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, error: { code: "INVALID_REQUEST", message: "Request requires an integer id and a non-empty method." } }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const params = request.params === undefined ? {} : request.params;
|
|
166
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
167
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, error: { code: "INVALID_REQUEST", message: "Request params must be a JSON object." } }));
|
|
145
168
|
return;
|
|
146
169
|
}
|
|
147
170
|
try {
|
|
148
|
-
const result = await this.dispatch(request.method,
|
|
149
|
-
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id
|
|
171
|
+
const result = await this.dispatch(request.method, params);
|
|
172
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, result }));
|
|
150
173
|
}
|
|
151
174
|
catch (error) {
|
|
152
175
|
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "PLAYER_ERROR";
|
|
153
176
|
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
-
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id
|
|
177
|
+
socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id, error: { code, message } }));
|
|
155
178
|
}
|
|
156
179
|
}
|
|
157
180
|
async dispatch(method, params) {
|
|
@@ -170,6 +193,9 @@ export class PlayerDaemon {
|
|
|
170
193
|
case "pause":
|
|
171
194
|
await player.pause();
|
|
172
195
|
return player.snapshot;
|
|
196
|
+
case "stop":
|
|
197
|
+
await player.stop();
|
|
198
|
+
return player.snapshot;
|
|
173
199
|
case "resume":
|
|
174
200
|
await player.resume();
|
|
175
201
|
return player.snapshot;
|