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.
- snapclientmpris/__init__.py +1 -0
- snapclientmpris/__main__.py +5 -0
- snapclientmpris/cli.py +245 -0
- snapclientmpris/mpris.py +295 -0
- snapclientmpris/snapclientmpris.py +409 -0
- snapclientmpris/translate.py +83 -0
- snapclientmpris-1.2.1.dist-info/METADATA +314 -0
- snapclientmpris-1.2.1.dist-info/RECORD +12 -0
- snapclientmpris-1.2.1.dist-info/WHEEL +5 -0
- snapclientmpris-1.2.1.dist-info/entry_points.txt +2 -0
- snapclientmpris-1.2.1.dist-info/licenses/LICENSE +22 -0
- snapclientmpris-1.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.1"
|
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()
|
snapclientmpris/mpris.py
ADDED
|
@@ -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})
|