Files
lintunes/lintunes/mpris.py
T
travandClaude Fable 5 663d5e5a08 v0.1.4: playback-control provenance log
LinTunes has spontaneously resumed playback for ~4s (then paused) four
times while trav was away — suspected phantom media-key events from the
Audioengine USB DAC's HID 'keyboard', but the pathway (MPRIS vs the
focused-window key filter) is unproven. Every control path now drops a
timestamped line in ~/.cache/lintunes/control-events.log: MPRIS Player
methods, the media-key eventFilter (with source input device where the
compositor exposes it), and Player.toggle_play/pause with position. New
lintunes/eventlog.py, 1MB rotation, never raises; conftest autouse
fixture keeps tests off the real log file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:21 -04:00

292 lines
8.2 KiB
Python

"""MPRIS2 D-Bus interface so desktop media keys / playerctl control lintunes.
Registers org.mpris.MediaPlayer2.lintunes on the session bus with the
standard root and Player interfaces, backed by our Player object. Album art
is exported to a small cache dir so GNOME's media popup can show it
(mpris:artUrl must be a file the notification daemon can read).
"""
import os
from pathlib import Path
from PyQt6.QtCore import QMetaType, QObject, pyqtSlot, pyqtProperty, pyqtClassInfo
from PyQt6.QtDBus import (
QDBusConnection, QDBusAbstractAdaptor, QDBusArgument, QDBusObjectPath,
QDBusMessage,
)
from lintunes import tagging
from lintunes.eventlog import log_control
SERVICE_NAME = "org.mpris.MediaPlayer2.lintunes"
OBJECT_PATH = "/org/mpris/MediaPlayer2"
ART_CACHE_LIMIT = 50
def _art_cache_dir() -> Path:
base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
return Path(base) / "lintunes" / "artwork"
def export_artwork(track) -> str | None:
"""Write the track's embedded art to the cache dir; return a file:// URL."""
if track is None or not track.location or not track.persistent_id:
return None
cache_dir = _art_cache_dir()
target = cache_dir / f"{track.persistent_id}.jpg"
if not target.exists():
data = tagging.read_embedded_artwork(track.location)
if not data:
return None
cache_dir.mkdir(parents=True, exist_ok=True)
_prune_cache(cache_dir)
tmp = target.with_suffix(".tmp")
tmp.write_bytes(data)
tmp.replace(target)
return "file://" + str(target)
def invalidate_artwork(track):
"""Drop a track's cached art so the next export re-reads the file (call
after embedding new artwork)."""
if track is None or not track.persistent_id:
return
try:
(_art_cache_dir() / f"{track.persistent_id}.jpg").unlink()
except OSError:
pass
def _prune_cache(cache_dir: Path):
files = sorted(cache_dir.glob("*.jpg"), key=lambda p: p.stat().st_mtime)
for old in files[:max(0, len(files) - ART_CACHE_LIMIT)]:
try:
old.unlink()
except OSError:
pass
def setup_mpris(player, main_window) -> "MprisService | None":
bus = QDBusConnection.sessionBus()
if not bus.isConnected():
return None
service = MprisService(player, main_window)
if not bus.registerService(SERVICE_NAME):
return None
bus.registerObject(OBJECT_PATH, service,
QDBusConnection.RegisterOption.ExportAdaptors)
return service
class MprisService(QObject):
def __init__(self, player, main_window):
super().__init__()
self.root_adaptor = MprisRootAdaptor(self, main_window)
self.player_adaptor = MprisPlayerAdaptor(self, player)
@pyqtClassInfo("D-Bus Interface", "org.mpris.MediaPlayer2")
class MprisRootAdaptor(QDBusAbstractAdaptor):
def __init__(self, parent, main_window):
super().__init__(parent)
self._window = main_window
@pyqtProperty(bool)
def CanQuit(self):
return True
@pyqtProperty(bool)
def CanRaise(self):
return True
@pyqtProperty(bool)
def HasTrackList(self):
return False
@pyqtProperty(str)
def Identity(self):
return "LinTunes"
@pyqtProperty(str)
def DesktopEntry(self):
return "lintunes"
@pyqtProperty("QStringList")
def SupportedUriSchemes(self):
return ["file"]
@pyqtProperty("QStringList")
def SupportedMimeTypes(self):
return ["audio/mpeg", "audio/mp4", "audio/flac"]
@pyqtSlot()
def Raise(self):
self._window.show()
self._window.raise_()
self._window.activateWindow()
@pyqtSlot()
def Quit(self):
self._window.close()
@pyqtClassInfo("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
class MprisPlayerAdaptor(QDBusAbstractAdaptor):
def __init__(self, parent, player):
super().__init__(parent)
self._player = player
player.playing_changed.connect(lambda _: self._notify(
{"PlaybackStatus": self.PlaybackStatus}))
player.track_changed.connect(lambda _: self._notify(
{"Metadata": self._metadata(), "PlaybackStatus": self.PlaybackStatus}))
# -- properties --
@pyqtProperty(str)
def PlaybackStatus(self):
if self._player.is_playing():
return "Playing"
if self._player.current_track is not None:
return "Paused"
return "Stopped"
@pyqtProperty(float)
def Rate(self):
return 1.0
@pyqtProperty(float)
def MinimumRate(self):
return 1.0
@pyqtProperty(float)
def MaximumRate(self):
return 1.0
@pyqtProperty(float)
def Volume(self):
return 1.0
@pyqtProperty("qlonglong")
def Position(self):
return self._player.position_ms() * 1000 # microseconds
@pyqtProperty("QVariantMap")
def Metadata(self):
return self._metadata()
@pyqtProperty(bool)
def CanGoNext(self):
return True
@pyqtProperty(bool)
def CanGoPrevious(self):
return True
@pyqtProperty(bool)
def CanPlay(self):
return True
@pyqtProperty(bool)
def CanPause(self):
return True
@pyqtProperty(bool)
def CanSeek(self):
return True
@pyqtProperty(bool)
def CanControl(self):
return True
# -- methods --
def _log(self, action: str):
# PyQt6 doesn't wrap QDBusContext, so the caller can't be identified
# in-process; run `dbus-monitor` alongside when the sender matters.
log_control("mpris", action)
@pyqtSlot()
def Next(self):
self._log("Next")
self._player.next()
@pyqtSlot()
def Previous(self):
self._log("Previous")
self._player.previous()
@pyqtSlot()
def Pause(self):
self._log("Pause")
self._player.pause()
@pyqtSlot()
def PlayPause(self):
self._log("PlayPause")
self._player.toggle_play()
@pyqtSlot()
def Play(self):
self._log("Play")
self._player.play()
@pyqtSlot()
def Stop(self):
self._log("Stop")
self._player.stop()
@pyqtSlot("qlonglong")
def Seek(self, offset_us):
self._log("Seek")
self._player.seek(self._player.position_ms() + offset_us // 1000)
@pyqtSlot(QDBusObjectPath, "qlonglong")
def SetPosition(self, track_id, position_us):
self._log("SetPosition")
self._player.seek(position_us // 1000)
@pyqtSlot(str)
def OpenUri(self, uri):
pass
# -- helpers --
def _metadata(self) -> dict:
track = self._player.current_track
if track is None:
return {"mpris:trackid": QDBusObjectPath("/org/lintunes/track/none")}
meta = {
"mpris:trackid": QDBusObjectPath(f"/org/lintunes/track/{track.track_id}"),
"xesam:title": track.name or "",
"xesam:artist": [track.artist or ""],
"xesam:album": track.album or "",
}
if track.total_time:
meta["mpris:length"] = track.total_time * 1000 # microseconds
if track.location:
meta["xesam:url"] = "file://" + track.location
art_url = export_artwork(track)
if art_url:
meta["mpris:artUrl"] = art_url
return meta
def _notify(self, changed: dict):
QDBusConnection.sessionBus().send(build_properties_changed(changed))
def build_properties_changed(changed: dict) -> QDBusMessage:
"""A spec-correct Properties.PropertiesChanged signal for the Player
interface. invalidated_properties must be marshalled as a D-Bus string
array ("as"); a plain Python [] goes over as "av", and GDBus consumers —
including gsd-media-keys, which keys its media-key MRU on seeing
PlaybackStatus change — validate the signature and drop the signal.
(Same pitfall as reveal_paths in track_table.)"""
msg = QDBusMessage.createSignal(
OBJECT_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
invalidated = QDBusArgument()
invalidated.beginArray(QMetaType(QMetaType.Type.QString.value).id())
invalidated.endArray()
msg.setArguments(["org.mpris.MediaPlayer2.Player", changed, invalidated])
return msg