Files
MTS-music-another-client/mpris.py
jads de9bf80d11 feat: download tracks with full ID3 metadata
- Add downloads.py: "Artist - Title.mp3" naming, no-overwrite dedupe,
  configurable download_dir, progress and per-item callbacks.
- Save maximum metadata via tags.py (ID3v2.3, mutagen): artists, album
  and album artist, track/disc, year/date, raw genre, label, duration,
  explicit flag, cover 600x600, plain and synced lyrics (MTS then
  LRCLIB), track URLs and Yandex/MTS ids.
- TUI/Qt: download current track and queue from the menu, "d" key in
  lists, folder picker in settings; CLI download gains -d/--dir and
  --no-tags.
- Add mutagen to requirements and document download/metadata in README.
- Fix MPRIS update_metadata crashing without dbus-next.
2026-10-25 20:06:59 +03:00

382 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MPRIS (D-Bus) интеграция: система видит плеер как источник звука.
Благодаря этому плеером можно управлять из панели GNOME (расширение
media-controls), из `playerctl` и т.п.: play/pause, следующий/предыдущий,
перемотка, метаданные и обложка.
Модуль необязательный: если нет `dbus-next` или сессионного D-Bus, всё
превращается в no-op, и приложения работают как обычно.
"""
import asyncio
import logging
import threading
from typing import Any, Callable
log = logging.getLogger("mpris")
try: # pragma: no cover - зависит от окружения
from dbus_next import Variant
from dbus_next.aio import MessageBus
from dbus_next.constants import PropertyAccess
from dbus_next.service import (
ServiceInterface,
dbus_property,
method,
signal,
)
HAVE_DBUS = True
except Exception: # pragma: no cover
HAVE_DBUS = False
BUS_NAME = "org.mpris.MediaPlayer2.mtsmusic"
OBJECT_PATH = "/org/mpris/MediaPlayer2"
def track_object_path(track_id: Any) -> str:
"""D-Bus object path для mpris:trackid (только [A-Za-z0-9_])."""
safe = "".join(ch if ch.isalnum()
else "_" for ch in str(track_id)) or "unknown"
return f"/mtsmusic/track/{safe}"
if HAVE_DBUS:
class _RootInterface(ServiceInterface):
def __init__(self, service: "MprisService"):
super().__init__("org.mpris.MediaPlayer2")
self._svc = service
@method()
def Raise(self):
self._svc.dispatch("raise")
@method()
def Quit(self):
self._svc.dispatch("quit")
@dbus_property(access=PropertyAccess.READ)
def Identity(self) -> "s": # noqa: F821
return self._svc.identity
@dbus_property(access=PropertyAccess.READ)
def DesktopEntry(self) -> "s": # noqa: F821
return self._svc.desktop_entry
@dbus_property(access=PropertyAccess.READ)
def CanQuit(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READ)
def CanRaise(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READ)
def HasTrackList(self) -> "b": # noqa: F821
return False
@dbus_property(access=PropertyAccess.READ)
def SupportedUriSchemes(self) -> "as": # noqa: F722
return []
@dbus_property(access=PropertyAccess.READ)
def SupportedMimeTypes(self) -> "as": # noqa: F722
return []
class _PlayerInterface(ServiceInterface):
def __init__(self, service: "MprisService"):
super().__init__("org.mpris.MediaPlayer2.Player")
self._svc = service
# -- методы ------------------------------------------------------
@method()
def Play(self):
self._svc.dispatch("play")
@method()
def Pause(self):
self._svc.dispatch("pause")
@method()
def PlayPause(self):
self._svc.dispatch("play_pause")
@method()
def Stop(self):
self._svc.dispatch("stop")
@method()
def Next(self):
self._svc.dispatch("next")
@method()
def Previous(self):
self._svc.dispatch("previous")
@method()
def Seek(self, offset: "x"): # noqa: F821
self._svc.dispatch("seek", int(offset))
@method()
def SetPosition(self, track_id: "o", position: "x"): # noqa: F821
self._svc.dispatch("set_position", str(track_id), int(position))
@method()
def OpenUri(self, uri: "s"): # noqa: F821
self._svc.dispatch("open_uri", str(uri))
# -- сигналы -----------------------------------------------------
@signal()
def Seeked(self, position: "x") -> "x": # noqa: F821
return position
# -- свойства ----------------------------------------------------
@dbus_property(access=PropertyAccess.READ)
def PlaybackStatus(self) -> "s": # noqa: F821
return self._svc.playback_status
@dbus_property(access=PropertyAccess.READ)
def Metadata(self) -> "a{sv}": # noqa: F722
return self._svc.metadata
@dbus_property(access=PropertyAccess.READ)
def Position(self) -> "x": # noqa: F821
return int(self._svc.position)
@dbus_property(access=PropertyAccess.READ)
def MinimumRate(self) -> "d": # noqa: F821
return 1.0
@dbus_property(access=PropertyAccess.READ)
def MaximumRate(self) -> "d": # noqa: F821
return 1.0
@dbus_property(access=PropertyAccess.READ)
def CanGoNext(self) -> "b": # noqa: F821
return self._svc.can_next
@dbus_property(access=PropertyAccess.READ)
def CanGoPrevious(self) -> "b": # noqa: F821
return self._svc.can_previous
@dbus_property(access=PropertyAccess.READ)
def CanPlay(self) -> "b": # noqa: F821
return self._svc.can_play
@dbus_property(access=PropertyAccess.READ)
def CanPause(self) -> "b": # noqa: F821
return self._svc.can_pause
@dbus_property(access=PropertyAccess.READ)
def CanSeek(self) -> "b": # noqa: F821
return self._svc.can_seek
@dbus_property(access=PropertyAccess.READ)
def CanControl(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READWRITE)
def Volume(self) -> "d": # noqa: F821
return float(self._svc.volume)
@Volume.setter
def Volume(self, value: "d"): # noqa: F821
self._svc.dispatch("volume", float(value))
@dbus_property(access=PropertyAccess.READWRITE)
def LoopStatus(self) -> "s": # noqa: F821
return self._svc.loop_status
@LoopStatus.setter
def LoopStatus(self, value: "s"): # noqa: F821
self._svc.loop_status = str(value)
@dbus_property(access=PropertyAccess.READWRITE)
def Shuffle(self) -> "b": # noqa: F821
return self._svc.shuffle
@Shuffle.setter
def Shuffle(self, value: "b"): # noqa: F821
self._svc.shuffle = bool(value)
@dbus_property(access=PropertyAccess.READWRITE)
def Rate(self) -> "d": # noqa: F821
return float(self._svc.rate)
@Rate.setter
def Rate(self, value: "d"): # noqa: F821
self._svc.rate = float(value)
class MprisService:
"""Публикует MPRIS на сессионном D-Bus; состояние задаётся снаружи.
``handler`` — объект с необязательными методами ``on_play``,
``on_pause``, ``on_play_pause``, ``on_next``, ``on_previous``,
``on_stop``, ``on_seek(offset_us)``, ``on_set_position(track_path, us)``,
``on_volume(value)``, ``on_raise()``, ``on_quit()``.
"""
def __init__(
self,
handler: Any,
identity: str = "MTS Music",
desktop_entry: str = "mts-music",
):
self.handler = handler
self.identity = identity
self.desktop_entry = desktop_entry
self.enabled = False
self.playback_status = "Stopped"
self.metadata: dict[str, Any] = {}
self.position = 0
self.volume = 1.0
self.can_next = False
self.can_previous = False
self.can_play = True
self.can_pause = True
self.can_seek = False
self.loop_status = "None"
self.shuffle = False
self.rate = 1.0
self._loop: asyncio.AbstractEventLoop | None = None
self._bus: Any = None
self._player: Any = None
self._root: Any = None
self._thread: threading.Thread | None = None
if HAVE_DBUS:
self._player = _PlayerInterface(self)
self._root = _RootInterface(self)
self._thread = threading.Thread(
target=self._run, name="mpris-dbus", daemon=True
)
self._thread.start()
# -- жизненный цикл ---------------------------------------------------
def _run(self):
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._connect())
loop.run_forever()
except Exception: # pragma: no cover - окружение без D-Bus
log.debug("MPRIS недоступен", exc_info=True)
async def _connect(self):
self._bus = await MessageBus().connect()
self._bus.export(OBJECT_PATH, self._player)
self._bus.export(OBJECT_PATH, self._root)
await self._bus.request_name(BUS_NAME)
self.enabled = True
log.debug("MPRIS зарегистрирован: %s", BUS_NAME)
def close(self):
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
# -- диспетчеризация команд из D-Bus ---------------------------------
def dispatch(self, action: str, *args: Any):
callback: Callable[..., Any] | None = getattr(
self.handler, "on_" + action, None
)
if callback is None:
return
try:
callback(*args)
except Exception: # pragma: no cover
log.debug("ошибка обработчика MPRIS: %s", action, exc_info=True)
def _emit(self, changed: dict[str, Any]):
if not (self.enabled and self._loop):
return
self._loop.call_soon_threadsafe(
self._player.emit_properties_changed, changed)
# -- обновление состояния (из основного потока) ----------------------
def update_playback_status(self, status: str):
if status == self.playback_status:
return
self.playback_status = status
self.can_seek = status != "Stopped" or self.can_seek
self._emit({"PlaybackStatus": status})
def update_metadata(
self,
track_id: Any,
title: str = "",
artist: str = "",
album: str = "",
duration_ms: int = 0,
art_url: str = "",
):
if not HAVE_DBUS:
return
metadata: dict[str, Any] = {
"mpris:trackid": Variant("o", track_object_path(track_id)),
"mpris:length": Variant("x", int(duration_ms) * 1000),
"xesam:title": Variant("s", title or ""),
"xesam:artist": Variant("as", [artist] if artist else []),
"xesam:album": Variant("s", album or ""),
}
if art_url:
metadata["mpris:artUrl"] = Variant("s", art_url)
self.metadata = metadata
self._emit({"Metadata": metadata})
def clear_metadata(self):
self.metadata = {}
self._emit({"Metadata": self.metadata})
def update_position(self, position_us: int):
# Position в MPRIS не рассылается через PropertiesChanged — клиенты
# читают его сами (или ориентируются на сигнал Seeked).
self.position = max(0, int(position_us))
def notify_seeked(self, position_us: int):
self.update_position(position_us)
if self.enabled and self._loop:
self._loop.call_soon_threadsafe(
self._player.Seeked, int(position_us))
def update_volume(self, volume: float):
volume = max(0.0, min(1.0, float(volume)))
if abs(volume - self.volume) < 1e-4:
return
self.volume = volume
self._emit({"Volume": volume})
def update_capabilities(
self,
can_next: bool | None = None,
can_previous: bool | None = None,
can_play: bool | None = None,
can_pause: bool | None = None,
can_seek: bool | None = None,
):
changed: dict[str, Any] = {}
if can_next is not None and can_next != self.can_next:
self.can_next = can_next
changed["CanGoNext"] = can_next
if can_previous is not None and can_previous != self.can_previous:
self.can_previous = can_previous
changed["CanGoPrevious"] = can_previous
if can_play is not None and can_play != self.can_play:
self.can_play = can_play
changed["CanPlay"] = can_play
if can_pause is not None and can_pause != self.can_pause:
self.can_pause = can_pause
changed["CanPause"] = can_pause
if can_seek is not None and can_seek != self.can_seek:
self.can_seek = can_seek
changed["CanSeek"] = can_seek
if changed:
self._emit(changed)