@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.
Files changed (86) hide show
  1. package/MPRIS.md +97 -0
  2. package/OWNTONE.md +145 -0
  3. package/README.md +29 -0
  4. package/SERVER_PLAYERS.md +215 -0
  5. package/build/daemon.d.ts.map +1 -1
  6. package/build/daemon.js +42 -16
  7. package/build/daemon.js.map +1 -1
  8. package/build/index.d.ts +4 -0
  9. package/build/index.d.ts.map +1 -1
  10. package/build/index.js +4 -0
  11. package/build/index.js.map +1 -1
  12. package/build/mpris.d.ts +11 -0
  13. package/build/mpris.d.ts.map +1 -0
  14. package/build/mpris.js +145 -0
  15. package/build/mpris.js.map +1 -0
  16. package/build/mprisBin.d.ts +3 -0
  17. package/build/mprisBin.d.ts.map +1 -0
  18. package/build/mprisBin.js +26 -0
  19. package/build/mprisBin.js.map +1 -0
  20. package/build/mprisMapping.d.ts +12 -0
  21. package/build/mprisMapping.d.ts.map +1 -0
  22. package/build/mprisMapping.js +35 -0
  23. package/build/mprisMapping.js.map +1 -0
  24. package/build/mpvBackend.d.ts +3 -0
  25. package/build/mpvBackend.d.ts.map +1 -1
  26. package/build/mpvBackend.js +6 -0
  27. package/build/mpvBackend.js.map +1 -1
  28. package/build/ownToneBackend.d.ts +62 -0
  29. package/build/ownToneBackend.d.ts.map +1 -0
  30. package/build/ownToneBackend.js +349 -0
  31. package/build/ownToneBackend.js.map +1 -0
  32. package/build/paths.d.ts +4 -0
  33. package/build/paths.d.ts.map +1 -0
  34. package/build/paths.js +8 -0
  35. package/build/paths.js.map +1 -0
  36. package/build/playLogger.d.ts +39 -0
  37. package/build/playLogger.d.ts.map +1 -0
  38. package/build/playLogger.js +59 -0
  39. package/build/playLogger.js.map +1 -0
  40. package/build/playThreshold.d.ts +31 -0
  41. package/build/playThreshold.d.ts.map +1 -0
  42. package/build/playThreshold.js +40 -0
  43. package/build/playThreshold.js.map +1 -0
  44. package/build/player.d.ts +1 -0
  45. package/build/player.d.ts.map +1 -1
  46. package/build/player.js +11 -0
  47. package/build/player.js.map +1 -1
  48. package/build/protocol.d.ts.map +1 -1
  49. package/build/protocol.js +1 -0
  50. package/build/protocol.js.map +1 -1
  51. package/build/resolver.d.ts +3 -1
  52. package/build/resolver.d.ts.map +1 -1
  53. package/build/resolver.js +30 -1
  54. package/build/resolver.js.map +1 -1
  55. package/build/server/bin.d.ts +3 -0
  56. package/build/server/bin.d.ts.map +1 -0
  57. package/build/server/bin.js +7 -0
  58. package/build/server/bin.js.map +1 -0
  59. package/build/server/http.d.ts +5 -0
  60. package/build/server/http.d.ts.map +1 -0
  61. package/build/server/http.js +129 -0
  62. package/build/server/http.js.map +1 -0
  63. package/build/server/manager.d.ts +45 -0
  64. package/build/server/manager.d.ts.map +1 -0
  65. package/build/server/manager.js +423 -0
  66. package/build/server/manager.js.map +1 -0
  67. package/build/server/observer.d.ts +9 -0
  68. package/build/server/observer.d.ts.map +1 -0
  69. package/build/server/observer.js +29 -0
  70. package/build/server/observer.js.map +1 -0
  71. package/build/server/runtime.d.ts +16 -0
  72. package/build/server/runtime.d.ts.map +1 -0
  73. package/build/server/runtime.js +78 -0
  74. package/build/server/runtime.js.map +1 -0
  75. package/build/server/store.d.ts +33 -0
  76. package/build/server/store.d.ts.map +1 -0
  77. package/build/server/store.js +108 -0
  78. package/build/server/store.js.map +1 -0
  79. package/build/types.d.ts +3 -1
  80. package/build/types.d.ts.map +1 -1
  81. package/build/types.js.map +1 -1
  82. package/owntone/Dockerfile +27 -0
  83. package/owntone/smoke.mjs +135 -0
  84. package/owntone/terminal-v1.patch +96 -0
  85. package/package.json +24 -4
  86. 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).
@@ -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;IAoBX,OAAO,CAAC,QAAQ,CAAC,OAAO;IAnBpC,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,CAAiF;IAEpH,YAA6B,OAAO,EAAE,mBAAmB,EAMxD;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAiB3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAkB1B;YAEa,iBAAiB;YAejB,cAAc;IAiB5B,OAAO,CAAC,MAAM;YAeA,UAAU;YAyBV,QAAQ;IAgDtB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,SAAS;CAIlB"}
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) => this.broadcast("error", { 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
- await this.options.player.close();
76
- await Promise.all([
77
- unlink(this.options.paths.socketPath).catch(() => undefined),
78
- unlink(this.options.paths.pidPath).catch(() => undefined),
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
- if (pid !== null && this.isProcessAlive(pid) && await this.socketIsActive()) {
90
- throw new Error(`Player daemon already appears to be running (pid ${pid}).`);
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 request;
139
+ let value;
130
140
  try {
131
- request = JSON.parse(line);
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 (!Number.isInteger(request.id))
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: request.id, error: { code: "INVALID_REQUEST", message: "Expected JSON-RPC version 2.0." } }));
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: request.id, error: { code: "VERSION_MISMATCH", message: `Unsupported player protocol version ${request.version}; this daemon supports version ${PLAYER_PROTOCOL_VERSION}.` } }));
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, request.params ?? {});
149
- socket.write(encodeRpcResponse({ version: PLAYER_PROTOCOL_VERSION, id: request.id, result }));
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: request.id, error: { code, message } }));
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;