snapclientmpris 1.2.1__tar.gz
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.
- snapclientmpris-1.2.1/LICENSE +22 -0
- snapclientmpris-1.2.1/PKG-INFO +314 -0
- snapclientmpris-1.2.1/README.md +293 -0
- snapclientmpris-1.2.1/pyproject.toml +75 -0
- snapclientmpris-1.2.1/setup.cfg +4 -0
- snapclientmpris-1.2.1/snapclientmpris/__init__.py +1 -0
- snapclientmpris-1.2.1/snapclientmpris/__main__.py +5 -0
- snapclientmpris-1.2.1/snapclientmpris/cli.py +245 -0
- snapclientmpris-1.2.1/snapclientmpris/mpris.py +295 -0
- snapclientmpris-1.2.1/snapclientmpris/snapclientmpris.py +409 -0
- snapclientmpris-1.2.1/snapclientmpris/translate.py +83 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/PKG-INFO +314 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/SOURCES.txt +19 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/dependency_links.txt +1 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/entry_points.txt +2 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/requires.txt +8 -0
- snapclientmpris-1.2.1/snapclientmpris.egg-info/top_level.txt +1 -0
- snapclientmpris-1.2.1/tests/test_cli.py +368 -0
- snapclientmpris-1.2.1/tests/test_mpris.py +139 -0
- snapclientmpris-1.2.1/tests/test_snapclientmpris.py +252 -0
- snapclientmpris-1.2.1/tests/test_translate.py +82 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019-2025 HiFiBerry <info@hifiberry.com>
|
|
4
|
+
Copyright (c) 2026 Mathieu Réquillart <mathieu.requillart@gmail.com>
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snapclientmpris
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Snapcast MPRIS bridge
|
|
5
|
+
Author-email: Mathieu Réquillart <mathieu.requillart@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/b0bbywan/snapclientmpris
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: snapcast>=2.3
|
|
14
|
+
Requires-Dist: dbus-fast>=2.0
|
|
15
|
+
Requires-Dist: zeroconf>=0.28
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# snapclientmpris
|
|
23
|
+
|
|
24
|
+
An [MPRIS2](https://specifications.freedesktop.org/mpris-spec/2.2/) D-Bus
|
|
25
|
+
bridge for the local Snapcast client. It surfaces the currently playing
|
|
26
|
+
track (title, artist, album, art) from a snapserver and forwards MPRIS
|
|
27
|
+
playback commands (Play / Pause / PlayPause / Stop / Next / Previous) to
|
|
28
|
+
the stream's source via snapserver's `Stream.Control` — so pausing from
|
|
29
|
+
any room pauses every listener on the stream, the multi-room semantic
|
|
30
|
+
MPRIS expects (à la Spotify Connect / Airplay 2).
|
|
31
|
+
|
|
32
|
+
The MPRIS interface is published under the bus name
|
|
33
|
+
`org.mpris.MediaPlayer2.snapcast` (the player exposes itself as the
|
|
34
|
+
Snapcast source, not the client implementation detail).
|
|
35
|
+
|
|
36
|
+
## Credits
|
|
37
|
+
|
|
38
|
+
This project started life as a fork of
|
|
39
|
+
[`hifiberry/snapcastmpris`](https://github.com/hifiberry/snapcastmpris)
|
|
40
|
+
— thanks to HiFiBerry for the original idea and for the work on tying
|
|
41
|
+
[Snapcast](https://github.com/snapcast/snapcast)'s JSON-RPC API to MPRIS2.
|
|
42
|
+
|
|
43
|
+
The current codebase is a complete rewrite around asyncio.
|
|
44
|
+
The repository was subsequently renamed from`snapcastmpris`
|
|
45
|
+
to `snapclientmpris` to better reflect what the daemon does.
|
|
46
|
+
|
|
47
|
+
## What's different from upstream
|
|
48
|
+
|
|
49
|
+
* Single asyncio event loop instead of threads + GLib MainLoop +
|
|
50
|
+
websocket-client + dbus-python.
|
|
51
|
+
* [`python-snapcast`](https://github.com/happyleavesaoc/python-snapcast)
|
|
52
|
+
for the snapserver JSON-RPC channel (no bespoke RPC / WebSocket
|
|
53
|
+
client) and [`dbus-fast`](https://github.com/Bluetooth-Devices/dbus-fast)
|
|
54
|
+
for the MPRIS interface (no GLib).
|
|
55
|
+
* Picks up track metadata from the `Stream.OnProperties` snapserver
|
|
56
|
+
event (snapserver ≥ 0.27) and surfaces it as `xesam:*` / `mpris:*`
|
|
57
|
+
keys, so MPRIS clients see the actual track title / artist / album.
|
|
58
|
+
* MPRIS Play / Pause / Next / Previous / Stop are forwarded to the
|
|
59
|
+
stream's source via `Stream.Control` rather than toggling the local
|
|
60
|
+
client's mute, so pausing from one room pauses everyone on the
|
|
61
|
+
stream. Capabilities (`CanPlay` / `CanPause` / `CanGoNext` /
|
|
62
|
+
`CanGoPrevious` / `CanSeek`) are mirrored from the stream's
|
|
63
|
+
properties, so MPRIS clients only enable the buttons the source
|
|
64
|
+
actually supports.
|
|
65
|
+
* Configuration is resolved from
|
|
66
|
+
`$XDG_CONFIG_HOME/snapclientmpris/snapclientmpris.conf` with
|
|
67
|
+
`/etc/snapclientmpris.conf` as fallback. An example template ships at
|
|
68
|
+
`/usr/share/snapclientmpris/snapclientmpris.conf`.
|
|
69
|
+
* The `dbus-bus` config key chooses between the session bus (default,
|
|
70
|
+
for a `systemctl --user` deployment) and the system bus (legacy
|
|
71
|
+
hifiberry-style, runs as `_snapclient` with a shipped D-Bus policy).
|
|
72
|
+
* The ALSA volume sync and the mute = pause-all integration were dropped.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
### From the Odio APT repository (recommended)
|
|
77
|
+
|
|
78
|
+
The `.deb` is the turn-key route: it wires up the systemd units, the
|
|
79
|
+
D-Bus policy, the config template and the `snapclient` dependency.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
curl -fsSL https://apt.odio.love/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/odio.gpg
|
|
83
|
+
echo "deb [signed-by=/usr/share/keyrings/odio.gpg] https://apt.odio.love stable main" \
|
|
84
|
+
| sudo tee /etc/apt/sources.list.d/odio.list
|
|
85
|
+
sudo apt update
|
|
86
|
+
sudo apt install snapclientmpris
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The package depends on `snapclient`, so APT pulls it in automatically.
|
|
90
|
+
Two bridge units are shipped (neither auto-enabled); pick whichever
|
|
91
|
+
fits your setup.
|
|
92
|
+
Both use `Type=dbus` with `BusName=org.mpris.MediaPlayer2.snapcast`
|
|
93
|
+
and pull in `snapclient.service` via `Wants=`.
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
# User mode (default, session bus)
|
|
97
|
+
systemctl --user enable --now snapclientmpris.service
|
|
98
|
+
|
|
99
|
+
# System mode (legacy hifiberry-style, runs as _snapclient on the system bus)
|
|
100
|
+
sudo cp /usr/share/snapclientmpris/snapclientmpris.conf /etc/snapclientmpris.conf
|
|
101
|
+
sudo sed -i 's/^dbus-bus = session/dbus-bus = system/' /etc/snapclientmpris.conf
|
|
102
|
+
sudo systemctl enable --now snapclientmpris.service
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
In user mode the bridge is also D-Bus session-activatable: any MPRIS
|
|
106
|
+
client (`playerctl`, desktop media keys, gnome-music) requesting the
|
|
107
|
+
bus name starts it on demand, so `enable --now` is only needed if you
|
|
108
|
+
want it up before any client asks for it.
|
|
109
|
+
|
|
110
|
+
In system mode the daemon owns `org.mpris.MediaPlayer2.snapcast` on
|
|
111
|
+
the system bus; the package ships the matching D-Bus policy at
|
|
112
|
+
`/usr/share/dbus-1/system.d/org.mpris.MediaPlayer2.snapcast.conf`
|
|
113
|
+
(grants `_snapclient` ownership, allows any local user to talk to it).
|
|
114
|
+
|
|
115
|
+
### From PyPI
|
|
116
|
+
|
|
117
|
+
Final releases are published to [PyPI](https://pypi.org/p/snapclientmpris),
|
|
118
|
+
prereleases to [TestPyPI](https://test.pypi.org/p/snapclientmpris):
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
pipx install snapclientmpris # or: pip install --user snapclientmpris
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The PyPI distribution ships **only** the `snapclientmpris` daemon, not
|
|
125
|
+
the systemd units, D-Bus policy or config template the `.deb` installs.
|
|
126
|
+
The daemon runs without a config file (Zeroconf auto-discovery), and
|
|
127
|
+
`snapclient` itself still has to come from your distro. To run it under
|
|
128
|
+
systemd, drop in a user unit pointing at the pipx/pip binary:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
mkdir -p ~/.config/systemd/user
|
|
132
|
+
cat > ~/.config/systemd/user/snapclientmpris.service <<'EOF'
|
|
133
|
+
[Unit]
|
|
134
|
+
Description=Snapcast MPRIS2 bridge
|
|
135
|
+
After=network-online.target snapclient.service
|
|
136
|
+
Wants=network-online.target snapclient.service
|
|
137
|
+
|
|
138
|
+
[Service]
|
|
139
|
+
Type=dbus
|
|
140
|
+
BusName=org.mpris.MediaPlayer2.snapcast
|
|
141
|
+
ExecStart=%h/.local/bin/snapclientmpris
|
|
142
|
+
Restart=on-failure
|
|
143
|
+
RestartSec=5
|
|
144
|
+
|
|
145
|
+
[Install]
|
|
146
|
+
WantedBy=default.target
|
|
147
|
+
EOF
|
|
148
|
+
|
|
149
|
+
systemctl --user daemon-reload
|
|
150
|
+
systemctl --user enable --now snapclientmpris.service
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Adjust `ExecStart` if `snapclientmpris` lives elsewhere (`which
|
|
154
|
+
snapclientmpris`). Unlike the APT install there is no D-Bus session
|
|
155
|
+
activation, so the unit (or a manual foreground run) is what starts the
|
|
156
|
+
bridge.
|
|
157
|
+
|
|
158
|
+
## Configuration
|
|
159
|
+
|
|
160
|
+
```ini
|
|
161
|
+
# Snapcast server IP. Leave commented to use Zeroconf auto-discovery.
|
|
162
|
+
# server = 192.168.1.100
|
|
163
|
+
# Override the JSON-RPC control port. Almost never needed: snapserver
|
|
164
|
+
# defaults to 1705, and snapserver >= 0.33 advertises the actual port via
|
|
165
|
+
# _snapcast-ctrl._tcp. Only useful if you've changed snapserver's TCP
|
|
166
|
+
# control port AND you run snapserver < 0.33 (e.g. 0.31 in Debian trixie).
|
|
167
|
+
# control-port = 1705
|
|
168
|
+
|
|
169
|
+
# D-Bus bus: session (default) or system.
|
|
170
|
+
dbus-bus = session
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Usage
|
|
175
|
+
|
|
176
|
+
Normally the daemon is started by systemd (see Installation). Run it
|
|
177
|
+
directly for debugging:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
snapclientmpris -v # run in the foreground with debug logging
|
|
181
|
+
snapclientmpris --discover # probe the network for Snapcast services and exit
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`--discover` performs a one-shot Zeroconf lookup and prints the
|
|
185
|
+
resolved IP and port of the snapserver control socket and the snapweb
|
|
186
|
+
UI, without starting the daemon:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
snapserver: tcp://192.168.1.21:1705
|
|
190
|
+
snapweb: http://192.168.1.21:1780
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
(IPv4 only. A snapserver < 0.33 that advertises only `_snapcast._tcp`
|
|
194
|
+
shows up as `snapserver: tcp://<ip>` without a port.)
|
|
195
|
+
|
|
196
|
+
## Architecture
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
remote local host
|
|
200
|
+
---------- ------------------------------------
|
|
201
|
+
|
|
202
|
+
audio +-------------+ +----------+
|
|
203
|
+
+---------------+ ---------------> | snapclient | -> | speakers |
|
|
204
|
+
| snapserver | | (own unit) | +----------+
|
|
205
|
+
| | +-------------+
|
|
206
|
+
| JSON-RPC :1705| <-- python-snapcast --+
|
|
207
|
+
+---------------+ (control + events) |
|
|
208
|
+
v
|
|
209
|
+
+---------------------------+
|
|
210
|
+
| snapclientmpris daemon |
|
|
211
|
+
| (this package, asyncio) |
|
|
212
|
+
+-------------+-------------+
|
|
213
|
+
| D-Bus (dbus-fast)
|
|
214
|
+
v
|
|
215
|
+
+---------------------------+
|
|
216
|
+
| MPRIS2 clients |
|
|
217
|
+
| (gnome-music, playerctl) |
|
|
218
|
+
+---------------------------+
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The daemon does **not** spawn snapclient. Snapclient runs as its own
|
|
222
|
+
service (`snapclient.service` from the `snapclient` Debian package);
|
|
223
|
+
the shipped systemd units pull it in via `Wants=snapclient.service`
|
|
224
|
+
and order `After=snapclient.service`, so enabling
|
|
225
|
+
`snapclientmpris.service` is enough.
|
|
226
|
+
|
|
227
|
+
Four Python modules:
|
|
228
|
+
|
|
229
|
+
* [`snapclientmpris/cli.py`](snapclientmpris/cli.py) — entry point.
|
|
230
|
+
Parses CLI flags, loads the config file, resolves the snapserver
|
|
231
|
+
address (explicit value or Zeroconf discovery), then hands off to
|
|
232
|
+
the `run()` coroutine.
|
|
233
|
+
* [`snapclientmpris/snapclientmpris.py`](snapclientmpris/snapclientmpris.py)
|
|
234
|
+
— asyncio orchestration. Connects to the snapserver, matches this
|
|
235
|
+
host to its snapserver-side client by MAC, exports the MPRIS
|
|
236
|
+
interface, and wires the snapserver stream/client callbacks to a
|
|
237
|
+
single `refresh()` that re-publishes PlaybackStatus, Metadata,
|
|
238
|
+
Volume and capabilities.
|
|
239
|
+
* [`snapclientmpris/mpris.py`](snapclientmpris/mpris.py) —
|
|
240
|
+
`MediaPlayer2` and `MediaPlayer2.Player` `ServiceInterface`
|
|
241
|
+
subclasses for dbus-fast (D-Bus interface definitions only).
|
|
242
|
+
* [`snapclientmpris/translate.py`](snapclientmpris/translate.py) —
|
|
243
|
+
pure helpers that map snapserver's MPRIS-like metadata to
|
|
244
|
+
`xesam:*` / `mpris:*` keys and snapserver stream state to an
|
|
245
|
+
MPRIS `PlaybackStatus`. No D-Bus or asyncio dependencies, so
|
|
246
|
+
fully unit-testable in isolation.
|
|
247
|
+
|
|
248
|
+
## Signals
|
|
249
|
+
|
|
250
|
+
* `SIGUSR1` — `Stream.Control Pause` on the bound stream.
|
|
251
|
+
* `SIGUSR2` — `Stream.Control Stop` on the bound stream.
|
|
252
|
+
|
|
253
|
+
For inspecting the running bridge, the MPRIS bus and the snapserver
|
|
254
|
+
JSON-RPC channel, see [DEBUGGING.md](DEBUGGING.md).
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
A top-level `Makefile` wraps the day-to-day commands so local dev and
|
|
259
|
+
CI stay in sync (the GitHub workflow calls the same targets):
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
make lint # ruff + mypy
|
|
263
|
+
make test # pytest
|
|
264
|
+
make build # python -m build (sdist + wheel)
|
|
265
|
+
make deb # dpkg-buildpackage -b -us -uc (Debian toolchain)
|
|
266
|
+
make clean # drop build/, dist/, *.egg-info
|
|
267
|
+
make version # print the Python version (from __init__.py)
|
|
268
|
+
make sync-deb # bump debian/changelog to match __init__.py
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`snapclientmpris/__init__.py` is the single source of truth for the
|
|
272
|
+
version; `make sync-deb` and `make check-tag TAG=…` keep
|
|
273
|
+
`debian/changelog` and the git tag aligned with it.
|
|
274
|
+
|
|
275
|
+
## Build a .deb
|
|
276
|
+
|
|
277
|
+
Build-deps (per `debian/control`): `debhelper-compat (= 13)`,
|
|
278
|
+
`dh-python`, `python3`, `python3-setuptools`. Then `make deb` on
|
|
279
|
+
Debian trixie or a derivative produces the `.deb` (wraps
|
|
280
|
+
`dpkg-buildpackage -b -us -uc`). The runtime deps
|
|
281
|
+
(`python3-snapcast`, `python3-dbus-fast`, `python3-zeroconf`,
|
|
282
|
+
`snapclient`) are resolved by APT at install time, not at build time.
|
|
283
|
+
|
|
284
|
+
## Continuous integration
|
|
285
|
+
|
|
286
|
+
`.github/workflows/build.yml` runs:
|
|
287
|
+
|
|
288
|
+
* **lint** on every PR to `master` — `ruff`, `mypy` and `pytest`.
|
|
289
|
+
* **build** on every PR and on `v*` tags — `make build` (sdist +
|
|
290
|
+
wheel), uploaded as an artifact for the release and publish jobs.
|
|
291
|
+
* **deb** on every PR and on `v*` tags — `dpkg-buildpackage` inside a
|
|
292
|
+
`debian:trixie` container; on tags, syncs `debian/changelog` with
|
|
293
|
+
the tag (rewriting `-rc/-beta/-alpha` to Debian-sortable `~rc/...`
|
|
294
|
+
suffixes) before building.
|
|
295
|
+
* **release** on `v*` tags — attaches the `.deb`, sdist and wheel to
|
|
296
|
+
the GitHub release, flagging `-rc/-beta/-alpha` tags as prereleases.
|
|
297
|
+
* **publish-to-testpypi** on `v*` tags — uploads sdist + wheel to
|
|
298
|
+
TestPyPI via trusted publishing (all tags, prereleases included).
|
|
299
|
+
* **publish-to-pypi** on final `v*` tags only — uploads to PyPI via
|
|
300
|
+
trusted publishing; `-rc/-beta/-alpha` tags stop at TestPyPI.
|
|
301
|
+
* **notify-apt-repo** on `v*` tags — dispatches to
|
|
302
|
+
[`b0bbywan/odio-apt-repo`](https://github.com/b0bbywan/odio-apt-repo)
|
|
303
|
+
so the new `.deb` is picked up by `apt.odio.love`.
|
|
304
|
+
|
|
305
|
+
## Used in
|
|
306
|
+
|
|
307
|
+
* [Odio](https://github.com/b0bbywan/odios) — the Odio streamer
|
|
308
|
+
installer turns a Linux box (typically a Raspberry Pi) into a
|
|
309
|
+
multi-room audio appliance; snapclientmpris is its per-room MPRIS
|
|
310
|
+
layer on top of snapcast.
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# snapclientmpris
|
|
2
|
+
|
|
3
|
+
An [MPRIS2](https://specifications.freedesktop.org/mpris-spec/2.2/) D-Bus
|
|
4
|
+
bridge for the local Snapcast client. It surfaces the currently playing
|
|
5
|
+
track (title, artist, album, art) from a snapserver and forwards MPRIS
|
|
6
|
+
playback commands (Play / Pause / PlayPause / Stop / Next / Previous) to
|
|
7
|
+
the stream's source via snapserver's `Stream.Control` — so pausing from
|
|
8
|
+
any room pauses every listener on the stream, the multi-room semantic
|
|
9
|
+
MPRIS expects (à la Spotify Connect / Airplay 2).
|
|
10
|
+
|
|
11
|
+
The MPRIS interface is published under the bus name
|
|
12
|
+
`org.mpris.MediaPlayer2.snapcast` (the player exposes itself as the
|
|
13
|
+
Snapcast source, not the client implementation detail).
|
|
14
|
+
|
|
15
|
+
## Credits
|
|
16
|
+
|
|
17
|
+
This project started life as a fork of
|
|
18
|
+
[`hifiberry/snapcastmpris`](https://github.com/hifiberry/snapcastmpris)
|
|
19
|
+
— thanks to HiFiBerry for the original idea and for the work on tying
|
|
20
|
+
[Snapcast](https://github.com/snapcast/snapcast)'s JSON-RPC API to MPRIS2.
|
|
21
|
+
|
|
22
|
+
The current codebase is a complete rewrite around asyncio.
|
|
23
|
+
The repository was subsequently renamed from`snapcastmpris`
|
|
24
|
+
to `snapclientmpris` to better reflect what the daemon does.
|
|
25
|
+
|
|
26
|
+
## What's different from upstream
|
|
27
|
+
|
|
28
|
+
* Single asyncio event loop instead of threads + GLib MainLoop +
|
|
29
|
+
websocket-client + dbus-python.
|
|
30
|
+
* [`python-snapcast`](https://github.com/happyleavesaoc/python-snapcast)
|
|
31
|
+
for the snapserver JSON-RPC channel (no bespoke RPC / WebSocket
|
|
32
|
+
client) and [`dbus-fast`](https://github.com/Bluetooth-Devices/dbus-fast)
|
|
33
|
+
for the MPRIS interface (no GLib).
|
|
34
|
+
* Picks up track metadata from the `Stream.OnProperties` snapserver
|
|
35
|
+
event (snapserver ≥ 0.27) and surfaces it as `xesam:*` / `mpris:*`
|
|
36
|
+
keys, so MPRIS clients see the actual track title / artist / album.
|
|
37
|
+
* MPRIS Play / Pause / Next / Previous / Stop are forwarded to the
|
|
38
|
+
stream's source via `Stream.Control` rather than toggling the local
|
|
39
|
+
client's mute, so pausing from one room pauses everyone on the
|
|
40
|
+
stream. Capabilities (`CanPlay` / `CanPause` / `CanGoNext` /
|
|
41
|
+
`CanGoPrevious` / `CanSeek`) are mirrored from the stream's
|
|
42
|
+
properties, so MPRIS clients only enable the buttons the source
|
|
43
|
+
actually supports.
|
|
44
|
+
* Configuration is resolved from
|
|
45
|
+
`$XDG_CONFIG_HOME/snapclientmpris/snapclientmpris.conf` with
|
|
46
|
+
`/etc/snapclientmpris.conf` as fallback. An example template ships at
|
|
47
|
+
`/usr/share/snapclientmpris/snapclientmpris.conf`.
|
|
48
|
+
* The `dbus-bus` config key chooses between the session bus (default,
|
|
49
|
+
for a `systemctl --user` deployment) and the system bus (legacy
|
|
50
|
+
hifiberry-style, runs as `_snapclient` with a shipped D-Bus policy).
|
|
51
|
+
* The ALSA volume sync and the mute = pause-all integration were dropped.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
### From the Odio APT repository (recommended)
|
|
56
|
+
|
|
57
|
+
The `.deb` is the turn-key route: it wires up the systemd units, the
|
|
58
|
+
D-Bus policy, the config template and the `snapclient` dependency.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
curl -fsSL https://apt.odio.love/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/odio.gpg
|
|
62
|
+
echo "deb [signed-by=/usr/share/keyrings/odio.gpg] https://apt.odio.love stable main" \
|
|
63
|
+
| sudo tee /etc/apt/sources.list.d/odio.list
|
|
64
|
+
sudo apt update
|
|
65
|
+
sudo apt install snapclientmpris
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The package depends on `snapclient`, so APT pulls it in automatically.
|
|
69
|
+
Two bridge units are shipped (neither auto-enabled); pick whichever
|
|
70
|
+
fits your setup.
|
|
71
|
+
Both use `Type=dbus` with `BusName=org.mpris.MediaPlayer2.snapcast`
|
|
72
|
+
and pull in `snapclient.service` via `Wants=`.
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# User mode (default, session bus)
|
|
76
|
+
systemctl --user enable --now snapclientmpris.service
|
|
77
|
+
|
|
78
|
+
# System mode (legacy hifiberry-style, runs as _snapclient on the system bus)
|
|
79
|
+
sudo cp /usr/share/snapclientmpris/snapclientmpris.conf /etc/snapclientmpris.conf
|
|
80
|
+
sudo sed -i 's/^dbus-bus = session/dbus-bus = system/' /etc/snapclientmpris.conf
|
|
81
|
+
sudo systemctl enable --now snapclientmpris.service
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
In user mode the bridge is also D-Bus session-activatable: any MPRIS
|
|
85
|
+
client (`playerctl`, desktop media keys, gnome-music) requesting the
|
|
86
|
+
bus name starts it on demand, so `enable --now` is only needed if you
|
|
87
|
+
want it up before any client asks for it.
|
|
88
|
+
|
|
89
|
+
In system mode the daemon owns `org.mpris.MediaPlayer2.snapcast` on
|
|
90
|
+
the system bus; the package ships the matching D-Bus policy at
|
|
91
|
+
`/usr/share/dbus-1/system.d/org.mpris.MediaPlayer2.snapcast.conf`
|
|
92
|
+
(grants `_snapclient` ownership, allows any local user to talk to it).
|
|
93
|
+
|
|
94
|
+
### From PyPI
|
|
95
|
+
|
|
96
|
+
Final releases are published to [PyPI](https://pypi.org/p/snapclientmpris),
|
|
97
|
+
prereleases to [TestPyPI](https://test.pypi.org/p/snapclientmpris):
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
pipx install snapclientmpris # or: pip install --user snapclientmpris
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The PyPI distribution ships **only** the `snapclientmpris` daemon, not
|
|
104
|
+
the systemd units, D-Bus policy or config template the `.deb` installs.
|
|
105
|
+
The daemon runs without a config file (Zeroconf auto-discovery), and
|
|
106
|
+
`snapclient` itself still has to come from your distro. To run it under
|
|
107
|
+
systemd, drop in a user unit pointing at the pipx/pip binary:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
mkdir -p ~/.config/systemd/user
|
|
111
|
+
cat > ~/.config/systemd/user/snapclientmpris.service <<'EOF'
|
|
112
|
+
[Unit]
|
|
113
|
+
Description=Snapcast MPRIS2 bridge
|
|
114
|
+
After=network-online.target snapclient.service
|
|
115
|
+
Wants=network-online.target snapclient.service
|
|
116
|
+
|
|
117
|
+
[Service]
|
|
118
|
+
Type=dbus
|
|
119
|
+
BusName=org.mpris.MediaPlayer2.snapcast
|
|
120
|
+
ExecStart=%h/.local/bin/snapclientmpris
|
|
121
|
+
Restart=on-failure
|
|
122
|
+
RestartSec=5
|
|
123
|
+
|
|
124
|
+
[Install]
|
|
125
|
+
WantedBy=default.target
|
|
126
|
+
EOF
|
|
127
|
+
|
|
128
|
+
systemctl --user daemon-reload
|
|
129
|
+
systemctl --user enable --now snapclientmpris.service
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Adjust `ExecStart` if `snapclientmpris` lives elsewhere (`which
|
|
133
|
+
snapclientmpris`). Unlike the APT install there is no D-Bus session
|
|
134
|
+
activation, so the unit (or a manual foreground run) is what starts the
|
|
135
|
+
bridge.
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
138
|
+
|
|
139
|
+
```ini
|
|
140
|
+
# Snapcast server IP. Leave commented to use Zeroconf auto-discovery.
|
|
141
|
+
# server = 192.168.1.100
|
|
142
|
+
# Override the JSON-RPC control port. Almost never needed: snapserver
|
|
143
|
+
# defaults to 1705, and snapserver >= 0.33 advertises the actual port via
|
|
144
|
+
# _snapcast-ctrl._tcp. Only useful if you've changed snapserver's TCP
|
|
145
|
+
# control port AND you run snapserver < 0.33 (e.g. 0.31 in Debian trixie).
|
|
146
|
+
# control-port = 1705
|
|
147
|
+
|
|
148
|
+
# D-Bus bus: session (default) or system.
|
|
149
|
+
dbus-bus = session
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Usage
|
|
154
|
+
|
|
155
|
+
Normally the daemon is started by systemd (see Installation). Run it
|
|
156
|
+
directly for debugging:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
snapclientmpris -v # run in the foreground with debug logging
|
|
160
|
+
snapclientmpris --discover # probe the network for Snapcast services and exit
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`--discover` performs a one-shot Zeroconf lookup and prints the
|
|
164
|
+
resolved IP and port of the snapserver control socket and the snapweb
|
|
165
|
+
UI, without starting the daemon:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
snapserver: tcp://192.168.1.21:1705
|
|
169
|
+
snapweb: http://192.168.1.21:1780
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
(IPv4 only. A snapserver < 0.33 that advertises only `_snapcast._tcp`
|
|
173
|
+
shows up as `snapserver: tcp://<ip>` without a port.)
|
|
174
|
+
|
|
175
|
+
## Architecture
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
remote local host
|
|
179
|
+
---------- ------------------------------------
|
|
180
|
+
|
|
181
|
+
audio +-------------+ +----------+
|
|
182
|
+
+---------------+ ---------------> | snapclient | -> | speakers |
|
|
183
|
+
| snapserver | | (own unit) | +----------+
|
|
184
|
+
| | +-------------+
|
|
185
|
+
| JSON-RPC :1705| <-- python-snapcast --+
|
|
186
|
+
+---------------+ (control + events) |
|
|
187
|
+
v
|
|
188
|
+
+---------------------------+
|
|
189
|
+
| snapclientmpris daemon |
|
|
190
|
+
| (this package, asyncio) |
|
|
191
|
+
+-------------+-------------+
|
|
192
|
+
| D-Bus (dbus-fast)
|
|
193
|
+
v
|
|
194
|
+
+---------------------------+
|
|
195
|
+
| MPRIS2 clients |
|
|
196
|
+
| (gnome-music, playerctl) |
|
|
197
|
+
+---------------------------+
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The daemon does **not** spawn snapclient. Snapclient runs as its own
|
|
201
|
+
service (`snapclient.service` from the `snapclient` Debian package);
|
|
202
|
+
the shipped systemd units pull it in via `Wants=snapclient.service`
|
|
203
|
+
and order `After=snapclient.service`, so enabling
|
|
204
|
+
`snapclientmpris.service` is enough.
|
|
205
|
+
|
|
206
|
+
Four Python modules:
|
|
207
|
+
|
|
208
|
+
* [`snapclientmpris/cli.py`](snapclientmpris/cli.py) — entry point.
|
|
209
|
+
Parses CLI flags, loads the config file, resolves the snapserver
|
|
210
|
+
address (explicit value or Zeroconf discovery), then hands off to
|
|
211
|
+
the `run()` coroutine.
|
|
212
|
+
* [`snapclientmpris/snapclientmpris.py`](snapclientmpris/snapclientmpris.py)
|
|
213
|
+
— asyncio orchestration. Connects to the snapserver, matches this
|
|
214
|
+
host to its snapserver-side client by MAC, exports the MPRIS
|
|
215
|
+
interface, and wires the snapserver stream/client callbacks to a
|
|
216
|
+
single `refresh()` that re-publishes PlaybackStatus, Metadata,
|
|
217
|
+
Volume and capabilities.
|
|
218
|
+
* [`snapclientmpris/mpris.py`](snapclientmpris/mpris.py) —
|
|
219
|
+
`MediaPlayer2` and `MediaPlayer2.Player` `ServiceInterface`
|
|
220
|
+
subclasses for dbus-fast (D-Bus interface definitions only).
|
|
221
|
+
* [`snapclientmpris/translate.py`](snapclientmpris/translate.py) —
|
|
222
|
+
pure helpers that map snapserver's MPRIS-like metadata to
|
|
223
|
+
`xesam:*` / `mpris:*` keys and snapserver stream state to an
|
|
224
|
+
MPRIS `PlaybackStatus`. No D-Bus or asyncio dependencies, so
|
|
225
|
+
fully unit-testable in isolation.
|
|
226
|
+
|
|
227
|
+
## Signals
|
|
228
|
+
|
|
229
|
+
* `SIGUSR1` — `Stream.Control Pause` on the bound stream.
|
|
230
|
+
* `SIGUSR2` — `Stream.Control Stop` on the bound stream.
|
|
231
|
+
|
|
232
|
+
For inspecting the running bridge, the MPRIS bus and the snapserver
|
|
233
|
+
JSON-RPC channel, see [DEBUGGING.md](DEBUGGING.md).
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
A top-level `Makefile` wraps the day-to-day commands so local dev and
|
|
238
|
+
CI stay in sync (the GitHub workflow calls the same targets):
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
make lint # ruff + mypy
|
|
242
|
+
make test # pytest
|
|
243
|
+
make build # python -m build (sdist + wheel)
|
|
244
|
+
make deb # dpkg-buildpackage -b -us -uc (Debian toolchain)
|
|
245
|
+
make clean # drop build/, dist/, *.egg-info
|
|
246
|
+
make version # print the Python version (from __init__.py)
|
|
247
|
+
make sync-deb # bump debian/changelog to match __init__.py
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`snapclientmpris/__init__.py` is the single source of truth for the
|
|
251
|
+
version; `make sync-deb` and `make check-tag TAG=…` keep
|
|
252
|
+
`debian/changelog` and the git tag aligned with it.
|
|
253
|
+
|
|
254
|
+
## Build a .deb
|
|
255
|
+
|
|
256
|
+
Build-deps (per `debian/control`): `debhelper-compat (= 13)`,
|
|
257
|
+
`dh-python`, `python3`, `python3-setuptools`. Then `make deb` on
|
|
258
|
+
Debian trixie or a derivative produces the `.deb` (wraps
|
|
259
|
+
`dpkg-buildpackage -b -us -uc`). The runtime deps
|
|
260
|
+
(`python3-snapcast`, `python3-dbus-fast`, `python3-zeroconf`,
|
|
261
|
+
`snapclient`) are resolved by APT at install time, not at build time.
|
|
262
|
+
|
|
263
|
+
## Continuous integration
|
|
264
|
+
|
|
265
|
+
`.github/workflows/build.yml` runs:
|
|
266
|
+
|
|
267
|
+
* **lint** on every PR to `master` — `ruff`, `mypy` and `pytest`.
|
|
268
|
+
* **build** on every PR and on `v*` tags — `make build` (sdist +
|
|
269
|
+
wheel), uploaded as an artifact for the release and publish jobs.
|
|
270
|
+
* **deb** on every PR and on `v*` tags — `dpkg-buildpackage` inside a
|
|
271
|
+
`debian:trixie` container; on tags, syncs `debian/changelog` with
|
|
272
|
+
the tag (rewriting `-rc/-beta/-alpha` to Debian-sortable `~rc/...`
|
|
273
|
+
suffixes) before building.
|
|
274
|
+
* **release** on `v*` tags — attaches the `.deb`, sdist and wheel to
|
|
275
|
+
the GitHub release, flagging `-rc/-beta/-alpha` tags as prereleases.
|
|
276
|
+
* **publish-to-testpypi** on `v*` tags — uploads sdist + wheel to
|
|
277
|
+
TestPyPI via trusted publishing (all tags, prereleases included).
|
|
278
|
+
* **publish-to-pypi** on final `v*` tags only — uploads to PyPI via
|
|
279
|
+
trusted publishing; `-rc/-beta/-alpha` tags stop at TestPyPI.
|
|
280
|
+
* **notify-apt-repo** on `v*` tags — dispatches to
|
|
281
|
+
[`b0bbywan/odio-apt-repo`](https://github.com/b0bbywan/odio-apt-repo)
|
|
282
|
+
so the new `.deb` is picked up by `apt.odio.love`.
|
|
283
|
+
|
|
284
|
+
## Used in
|
|
285
|
+
|
|
286
|
+
* [Odio](https://github.com/b0bbywan/odios) — the Odio streamer
|
|
287
|
+
installer turns a Linux box (typically a Raspberry Pi) into a
|
|
288
|
+
multi-room audio appliance; snapclientmpris is its per-room MPRIS
|
|
289
|
+
layer on top of snapcast.
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT — see [LICENSE](LICENSE).
|