Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
(RatingDelegate); click slot k sets k stars, clicking the current count
clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
fetch, preview/confirm dialog with Next Result, embeds via write_artwork
+ size refresh, invalidates the MPRIS art cache.
Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
instead of "as", so gsd-media-keys dropped it and never MRU-bumped
lintunes (why the play/pause key kept waking stale players). Now an
explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
swap, and BufferedMedia (needs verify on the affected machine).
Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.
TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
278 lines
7.7 KiB
Python
278 lines
7.7 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
|
|
|
|
|
|
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 --
|
|
|
|
@pyqtSlot()
|
|
def Next(self):
|
|
self._player.next()
|
|
|
|
@pyqtSlot()
|
|
def Previous(self):
|
|
self._player.previous()
|
|
|
|
@pyqtSlot()
|
|
def Pause(self):
|
|
self._player.pause()
|
|
|
|
@pyqtSlot()
|
|
def PlayPause(self):
|
|
self._player.toggle_play()
|
|
|
|
@pyqtSlot()
|
|
def Play(self):
|
|
self._player.play()
|
|
|
|
@pyqtSlot()
|
|
def Stop(self):
|
|
self._player.stop()
|
|
|
|
@pyqtSlot("qlonglong")
|
|
def Seek(self, offset_us):
|
|
self._player.seek(self._player.position_ms() + offset_us // 1000)
|
|
|
|
@pyqtSlot(QDBusObjectPath, "qlonglong")
|
|
def SetPosition(self, track_id, position_us):
|
|
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
|