snapclientmpris 1.2.1__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "1.2.1"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m snapclientmpris`` to invoke the CLI."""
2
+
3
+ from snapclientmpris.cli import main
4
+
5
+ main()
snapclientmpris/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ """CLI entry point: argparse, config-file parsing, Zeroconf discovery.
2
+
3
+ Kept separate from the daemon runtime (``snapclientmpris.snapclientmpris``)
4
+ so the bootstrap surface (argument parsing, config resolution, host
5
+ discovery) is testable in isolation from the asyncio event loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import contextlib
13
+ import functools
14
+ import logging
15
+ import os
16
+ import sys
17
+
18
+ from dbus_fast import BusType
19
+ from zeroconf import IPVersion, Zeroconf
20
+
21
+ from snapclientmpris.snapclientmpris import run
22
+
23
+ logger = logging.getLogger("snapclientmpris")
24
+
25
+ CONFIG_PATHS = [
26
+ os.path.join(
27
+ os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
28
+ "snapclientmpris",
29
+ "snapclientmpris.conf",
30
+ ),
31
+ "/etc/snapclientmpris.conf",
32
+ ]
33
+
34
+ SNAPSERVER_CONTROL_PORT = 1705
35
+
36
+ # Zeroconf service types advertised by snapserver. The instance name is
37
+ # always "Snapcast." + the service type.
38
+ CTRL_SERVICE_TYPE = "_snapcast-ctrl._tcp.local." # JSON-RPC control (>= 0.33)
39
+ AUDIO_SERVICE_TYPE = "_snapcast._tcp.local." # audio stream (all versions)
40
+ HTTP_SERVICE_TYPE = "_snapcast-http._tcp.local." # snapweb UI
41
+
42
+
43
+ class ConfigError(Exception):
44
+ """Raised when the daemon can't start because of invalid / missing config."""
45
+
46
+
47
+ def read_config() -> dict[str, str]:
48
+ """Parse the first existing config file as flat ``key = value`` pairs.
49
+
50
+ The format is intentionally minimal: three keys (``server``,
51
+ ``control-port``, ``dbus-bus``), no sections. ``#`` introduces a
52
+ comment to end-of-line; blank and malformed lines are skipped.
53
+ """
54
+ for path in CONFIG_PATHS:
55
+ try:
56
+ with open(path) as f:
57
+ content = f.read()
58
+ except FileNotFoundError:
59
+ continue
60
+ except OSError as e:
61
+ logger.warning("failed to read %s: %s", path, e)
62
+ continue
63
+ cfg: dict[str, str] = {}
64
+ for raw in content.splitlines():
65
+ line = raw.split("#", 1)[0].strip()
66
+ if not line or "=" not in line:
67
+ continue
68
+ k, v = line.split("=", 1)
69
+ cfg[k.strip()] = v.strip()
70
+ logger.info("read %s", path)
71
+ return cfg
72
+ logger.info("no config file found, using defaults")
73
+ return {}
74
+
75
+
76
+ def _lookup_service(
77
+ zc: Zeroconf, service_type: str
78
+ ) -> tuple[str | None, int | None]:
79
+ """Resolve the "Snapcast" instance of a service type to (host, port)."""
80
+ info = zc.get_service_info(service_type, f"Snapcast.{service_type}", timeout=3000)
81
+ if info is None or info.port is None:
82
+ return None, None
83
+ for addr in info.parsed_addresses(IPVersion.V4Only):
84
+ if addr != "0.0.0.0":
85
+ return str(addr), info.port
86
+ return None, None
87
+
88
+
89
+ def discover_snapserver() -> tuple[str, int | None] | None:
90
+ """Return ``(host, control_port)`` for the snapserver, or ``None``.
91
+
92
+ snapserver >= 0.33 advertises its JSON-RPC control socket as
93
+ ``_snapcast-ctrl._tcp``, which yields both the host and the actual
94
+ control port. Older snapservers (e.g. 0.31 in Debian trixie) only
95
+ advertise ``_snapcast._tcp`` (the audio port); in that case the host
96
+ is returned with ``port=None`` and the caller falls back to the
97
+ configured / default control port.
98
+ """
99
+ zc = Zeroconf()
100
+ try:
101
+ host, port = _lookup_service(zc, CTRL_SERVICE_TYPE)
102
+ if host is not None:
103
+ return host, port
104
+
105
+ host, _ = _lookup_service(zc, AUDIO_SERVICE_TYPE)
106
+ if host is not None:
107
+ return host, None
108
+ return None
109
+ finally:
110
+ zc.close()
111
+
112
+
113
+ def discover_snapweb() -> tuple[str, int] | None:
114
+ """Return ``(host, http_port)`` for the snapweb UI, or ``None``.
115
+
116
+ snapserver advertises its built-in web UI as ``_snapcast-http._tcp``,
117
+ which carries the actual HTTP port (default 1780).
118
+ """
119
+ zc = Zeroconf()
120
+ try:
121
+ host, port = _lookup_service(zc, HTTP_SERVICE_TYPE)
122
+ if host is not None and port is not None:
123
+ return host, port
124
+ return None
125
+ finally:
126
+ zc.close()
127
+
128
+
129
+ def run_discovery() -> None:
130
+ """Probe Zeroconf for snapserver + snapweb and print findings to stdout.
131
+
132
+ Diagnostic one-shot for ``--discover``: never starts the daemon.
133
+ """
134
+ server = discover_snapserver()
135
+ web = discover_snapweb()
136
+ if server is None and web is None:
137
+ print("No Snapcast services found on the local network.")
138
+ return
139
+ if server is not None:
140
+ host, port = server
141
+ print(f"snapserver: tcp://{host}" + (f":{port}" if port else ""))
142
+ if web is not None:
143
+ host, port = web
144
+ print(f"snapweb: http://{host}:{port}")
145
+
146
+
147
+ def parse_control_port(cfg: dict[str, str]) -> int:
148
+ """Validate the configured control port (fallback only, a discovered
149
+ port wins). Fail-fast on a typo. Raises ConfigError if unparseable.
150
+ """
151
+ raw_port = cfg.get("control-port") or SNAPSERVER_CONTROL_PORT
152
+ try:
153
+ return int(raw_port)
154
+ except ValueError as e:
155
+ raise ConfigError(f"invalid control-port {raw_port!r}") from e
156
+
157
+
158
+ def resolve_endpoint(
159
+ cfg: dict[str, str], default_control_port: int
160
+ ) -> tuple[str, int] | None:
161
+ """Return ``(host, control_port)`` from config server or Zeroconf, or
162
+ ``None`` if no host is found yet (transient, caller retries).
163
+
164
+ A snapserver >= 0.33 advertises its control port (wins); older ones only
165
+ give the host, so the port falls back to ``default_control_port``.
166
+ """
167
+ host = cfg.get("server") or None
168
+ discovered_port: int | None = None
169
+ if not host:
170
+ discovered = discover_snapserver()
171
+ if not discovered:
172
+ return None
173
+ host, discovered_port = discovered
174
+ if discovered_port is not None:
175
+ return host, discovered_port
176
+ return host, default_control_port
177
+
178
+
179
+ def resolve_dbus_bus(cfg: dict[str, str]) -> BusType:
180
+ """Return the BusType selected by the ``dbus-bus`` config key.
181
+
182
+ Raises ConfigError on an unrecognised value, failing loudly avoids
183
+ landing the daemon on a different (often more privileged) bus than
184
+ intended through a config typo.
185
+ """
186
+ bus_choice = (cfg.get("dbus-bus") or "session").lower()
187
+ if bus_choice == "session":
188
+ return BusType.SESSION
189
+ if bus_choice == "system":
190
+ return BusType.SYSTEM
191
+ raise ConfigError(
192
+ f"invalid dbus-bus {bus_choice!r} (expected 'session' or 'system')"
193
+ )
194
+
195
+
196
+ def main() -> None:
197
+ parser = argparse.ArgumentParser(
198
+ prog="snapclientmpris",
199
+ description=("MPRIS2 D-Bus bridge for a local Snapcast client. "
200
+ "Talks to snapserver and exposes the local client's "
201
+ "PlaybackStatus, Metadata and Volume over D-Bus. "
202
+ "Snapclient itself runs as its own service."),
203
+ )
204
+ parser.add_argument("-v", "--verbose", action="store_true", help="enable debug logging")
205
+ parser.add_argument(
206
+ "--discover",
207
+ action="store_true",
208
+ help="probe the network for snapserver and snapweb, print the result and exit",
209
+ )
210
+ args = parser.parse_args()
211
+
212
+ logging.basicConfig(
213
+ format="%(levelname)s: %(name)s - %(message)s",
214
+ level=logging.DEBUG if args.verbose else logging.INFO,
215
+ )
216
+ if not args.verbose:
217
+ logging.getLogger("snapcast").setLevel(logging.WARNING)
218
+
219
+ if args.discover:
220
+ run_discovery()
221
+ return
222
+
223
+ cfg = read_config()
224
+ try:
225
+ control_port = parse_control_port(cfg)
226
+ bus_type = resolve_dbus_bus(cfg)
227
+ except ConfigError as e:
228
+ logger.critical("%s", e)
229
+ sys.exit(1)
230
+ logger.info("%s D-Bus; snapserver resolved lazily (config host or Zeroconf)",
231
+ "session" if bus_type == BusType.SESSION else "system")
232
+
233
+ # Resolved lazily in run()'s connect loop so a snapserver that isn't up
234
+ # yet doesn't abort startup. resolve() -> (host, control_port) | None.
235
+ resolve = functools.partial(resolve_endpoint, cfg, control_port)
236
+
237
+ # SIGTERM/SIGINT inside run() cancel the main task, asyncio.run()
238
+ # then re-raises the CancelledError; suppress both that and the
239
+ # pre-loop KeyboardInterrupt path for a clean exit code.
240
+ with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError):
241
+ asyncio.run(run(resolve, bus_type))
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
@@ -0,0 +1,295 @@
1
+ """MPRIS2 D-Bus interface, exposed via dbus-fast.
2
+
3
+ The two ServiceInterface subclasses below correspond to the two
4
+ interfaces every MPRIS2 player must implement on the object path
5
+ ``/org/mpris/MediaPlayer2``:
6
+
7
+ * ``org.mpris.MediaPlayer2`` — identity + capabilities (root)
8
+ * ``org.mpris.MediaPlayer2.Player`` — playback state + controls
9
+
10
+ Behaviour is driven from the outside: callbacks injected at construction
11
+ time handle Play/Pause/Stop/PlayPause, and ``update_playback_status`` /
12
+ ``update_metadata`` push state changes back to subscribed MPRIS clients.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from collections.abc import Callable
19
+
20
+ from dbus_fast.errors import DBusError
21
+ from dbus_fast.service import PropertyAccess, ServiceInterface, dbus_property, method
22
+
23
+ NOT_SUPPORTED = "org.freedesktop.DBus.Error.NotSupported"
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ ROOT_PATH = "/org/mpris/MediaPlayer2"
28
+ MEDIA_PLAYER_IFACE = "org.mpris.MediaPlayer2"
29
+ BUS_NAME = f"{MEDIA_PLAYER_IFACE}.snapcast"
30
+ PLAYER_IFACE = f"{MEDIA_PLAYER_IFACE}.Player"
31
+
32
+ IDENTITY = "Snapcast client"
33
+ DESKTOP_ENTRY = "snapclientmpris"
34
+
35
+
36
+ class MediaPlayer2(ServiceInterface):
37
+ """Root MPRIS interface."""
38
+
39
+ def __init__(self) -> None:
40
+ super().__init__(MEDIA_PLAYER_IFACE)
41
+
42
+ @method()
43
+ def Raise(self):
44
+ raise DBusError(NOT_SUPPORTED, "Raise is not supported")
45
+
46
+ @method()
47
+ def Quit(self):
48
+ raise DBusError(NOT_SUPPORTED, "Quit is not supported")
49
+
50
+ @dbus_property(access=PropertyAccess.READ)
51
+ def CanQuit(self) -> "b":
52
+ return False
53
+
54
+ @dbus_property(access=PropertyAccess.READ)
55
+ def CanRaise(self) -> "b":
56
+ return False
57
+
58
+ @dbus_property(access=PropertyAccess.READ)
59
+ def HasTrackList(self) -> "b":
60
+ return False
61
+
62
+ @dbus_property(access=PropertyAccess.READ)
63
+ def Identity(self) -> "s":
64
+ return IDENTITY
65
+
66
+ @dbus_property(access=PropertyAccess.READ)
67
+ def DesktopEntry(self) -> "s":
68
+ return DESKTOP_ENTRY
69
+
70
+ @dbus_property(access=PropertyAccess.READ)
71
+ def SupportedUriSchemes(self) -> "as":
72
+ return []
73
+
74
+ @dbus_property(access=PropertyAccess.READ)
75
+ def SupportedMimeTypes(self) -> "as":
76
+ return []
77
+
78
+
79
+ _CAP_ATTRS = {
80
+ "CanPlay": "_can_play",
81
+ "CanPause": "_can_pause",
82
+ "CanGoNext": "_can_go_next",
83
+ "CanGoPrevious": "_can_go_previous",
84
+ "CanSeek": "_can_seek",
85
+ }
86
+
87
+
88
+ class MediaPlayer2Player(ServiceInterface):
89
+ """Player MPRIS interface — playback state and controls."""
90
+
91
+ def __init__(
92
+ self,
93
+ on_play: Callable[[], None] | None = None,
94
+ on_pause: Callable[[], None] | None = None,
95
+ on_play_pause: Callable[[], None] | None = None,
96
+ on_stop: Callable[[], None] | None = None,
97
+ on_next: Callable[[], None] | None = None,
98
+ on_previous: Callable[[], None] | None = None,
99
+ on_volume_set: Callable[[float], None] | None = None,
100
+ ) -> None:
101
+ super().__init__(PLAYER_IFACE)
102
+ self._playback_status = "Stopped"
103
+ self._metadata: dict = {}
104
+ self._volume = 1.0
105
+ # Capabilities default to False — they're filled in from the
106
+ # snapserver stream's properties on the first refresh().
107
+ self._can_play = False
108
+ self._can_pause = False
109
+ self._can_go_next = False
110
+ self._can_go_previous = False
111
+ self._can_seek = False
112
+ self._on_play = on_play
113
+ self._on_pause = on_pause
114
+ self._on_play_pause = on_play_pause
115
+ self._on_stop = on_stop
116
+ self._on_next = on_next
117
+ self._on_previous = on_previous
118
+ self._on_volume_set = on_volume_set
119
+
120
+ # --- MPRIS methods ------------------------------------------------
121
+ @method()
122
+ def Play(self):
123
+ logger.debug("MPRIS Play")
124
+ if self._on_play:
125
+ self._on_play()
126
+
127
+ @method()
128
+ def Pause(self):
129
+ logger.debug("MPRIS Pause")
130
+ if self._on_pause:
131
+ self._on_pause()
132
+
133
+ @method()
134
+ def PlayPause(self):
135
+ logger.debug("MPRIS PlayPause")
136
+ if self._on_play_pause:
137
+ self._on_play_pause()
138
+
139
+ @method()
140
+ def Stop(self):
141
+ logger.debug("MPRIS Stop")
142
+ if self._on_stop:
143
+ self._on_stop()
144
+
145
+ @method()
146
+ def Next(self):
147
+ logger.debug("MPRIS Next")
148
+ if self._on_next:
149
+ self._on_next()
150
+
151
+ @method()
152
+ def Previous(self):
153
+ logger.debug("MPRIS Previous")
154
+ if self._on_previous:
155
+ self._on_previous()
156
+
157
+ @method()
158
+ def Seek(self, Offset: "x"): # noqa: N803, ARG002
159
+ raise DBusError(NOT_SUPPORTED, "Seek is not supported")
160
+
161
+ @method()
162
+ def SetPosition(self, TrackId: "o", Position: "x"): # noqa: N803, ARG002
163
+ raise DBusError(NOT_SUPPORTED, "SetPosition is not supported")
164
+
165
+ @method()
166
+ def OpenUri(self, Uri: "s"): # noqa: N803, ARG002
167
+ raise DBusError(NOT_SUPPORTED, "OpenUri is not supported")
168
+
169
+ # --- MPRIS properties --------------------------------------------
170
+ @dbus_property(access=PropertyAccess.READ)
171
+ def PlaybackStatus(self) -> "s":
172
+ return self._playback_status
173
+
174
+ @dbus_property(access=PropertyAccess.READ)
175
+ def Metadata(self) -> "a{sv}":
176
+ return self._metadata
177
+
178
+ @dbus_property(access=PropertyAccess.READ)
179
+ def Position(self) -> "x":
180
+ return 0
181
+
182
+ @dbus_property(access=PropertyAccess.READ)
183
+ def Rate(self) -> "d":
184
+ return 1.0
185
+
186
+ @dbus_property(access=PropertyAccess.READ)
187
+ def MinimumRate(self) -> "d":
188
+ return 1.0
189
+
190
+ @dbus_property(access=PropertyAccess.READ)
191
+ def MaximumRate(self) -> "d":
192
+ return 1.0
193
+
194
+ @dbus_property(access=PropertyAccess.READ)
195
+ def CanGoNext(self) -> "b":
196
+ return self._can_go_next
197
+
198
+ @dbus_property(access=PropertyAccess.READ)
199
+ def CanGoPrevious(self) -> "b":
200
+ return self._can_go_previous
201
+
202
+ @dbus_property(access=PropertyAccess.READ)
203
+ def CanPlay(self) -> "b":
204
+ return self._can_play
205
+
206
+ @dbus_property(access=PropertyAccess.READ)
207
+ def CanPause(self) -> "b":
208
+ return self._can_pause
209
+
210
+ @dbus_property(access=PropertyAccess.READ)
211
+ def CanSeek(self) -> "b":
212
+ return self._can_seek
213
+
214
+ @dbus_property(access=PropertyAccess.READ)
215
+ def CanControl(self) -> "b":
216
+ # Hardcoded True even when the source has canControl=false in
217
+ # its snapserver stream.properties: MPRIS clients refuse to set
218
+ # Volume when CanControl=False (per spec), but snapcast volume
219
+ # is per-client and we want it controllable regardless of the
220
+ # stream source's own controllability. Backends that need the
221
+ # truth can read the per-operation Can* flags (which are
222
+ # mirrored from the stream) or the snapcast:streamStatus
223
+ # Metadata key.
224
+ return True
225
+
226
+ @dbus_property()
227
+ def Volume(self) -> "d":
228
+ return self._volume
229
+
230
+ @Volume.setter # type: ignore[no-redef]
231
+ def Volume(self, val: "d") -> None:
232
+ clamped = max(0.0, min(1.0, float(val)))
233
+ logger.debug("MPRIS Set Volume: %.3f -> %.3f", self._volume, clamped)
234
+ if clamped == self._volume:
235
+ # Still trigger the backend hook so the snapserver-side state
236
+ # follows even when the value is identical (idempotent retry).
237
+ if self._on_volume_set:
238
+ self._on_volume_set(clamped)
239
+ return
240
+ self._volume = clamped
241
+ # Emit synchronously so every MPRIS subscriber (gnome-music, KDE
242
+ # plasma, etc.) learns about the change. The follow-up refresh()
243
+ # triggered by client.OnVolumeChanged will early-return in
244
+ # update_volume() because self._volume already matches — no double
245
+ # emit.
246
+ self.emit_properties_changed({"Volume": clamped})
247
+ if self._on_volume_set:
248
+ self._on_volume_set(clamped)
249
+
250
+ # --- External update API -----------------------------------------
251
+ def update_playback_status(self, status: str) -> None:
252
+ """Set PlaybackStatus and notify subscribers."""
253
+ if status not in ("Playing", "Paused", "Stopped"):
254
+ logger.warning("ignoring invalid playback status: %s", status)
255
+ return
256
+ if status == self._playback_status:
257
+ return
258
+ self._playback_status = status
259
+ self.emit_properties_changed({"PlaybackStatus": status})
260
+
261
+ def update_metadata(self, metadata: dict) -> None:
262
+ """Replace Metadata and notify subscribers."""
263
+ self._metadata = metadata
264
+ self.emit_properties_changed({"Metadata": metadata})
265
+
266
+ def update_capabilities(self, caps: dict) -> None:
267
+ """Update the Can* flags from external state (snapserver stream
268
+ properties) and emit PropertiesChanged for any that changed.
269
+
270
+ ``caps`` is a dict keyed by MPRIS property name, e.g.
271
+ ``{"CanPlay": True, "CanPause": True, "CanGoNext": False, ...}``.
272
+ Unknown keys are silently ignored.
273
+ """
274
+ changed: dict = {}
275
+ for key, val in caps.items():
276
+ attr = _CAP_ATTRS.get(key)
277
+ if attr is None:
278
+ continue
279
+ if getattr(self, attr) != val:
280
+ setattr(self, attr, val)
281
+ changed[key] = val
282
+ if changed:
283
+ logger.debug("update_capabilities: %s", changed)
284
+ self.emit_properties_changed(changed)
285
+
286
+ def update_volume(self, volume: float) -> None:
287
+ """Set Volume from external state (e.g. snapserver event)."""
288
+ clamped = max(0.0, min(1.0, float(volume)))
289
+ if clamped == self._volume:
290
+ logger.debug("update_volume: no change (%.3f), skip emit", clamped)
291
+ return
292
+ logger.debug("update_volume: %.3f -> %.3f, emitting PropertiesChanged",
293
+ self._volume, clamped)
294
+ self._volume = clamped
295
+ self.emit_properties_changed({"Volume": clamped})