Initial commit

This commit is contained in:
Storm Dragon
2025-12-15 04:09:55 -05:00
commit 555ca0bba9
26 changed files with 4532 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Integration helpers for NaviPy."""
+389
View File
@@ -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