Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Integration helpers for NaviPy."""
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
MPRIS v2 integration using dbus-python with a GLib main loop.
|
||||
|
||||
We avoid QtDBus because some environments block Qt's D-Bus socket while the
|
||||
standard dbus-python bindings work. The service exposes both interfaces on
|
||||
`/org/mpris/MediaPlayer2` and runs a GLib loop in a background thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
try:
|
||||
import dbus
|
||||
import dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBUS_AVAILABLE = True
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
DBUS_AVAILABLE = False
|
||||
dbus = None # type: ignore
|
||||
DBusGMainLoop = None # type: ignore
|
||||
GLib = None # type: ignore
|
||||
|
||||
from PySide6.QtCore import QMetaObject, Qt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_track_id(track_id: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9]", "_", track_id or "track")
|
||||
|
||||
|
||||
if DBUS_AVAILABLE:
|
||||
DBusObjectBase = dbus.service.Object
|
||||
else:
|
||||
class DBusObjectBase: # type: ignore
|
||||
"""Placeholder when dbus-python is unavailable."""
|
||||
pass
|
||||
|
||||
|
||||
class MprisService(DBusObjectBase):
|
||||
BUS_NAME = "org.mpris.MediaPlayer2.navipy"
|
||||
OBJECT_PATH = "/org/mpris/MediaPlayer2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
playback,
|
||||
art_url_resolver: Optional[Callable[[str], Optional[str]]] = None,
|
||||
raise_callback: Optional[Callable[[], None]] = None,
|
||||
quit_callback: Optional[Callable[[], None]] = None,
|
||||
on_volume_changed: Optional[Callable[[int], None]] = None,
|
||||
on_shuffle_changed: Optional[Callable[[bool], None]] = None,
|
||||
on_loop_changed: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
if not DBUS_AVAILABLE:
|
||||
self.available = False
|
||||
logger.warning("MPRIS disabled: dbus-python/GLib bindings not available.")
|
||||
return
|
||||
|
||||
self.playback = playback
|
||||
self.art_url_resolver = art_url_resolver
|
||||
self.raise_callback = raise_callback
|
||||
self.quit_callback = quit_callback
|
||||
self.on_volume_changed = on_volume_changed
|
||||
self.on_shuffle_changed = on_shuffle_changed
|
||||
self.on_loop_changed = on_loop_changed
|
||||
|
||||
self._metadata: Dict[str, object] = {}
|
||||
self._playbackState: str = "stopped"
|
||||
self._currentTrackPath: dbus.ObjectPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")
|
||||
|
||||
self.available = False
|
||||
self._loop = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
bus = self._connect_to_bus()
|
||||
if not bus:
|
||||
return
|
||||
|
||||
self.bus = bus
|
||||
super().__init__(bus, self.OBJECT_PATH)
|
||||
self.available = True
|
||||
logger.info("MPRIS registered as %s", self.BUS_NAME)
|
||||
|
||||
self._start_loop()
|
||||
self.updateMetadata(self.playback.currentSong())
|
||||
self.updatePlaybackStatus(self._playbackState)
|
||||
self.notifyCapabilities()
|
||||
self.notifyVolumeChanged()
|
||||
self.notifyShuffleChanged()
|
||||
self.notifyLoopStatus()
|
||||
|
||||
# ===== DBus setup =====
|
||||
|
||||
def _connect_to_bus(self) -> Optional[dbus.SessionBus]:
|
||||
"""Connect to the session bus, logging a warning on failure."""
|
||||
try:
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SessionBus()
|
||||
bus.request_name(self.BUS_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE)
|
||||
return bus
|
||||
except Exception as e:
|
||||
logger.warning("MPRIS disabled: %s", e)
|
||||
return None
|
||||
|
||||
def _start_loop(self):
|
||||
"""Run a GLib main loop in a background thread for DBus dispatch."""
|
||||
self._loop = GLib.MainLoop()
|
||||
self._loop_thread = threading.Thread(target=self._loop.run, daemon=True)
|
||||
self._loop_thread.start()
|
||||
|
||||
def shutdown(self):
|
||||
"""Stop the DBus loop."""
|
||||
if self._loop:
|
||||
self._loop.quit()
|
||||
if self._loop_thread and self._loop_thread.is_alive():
|
||||
self._loop_thread.join(timeout=1)
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _call_on_main(self, func: Callable, *args):
|
||||
"""Invoke playback operations on the Qt main thread."""
|
||||
try:
|
||||
QMetaObject.invokeMethod(self.playback, lambda: func(*args), Qt.QueuedConnection)
|
||||
except Exception:
|
||||
try:
|
||||
func(*args)
|
||||
except Exception as e:
|
||||
logger.debug("MPRIS call failed: %s", e)
|
||||
|
||||
def _build_metadata(self, song) -> Dict[str, object]:
|
||||
if not song:
|
||||
self._currentTrackPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")
|
||||
return dbus.Dictionary(
|
||||
{"mpris:trackid": self._currentTrackPath},
|
||||
signature="sv",
|
||||
)
|
||||
|
||||
track_path = dbus.ObjectPath(f"/org/navipy/track/{_sanitize_track_id(song.id)}")
|
||||
duration_us = int(song.duration * 1_000_000)
|
||||
metadata: Dict[str, object] = {
|
||||
"mpris:trackid": track_path,
|
||||
"mpris:length": dbus.Int64(duration_us),
|
||||
"xesam:title": dbus.String(song.title or "Unknown Title"),
|
||||
"xesam:album": dbus.String(song.album or ""),
|
||||
"xesam:artist": dbus.Array([song.artist], signature="s") if song.artist else dbus.Array([], signature="s"),
|
||||
"xesam:genre": dbus.Array([song.genre], signature="s") if song.genre else dbus.Array([], signature="s"),
|
||||
"xesam:trackNumber": dbus.Int32(song.track or 0),
|
||||
"xesam:discNumber": dbus.Int32(song.discNumber or 0),
|
||||
}
|
||||
|
||||
art_url = self._resolve_art_url(song.coverArt)
|
||||
if art_url:
|
||||
metadata["mpris:artUrl"] = art_url
|
||||
|
||||
self._currentTrackPath = track_path
|
||||
return dbus.Dictionary(metadata, signature="sv")
|
||||
|
||||
def _resolve_art_url(self, cover_id: Optional[str]) -> Optional[str]:
|
||||
if not cover_id or not self.art_url_resolver:
|
||||
return None
|
||||
try:
|
||||
return self.art_url_resolver(cover_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _emit_properties_changed(self, interface: str, changes: Dict[str, object]):
|
||||
"""Emit PropertiesChanged for the player object."""
|
||||
try:
|
||||
payload = dbus.Dictionary(changes, signature="sv")
|
||||
invalidated = dbus.Array([], signature="s")
|
||||
self.PropertiesChanged(interface, payload, invalidated)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to emit PropertiesChanged (%s): %s", interface, e)
|
||||
|
||||
# ===== External notifications =====
|
||||
|
||||
def updateMetadata(self, song) -> None:
|
||||
if not self.available:
|
||||
return
|
||||
self._metadata = self._build_metadata(song)
|
||||
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Metadata": self._metadata})
|
||||
|
||||
def updatePlaybackStatus(self, state: str) -> None:
|
||||
self._playbackState = state
|
||||
if not self.available:
|
||||
return
|
||||
self._emit_properties_changed(
|
||||
"org.mpris.MediaPlayer2.Player",
|
||||
{"PlaybackStatus": self._player_props()["PlaybackStatus"]},
|
||||
)
|
||||
|
||||
def notifyCapabilities(self) -> None:
|
||||
if not self.available:
|
||||
return
|
||||
props = self._player_props()
|
||||
self._emit_properties_changed(
|
||||
"org.mpris.MediaPlayer2.Player",
|
||||
{
|
||||
"CanGoNext": props["CanGoNext"],
|
||||
"CanGoPrevious": props["CanGoPrevious"],
|
||||
"CanPlay": props["CanPlay"],
|
||||
"CanPause": props["CanPause"],
|
||||
"CanSeek": props["CanSeek"],
|
||||
"CanControl": True,
|
||||
},
|
||||
)
|
||||
|
||||
def notifyVolumeChanged(self) -> None:
|
||||
if self.available:
|
||||
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Volume": self._player_props()["Volume"]})
|
||||
|
||||
def notifyShuffleChanged(self) -> None:
|
||||
if self.available:
|
||||
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"Shuffle": self._player_props()["Shuffle"]})
|
||||
|
||||
def notifyLoopStatus(self) -> None:
|
||||
if self.available:
|
||||
self._emit_properties_changed("org.mpris.MediaPlayer2.Player", {"LoopStatus": self._player_props()["LoopStatus"]})
|
||||
|
||||
# ===== Root interface properties =====
|
||||
|
||||
def _root_props(self) -> Dict[str, object]:
|
||||
return {
|
||||
"CanQuit": dbus.Boolean(True),
|
||||
"CanRaise": dbus.Boolean(True),
|
||||
"HasTrackList": dbus.Boolean(False),
|
||||
"CanSetFullscreen": dbus.Boolean(False),
|
||||
"Fullscreen": dbus.Boolean(False),
|
||||
"DesktopEntry": dbus.String("navipy"),
|
||||
"Identity": dbus.String("NaviPy"),
|
||||
"SupportedUriSchemes": dbus.Array(["http", "https"], signature="s"),
|
||||
"SupportedMimeTypes": dbus.Array(
|
||||
["audio/mpeg", "audio/flac", "audio/ogg", "audio/mp4", "audio/aac", "audio/wav"],
|
||||
signature="s",
|
||||
),
|
||||
}
|
||||
|
||||
# ===== Player interface properties =====
|
||||
|
||||
def _player_props(self) -> Dict[str, object]:
|
||||
queue = getattr(self.playback, "queue", [])
|
||||
mode = getattr(self.playback, "repeatMode", "none")
|
||||
player = getattr(self.playback, "player", None)
|
||||
audio_output = getattr(self.playback, "audioOutput", None)
|
||||
|
||||
return {
|
||||
"PlaybackStatus": dbus.String({"playing": "Playing", "paused": "Paused", "stopped": "Stopped"}.get(self._playbackState, "Stopped")),
|
||||
"LoopStatus": dbus.String({"one": "Track", "all": "Playlist", "none": "None"}.get(mode, "None")),
|
||||
"Rate": dbus.Double(1.0),
|
||||
"Shuffle": dbus.Boolean(bool(getattr(self.playback, "shuffleEnabled", False))),
|
||||
"Metadata": self._metadata,
|
||||
"Volume": dbus.Double(float(audio_output.volume()) if audio_output else 0.0),
|
||||
"Position": dbus.Int64(int(player.position() * 1000) if player else 0),
|
||||
"MinimumRate": dbus.Double(1.0),
|
||||
"MaximumRate": dbus.Double(1.0),
|
||||
"CanGoNext": dbus.Boolean(len(queue) > 1 or (len(queue) == 1 and mode != "none")),
|
||||
"CanGoPrevious": dbus.Boolean(len(queue) > 0),
|
||||
"CanPlay": dbus.Boolean(bool(queue)),
|
||||
"CanPause": dbus.Boolean(bool(queue)),
|
||||
"CanControl": dbus.Boolean(True),
|
||||
"CanSeek": dbus.Boolean(bool(queue)),
|
||||
"CanGoForward": dbus.Boolean(False),
|
||||
"CanGoBackward": dbus.Boolean(False),
|
||||
}
|
||||
|
||||
# ===== org.freedesktop.DBus.Properties =====
|
||||
|
||||
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v")
|
||||
def Get(self, interface: str, prop: str):
|
||||
if interface == "org.mpris.MediaPlayer2":
|
||||
return self._root_props().get(prop)
|
||||
if interface == "org.mpris.MediaPlayer2.Player":
|
||||
return self._player_props().get(prop)
|
||||
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Unknown interface {interface}")
|
||||
|
||||
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}")
|
||||
def GetAll(self, interface: str):
|
||||
if interface == "org.mpris.MediaPlayer2":
|
||||
return self._root_props()
|
||||
if interface == "org.mpris.MediaPlayer2.Player":
|
||||
return self._player_props()
|
||||
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Unknown interface {interface}")
|
||||
|
||||
@dbus.service.method(dbus_interface="org.freedesktop.DBus.Properties", in_signature="ssv")
|
||||
def Set(self, interface: str, prop: str, value):
|
||||
if interface != "org.mpris.MediaPlayer2.Player":
|
||||
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", "Properties on this interface are read-only")
|
||||
|
||||
if prop == "LoopStatus":
|
||||
loop_status = str(value)
|
||||
mode = {"Track": "one", "Playlist": "all", "None": "none"}.get(loop_status, "none")
|
||||
if self.on_loop_changed:
|
||||
self.on_loop_changed(loop_status)
|
||||
else:
|
||||
self._call_on_main(self.playback.setRepeatMode, mode)
|
||||
self.notifyLoopStatus()
|
||||
elif prop == "Shuffle":
|
||||
enabled = bool(value)
|
||||
if self.on_shuffle_changed:
|
||||
self.on_shuffle_changed(enabled)
|
||||
else:
|
||||
self._call_on_main(self.playback.setShuffle, enabled)
|
||||
self.notifyShuffleChanged()
|
||||
elif prop == "Volume":
|
||||
percent = int(max(0.0, min(1.0, float(value))) * 100)
|
||||
if self.on_volume_changed:
|
||||
self.on_volume_changed(percent)
|
||||
else:
|
||||
self._call_on_main(self.playback.setVolume, percent)
|
||||
self.notifyVolumeChanged()
|
||||
else:
|
||||
raise dbus.exceptions.DBusException("org.freedesktop.DBus.Error.InvalidArgs", f"Property {prop} is not writable")
|
||||
|
||||
# ===== Signals =====
|
||||
|
||||
@dbus.service.signal(dbus_interface="org.freedesktop.DBus.Properties", signature="sa{sv}as")
|
||||
def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties):
|
||||
pass
|
||||
|
||||
@dbus.service.signal(dbus_interface="org.mpris.MediaPlayer2.Player", signature="x")
|
||||
def Seeked(self, position):
|
||||
pass
|
||||
|
||||
# ===== Root interface methods =====
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2")
|
||||
def Raise(self):
|
||||
if self.raise_callback:
|
||||
self.raise_callback()
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2")
|
||||
def Quit(self):
|
||||
if self.quit_callback:
|
||||
self.quit_callback()
|
||||
|
||||
# ===== Player methods =====
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def Next(self):
|
||||
self._call_on_main(self.playback.next)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def Previous(self):
|
||||
self._call_on_main(self.playback.previous)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def Pause(self):
|
||||
player = getattr(self.playback, "player", None)
|
||||
if player:
|
||||
self._call_on_main(player.pause)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def PlayPause(self):
|
||||
self._call_on_main(self.playback.togglePlayPause)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def Stop(self):
|
||||
self._call_on_main(self.playback.stop)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player")
|
||||
def Play(self):
|
||||
self._call_on_main(self.playback.play)
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="x")
|
||||
def Seek(self, offset: int):
|
||||
player = getattr(self.playback, "player", None)
|
||||
if not player:
|
||||
return
|
||||
current = player.position() * 1000 # to microseconds
|
||||
target = max(0, current + offset)
|
||||
self._call_on_main(self.playback.seek, int(target / 1000))
|
||||
self.Seeked(dbus.Int64(target))
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="ox")
|
||||
def SetPosition(self, track_id: dbus.ObjectPath, position: int):
|
||||
if track_id != self._currentTrackPath:
|
||||
return
|
||||
self._call_on_main(self.playback.seek, int(position / 1000))
|
||||
self.Seeked(dbus.Int64(position))
|
||||
|
||||
@dbus.service.method(dbus_interface="org.mpris.MediaPlayer2.Player", in_signature="s")
|
||||
def OpenUri(self, uri: str):
|
||||
# Navidrome streams require authentication; ignore external open requests.
|
||||
_ = uri
|
||||
Reference in New Issue
Block a user