Files
Modus/src/services/mpris.py
T
sankalp 2a9964a8e5 refactor(mpris): replace Fabricator with direct GLib.timeout_add
- Remove Fabricator dependency from position/status polling
- Position polling: _pos_source_id + _on_pos_poll() (2s interval)
- Status polling: _status_source_id + _on_status_poll() (5s interval)
- Simplified cleanup with direct source_remove calls
2026-07-18 21:43:22 +05:45

600 lines
21 KiB
Python

import hashlib
import mimetypes
import threading
import urllib.parse
import urllib.request
from pathlib import Path
import gi
from fabric.utils import GLib, exec_shell_command_async, logger
gi.require_version("Playerctl", "2.0")
from fabric.core.service import Property, Service, Signal # noqa: E402
from gi.repository import Playerctl # noqa: E402
from shared.data import CACHE_DIR # noqa: E402
TEMP_DIR = CACHE_DIR
# Native Linux media artwork cache (libmediaart). Optional: when the GI
# namespace is unavailable we transparently fall back to our own cache dir,
# so the service keeps working without adding a hard dependency.
try:
gi.require_version("MediaArt", "2.0")
from gi.repository import MediaArt
_MEDIAART_AVAILABLE = True
except (ValueError, ImportError):
MediaArt = None
_MEDIAART_AVAILABLE = False
def _mediaart_cache_path(artist: str | None, album: str | None, title: str | None):
"""Standard media-art cache path for a track, or None when unavailable.
Does NOT download; merely derives the canonical cache location so we can
both look artwork up and store new downloads following the same rules.
"""
if not _MEDIAART_AVAILABLE:
return None
try:
result = MediaArt.get_path(
artist or None, album or None, title or None, MediaArt.Type.ALBUM
)
except Exception as e:
logger.debug(f"MediaArt.get_path failed: {e}")
return None
# PyGObject return shape varies by version: (found, path, uri) or (path, uri).
if isinstance(result, tuple):
if len(result) == 3:
found, path, _uri = result
elif len(result) == 2:
found, path = True, result[0]
else:
return None
return path if found else None
return result
class PlayerService(Service):
@Signal
def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
@Signal
def artwork_change(self, local_path: str) -> None: ...
@Signal
def pause(self) -> None: ...
@Signal
def play(self) -> None: ...
@Signal
def track_position(self, pos: float, dur: float) -> None: ...
@Property(bool, "readable", default_value=False)
def can_seek(self) -> bool:
return self._player.get_property("can_seek")
@Property(str, "readable", default_value="")
def player_name(self) -> str:
try:
return self._player.props.player_name or ""
except Exception as e:
logger.warning(
f"[mpris] return self._player.props.player_name or '' failed: {e}"
)
return ""
@Property(str, "readable", default_value="")
def title(self) -> str:
try:
return self._player.get_title() or ""
except Exception as e:
logger.warning(f"[mpris] return self._player.get_title() or '' failed: {e}")
return ""
@Property(object, "readable")
def artist(self) -> list:
try:
return list(self._player.props.metadata["xesam:artist"]) or []
except Exception as e:
logger.warning(
f"[mpris] return list(self._player.props.metadata['xesam:artist']) ... failed: {e}"
)
return []
@Property(str, "readable", default_value="")
def album(self) -> str:
try:
return self._player.props.metadata["xesam:album"] or ""
except Exception as e:
logger.warning(
f"[mpris] return self._player.props.metadata['xesam:album'] or '' failed: {e}"
)
return ""
@Property(str, "readable", default_value="Stopped")
def playback_status(self) -> str:
try:
val = int(self._player.props.playback_status)
if val == 0:
return "Playing"
elif val == 1:
return "Paused"
else:
return "Stopped"
except Exception as e:
logger.warning(
f"[mpris] val = int(self._player.props.playback_status) failed: {e}"
)
return "Stopped"
@Property(int, "readable", default_value=0)
def length(self) -> int:
try:
return int(self._player.props.metadata["mpris:length"])
except Exception as e:
logger.warning(
f"[mpris] return int(self._player.props.metadata['mpris:length']) failed: {e}"
)
return 0
@Property(int, "readable", default_value=0)
def position(self) -> int:
try:
return int(self._player.get_position())
except Exception as e:
logger.warning(
f"[mpris] return int(self._player.get_position()) failed: {e}"
)
return 0
def play_pause(self, *_):
try:
self._player.play_pause()
except GLib.Error as e:
logger.warning(f"play_pause failed: {e}")
def next(self, *_):
try:
self._player.next()
except GLib.Error as e:
logger.warning(f"next failed: {e}")
def previous(self, *_):
try:
self._player.previous()
except GLib.Error as e:
logger.warning(f"previous failed: {e}")
def __init__(self, player: Playerctl.Player, **kwargs):
super().__init__(**kwargs)
self._player: Playerctl.Player = player
self._current_artwork_hash = ""
self._current_artwork_path = ""
self._is_cleaning_up = False
self._artwork_generation = 0
self._signal_ids = []
self._last_emitted_status = ""
self._signal_ids.append(
self._player.connect("playback-status", self.on_playback_status)
)
self._signal_ids.append(self._player.connect("metadata", self.on_metadata))
self._signal_ids.append(self._player.connect("seeked", self.on_seeked))
self._pos_source_id = 0
self._status_source_id = 0
self._last_polled_status = ""
self._start_status_polling()
self.poll_progress()
try:
metadata = self._player.props.metadata
if metadata:
# meta_change emission must not block artwork discovery.
try:
self.meta_change(metadata, self._player)
except Exception as e:
logger.debug(f"meta_change emit failed: {e}")
self._handle_artwork(metadata)
except Exception as e:
logger.warning(f"Failed to initialize metadata: {e}")
# Playerctl loads player properties asynchronously. For a player that
# was already playing before Modus started, the initial metadata read
# above can be empty, and the `metadata` signal only fires on *change*
# — so it would never re-fire and artwork would stay missing. Browser
# players (YouTube in Firefox/Chromium) in particular populate
# `mpris:artUrl` lazily, seconds after playback begins. Re-read metadata
# repeatedly for a short window so the artwork is eventually picked up.
GLib.timeout_add(500, self._refresh_initial_state, 0)
def _refresh_initial_state(self, attempt: int = 0):
if self._is_cleaning_up:
return False
try:
metadata = self._player.props.metadata
except Exception as e:
logger.warning(
f"[mpris] metadata = self._player.props.metadata failed: {e}"
)
return False
if metadata:
# Push full metadata only once to avoid UI churn on every retry.
if attempt == 0:
try:
self.meta_change(metadata, self._player)
except Exception as e:
logger.debug(f"meta_change emit failed: {e}")
self._handle_artwork(metadata)
self.fabricating(metadata)
# Stop as soon as artwork was found/queued.
if self._current_artwork_hash:
return False
# Retry for ~10s (browser players can be very slow to expose artUrl).
if attempt < 12:
GLib.timeout_add(800, self._refresh_initial_state, attempt + 1)
return False
def get_artwork(self) -> str:
return self._current_artwork_path
def get_position(self) -> float:
if self._is_cleaning_up:
return 0
try:
return self._player.get_position()
except Exception as e:
logger.warning(f"Could not get position: {e}")
return 0
def _start_pos_fabricator(self):
"""Start position polling only if not already running."""
if self._pos_source_id:
return
self._pos_source_id = GLib.timeout_add(2000, self._on_pos_poll)
def _stop_pos_fabricator(self):
"""Stop position polling."""
if not self._pos_source_id:
return
GLib.source_remove(self._pos_source_id)
self._pos_source_id = 0
def _on_pos_poll(self):
if self._is_cleaning_up:
return False
self.fabricating()
return True # keep polling while playing
def _start_status_polling(self):
"""Start status polling for browser players that don't emit signals."""
if self._status_source_id:
return
self._last_polled_status = self.playback_status
self._status_source_id = GLib.timeout_add(5000, self._on_status_poll)
def _stop_status_polling(self):
"""Stop status polling."""
if not self._status_source_id:
return
GLib.source_remove(self._status_source_id)
self._status_source_id = 0
def _on_status_poll(self):
if self._is_cleaning_up:
return False
status = self.playback_status
if status != self._last_polled_status:
self._last_polled_status = status
self.poll_progress()
self._notify_playback(status)
return True # keep polling
def seek_position(self, pos: float):
if self._is_cleaning_up:
return
self._stop_pos_fabricator()
current = self.get_position() / 1_000_000
try:
self._player.set_position(int(pos * 1_000_000))
except GLib.Error:
try:
offset = pos - current
self._player.seek(int(offset * 1_000_000))
except GLib.Error:
name = self.player_name
if name:
# No shell: pass args as a list so player_name can't inject.
exec_shell_command_async(
["playerctl", "-p", name, "position", str(pos)]
)
finally:
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
def seek(self, offset: float):
if self._is_cleaning_up:
return
self._stop_pos_fabricator()
try:
self._player.seek(int(offset * 1_000_000))
except GLib.Error as e:
logger.error(f"Failed to seek: {e}")
finally:
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
def poll_progress(self):
if self._is_cleaning_up:
return
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
else:
self._stop_pos_fabricator()
def fabricating(self, metadata=None):
if self._is_cleaning_up:
return
try:
pos = self._player.get_position() / 1_000_000
except GLib.Error as e:
logger.warning(f"Failed to get position: {e}")
return
dur = 0
if metadata is None:
try:
metadata = self._player.props.metadata
except Exception as e:
logger.debug(f"[mpris] Failed to read metadata: {e}")
metadata = None
if metadata is not None:
try:
dur = metadata["mpris:length"] / 1_000_000
except Exception as e:
logger.debug(f"[mpris] Failed to read duration: {e}")
dur = 0
# Browser players (YouTube in Firefox/Chromium) populate
# `mpris:artUrl` lazily and don't reliably emit the `metadata`
# signal. Since the position poll runs every 2s while playing, this
# also polls the artwork so it shows up even for an already-playing tab.
self._handle_artwork(metadata)
self.track_position(pos, dur)
def on_seeked(self, player, position):
if self._is_cleaning_up:
return
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
def _notify_playback(self, status_str: str):
"""Emit play/pause once per actual state change (dedupes the
Playerctl signal and the status poller so listeners don't churn)."""
if self._is_cleaning_up:
return
status_str = (status_str or "").lower()
if status_str == self._last_emitted_status:
return
self._last_emitted_status = status_str
if status_str == "playing":
self.play()
else:
self.pause()
def on_playback_status(self, player, status):
"""DBus signal handler - instant when it fires (e.g. Modus buttons)."""
if self._is_cleaning_up:
return
self.poll_progress()
self._notify_playback(self.playback_status)
def _on_polled_status_change(self, new_status: str):
"""Status poll change - fires reliably for browser players
that don't emit playback-status signals."""
if self._is_cleaning_up:
return
self.poll_progress()
self._notify_playback(new_status)
def on_metadata(self, player, metadata):
if self._is_cleaning_up:
return
self.meta_change(metadata, player)
self.fabricating(metadata)
def _meta_str(self, metadata, key: str) -> str | None:
try:
value = metadata[key]
except (KeyError, TypeError, GLib.Error) as e:
logger.warning(f"[mpris] value = metadata[key] failed: {e}")
return None
if isinstance(value, (list, tuple)):
return " ".join(str(v) for v in value) if value else None
return str(value) if value else None
def _find_cached_artwork(self, metadata) -> str | None:
"""Locate already-cached artwork for this track (native MediaArt cache)."""
if not _MEDIAART_AVAILABLE:
return None
artist = self._meta_str(metadata, "xesam:artist")
album = self._meta_str(metadata, "xesam:album")
title = self._meta_str(metadata, "xesam:title")
if not (artist or album or title):
return None
base = _mediaart_cache_path(artist, album, title)
if not base:
return None
base = Path(base)
if base.exists():
return str(base)
# MediaArt files usually carry an extension; look for any sibling.
matches = list(base.parent.glob(base.stem + ".*"))
return str(matches[0]) if matches else None
def _existing_local_artwork(self, artwork_hash: str, metadata) -> str | None:
cached = self._find_cached_artwork(metadata)
if cached:
return cached
cache_dir = TEMP_DIR / "player-art"
matches = list(cache_dir.glob(f"{artwork_hash}.*"))
return str(matches[0]) if matches else None
def _handle_artwork(self, metadata):
if self._is_cleaning_up:
return
try:
art_url = metadata["mpris:artUrl"]
except (KeyError, TypeError) as e:
logger.warning(f"[mpris] art_url = metadata['mpris:artUrl'] failed: {e}")
return
if not art_url:
return
artwork_hash = hashlib.md5(art_url.encode()).hexdigest()
if artwork_hash == self._current_artwork_hash:
return
self._current_artwork_hash = artwork_hash
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
local = urllib.parse.unquote(parsed.path)
if Path(local).exists():
self._set_artwork(local)
return
if parsed.scheme in ("http", "https"):
existing = self._existing_local_artwork(artwork_hash, metadata)
if existing and Path(existing).exists():
self._set_artwork(existing)
return
# Capture generation so the thread can bail out if cleanup runs
# while the download is in-flight.
gen = self._artwork_generation
threading.Thread(
target=self._download_artwork,
args=(art_url, artwork_hash, metadata, gen),
daemon=True,
).start()
def _set_artwork(self, path: str):
if self._is_cleaning_up:
return
self._current_artwork_path = path
self.artwork_change(path)
def _artwork_target_path(self, artwork_hash: str, metadata, suffix: str) -> Path:
"""Where to store a freshly downloaded cover: the standard MediaArt
cache location when available, otherwise our own fallback cache."""
ma_base = self._find_cached_artwork(metadata)
if ma_base:
base = Path(ma_base)
return base.parent / (base.stem + suffix)
cache_dir = TEMP_DIR / "player-art"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / f"{artwork_hash}{suffix}"
def _download_artwork(self, art_url: str, artwork_hash: str, metadata, gen: int):
try:
with urllib.request.urlopen(art_url, timeout=5) as response:
data = response.read()
suffix = (
mimetypes.guess_extension(response.info().get_content_type())
or ".png"
)
target = self._artwork_target_path(artwork_hash, metadata, suffix)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_bytes(data)
tmp.replace(target)
# Bail out if cleanup ran while we were downloading — the idle
# callback would reference a stale service.
if not self._is_cleaning_up and gen == self._artwork_generation:
GLib.idle_add(self._set_artwork, str(target))
except Exception as e:
logger.error(f"Failed to download artwork: {e}")
def cleanup(self):
if self._is_cleaning_up:
return
self._is_cleaning_up = True
# Invalidate any in-flight artwork download threads so they won't
# call _set_artwork on a stale service.
self._artwork_generation += 1
self._stop_pos_fabricator()
self._stop_status_polling()
for signal_id in self._signal_ids:
try:
self._player.disconnect(signal_id)
except Exception as e:
logger.warning(f"Error disconnecting signal: {e}")
self._signal_ids.clear()
self._current_artwork_path = ""
class PlayerManager(Service):
_instance = None
@Signal
def new_player(self, player_name: str, service: PlayerService) -> None: ...
@Signal
def player_vanish(self, player_name: str) -> None: ...
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_singleton()
return cls._instance
def _init_singleton(self):
super().__init__()
self._manager = Playerctl.PlayerManager()
self._services: dict[str, PlayerService] = {}
self._player_objects: dict[str, Playerctl.Player] = {}
self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
self._manager.connect(
"player-vanished", self._on_player_vanished, self._manager
)
self._init_existing_players()
def _init_existing_players(self):
for player_obj in self._manager.props.player_names:
self._create_player(player_obj)
def _create_player(self, name_obj):
name_str = name_obj.name
if name_str in self._services:
return
try:
player = Playerctl.Player.new_from_name(name_obj)
self._manager.manage_player(player)
service = PlayerService(player)
self._services[name_str] = service
self._player_objects[name_str] = player
self.new_player(name_str, service)
except Exception as e:
logger.error(f"Failed to create player {name_str}: {e}")
def _on_name_appeared(self, sender, name, _manager):
self._create_player(name)
def _on_player_vanished(self, sender, player, _manager):
name = player.props.player_name
if name in self._services:
self._services[name].cleanup()
del self._services[name]
self._player_objects.pop(name, None)
self.player_vanish(name)
def get_player_service(self, name: str) -> PlayerService | None:
return self._services.get(name)
def get_all_services(self) -> dict[str, PlayerService]:
return self._services.copy()