"""Linux media controller using MPRIS D-Bus interface.""" import asyncio import logging import os import subprocess import threading from typing import Any, Optional from urllib.parse import unquote, urlparse from ..models import MediaState, MediaStatus from .media_controller import MediaController logger = logging.getLogger(__name__) # Cap remote artwork downloads so a hostile / huge Spotify image can't # blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable # upper bound that also covers loss-less PNGs. _MAX_ART_BYTES = 8 * 1024 * 1024 _ART_FETCH_TIMEOUT = 5.0 # seconds # Linux-specific imports try: import dbus from dbus.mainloop.glib import DBusGMainLoop DBUS_AVAILABLE = True except ImportError: DBUS_AVAILABLE = False logger.warning("D-Bus libraries not available") class LinuxMediaController(MediaController): """Media controller for Linux using MPRIS D-Bus interface.""" MPRIS_PATH = "/org/mpris/MediaPlayer2" MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player" MPRIS_PREFIX = "org.mpris.MediaPlayer2." PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties" def __init__(self): if not DBUS_AVAILABLE: raise RuntimeError( "Linux media control requires dbus-python package. " "Install with: sudo apt-get install python3-dbus" ) # The session-bus connection is deferred until first use. Connecting # in __init__ raised during app startup whenever the user's session # bus wasn't ready yet — common under systemd (service starts # before logind set up /run/user//bus), under SSH-without-X11, # and in headless CI. Failing here killed the whole lifespan; now # MPRIS calls return "idle" until the bus appears, and other # endpoints (health, scripts, browser, …) keep working. self._bus_lock = threading.Lock() self._bus = None # type: ignore[assignment] self._bus_init_logged = False # Cached art bytes keyed by the mpris:artUrl currently in flight. # Lock guards the swap from the status thread vs the artwork handler. self._art_lock = threading.Lock() self._art_url: Optional[str] = None self._art_bytes: Optional[bytes] = None def _get_bus(self): """Lazily connect to the session bus; returns None if unavailable.""" if self._bus is not None: return self._bus with self._bus_lock: if self._bus is not None: return self._bus try: DBusGMainLoop(set_as_default=True) self._bus = dbus.SessionBus() logger.info("Connected to D-Bus session bus") return self._bus except Exception as e: # Log once at INFO to avoid log spam if every status poll fails. if not self._bus_init_logged: logger.info( "D-Bus session bus not available (%s). " "MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS" " is set and the bus is reachable. Under systemd, ensure" " `loginctl enable-linger ` is set.", e, ) self._bus_init_logged = True return None def _get_active_player(self) -> Optional[str]: """Find an active MPRIS media player on the bus.""" bus = self._get_bus() if bus is None: return None try: bus_names = bus.list_names() mpris_players = [ name for name in bus_names if name.startswith(self.MPRIS_PREFIX) ] if not mpris_players: return None # Prefer players that are currently playing for player in mpris_players: try: proxy = self._bus.get_object(player, self.MPRIS_PATH) props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE) status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus") if status == "Playing": return player except Exception: continue # Return the first available player return mpris_players[0] except Exception as e: logger.error(f"Failed to get active player: {e}") return None def _get_player_interface(self, player_name: str): """Get the MPRIS player interface.""" proxy = self._bus.get_object(player_name, self.MPRIS_PATH) return dbus.Interface(proxy, self.MPRIS_INTERFACE) def _get_properties_interface(self, player_name: str): """Get the properties interface for a player.""" proxy = self._bus.get_object(player_name, self.MPRIS_PATH) return dbus.Interface(proxy, self.PROPERTIES_INTERFACE) def _get_property(self, player_name: str, property_name: str) -> Any: """Get a property from the player.""" try: props = self._get_properties_interface(player_name) return props.Get(self.MPRIS_INTERFACE, property_name) except Exception as e: logger.debug(f"Failed to get property {property_name}: {e}") return None def _get_volume_pulseaudio(self) -> tuple[int, bool]: """Get volume using pactl (PulseAudio/PipeWire).""" try: # Get default sink volume result = subprocess.run( ["pactl", "get-sink-volume", "@DEFAULT_SINK@"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: # Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB" for part in result.stdout.split("/"): if "%" in part: volume = int(part.strip().rstrip("%")) break else: volume = 100 else: volume = 100 # Get mute status result = subprocess.run( ["pactl", "get-sink-mute", "@DEFAULT_SINK@"], capture_output=True, text=True, timeout=5, ) muted = "yes" in result.stdout.lower() if result.returncode == 0 else False return volume, muted except Exception as e: logger.error(f"Failed to get volume via pactl: {e}") return 100, False def _set_volume_pulseaudio(self, volume: int) -> bool: """Set volume using pactl.""" try: result = subprocess.run( ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"], capture_output=True, timeout=5, ) return result.returncode == 0 except Exception as e: logger.error(f"Failed to set volume: {e}") return False def _toggle_mute_pulseaudio(self) -> bool: """Toggle mute using pactl, returns new mute state.""" try: result = subprocess.run( ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], capture_output=True, timeout=5, ) if result.returncode == 0: _, muted = self._get_volume_pulseaudio() return muted return False except Exception as e: logger.error(f"Failed to toggle mute: {e}") return False def _sync_get_status(self) -> MediaStatus: """Synchronous status read (called from a worker thread).""" status = MediaStatus() volume, muted = self._get_volume_pulseaudio() status.volume = volume status.muted = muted player_name = self._get_active_player() if player_name is None: status.state = MediaState.IDLE return status playback_status = self._get_property(player_name, "PlaybackStatus") if playback_status == "Playing": status.state = MediaState.PLAYING elif playback_status == "Paused": status.state = MediaState.PAUSED elif playback_status == "Stopped": status.state = MediaState.STOPPED else: status.state = MediaState.IDLE metadata = self._get_property(player_name, "Metadata") if metadata: status.title = str(metadata.get("xesam:title", "")) or None artists = metadata.get("xesam:artist", []) if artists: status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) status.album = str(metadata.get("xesam:album", "")) or None art_url = str(metadata.get("mpris:artUrl", "")) or None status.album_art_url = art_url # Invalidate cached bytes when the track changes. Real fetch # happens lazily in get_album_art() so the status hot path # never blocks on HTTP. with self._art_lock: if art_url != self._art_url: self._art_url = art_url self._art_bytes = None length = metadata.get("mpris:length", 0) if length: status.duration = int(length) / 1_000_000 position = self._get_property(player_name, "Position") if position is not None: status.position = int(position) / 1_000_000 status.source = player_name.replace(self.MPRIS_PREFIX, "") return status async def get_status(self) -> MediaStatus: """Get current media playback status (off the event loop).""" # pactl + DBus calls each take 5-100ms on a Pi and would block every # other coroutine on the server. Run them in a worker thread. return await asyncio.to_thread(self._sync_get_status) def _call_player(self, method_name: str) -> bool: player_name = self._get_active_player() if player_name is None: return False try: player = self._get_player_interface(player_name) getattr(player, method_name)() return True except Exception as e: logger.error(f"Failed to call player.{method_name}: {e}") return False async def play(self) -> bool: return await asyncio.to_thread(self._call_player, "Play") async def pause(self) -> bool: return await asyncio.to_thread(self._call_player, "Pause") async def stop(self) -> bool: return await asyncio.to_thread(self._call_player, "Stop") async def next_track(self) -> bool: return await asyncio.to_thread(self._call_player, "Next") async def previous_track(self) -> bool: return await asyncio.to_thread(self._call_player, "Previous") async def set_volume(self, volume: int) -> bool: return await asyncio.to_thread(self._set_volume_pulseaudio, volume) async def toggle_mute(self) -> bool: return await asyncio.to_thread(self._toggle_mute_pulseaudio) def _sync_seek(self, position: float) -> bool: player_name = self._get_active_player() if player_name is None: return False try: player = self._get_player_interface(player_name) player.SetPosition( self._get_property(player_name, "Metadata").get("mpris:trackid", "/"), int(position * 1_000_000), ) return True except Exception as e: logger.error(f"Failed to seek: {e}") return False async def seek(self, position: float) -> bool: return await asyncio.to_thread(self._sync_seek, position) async def open_file(self, file_path: str) -> bool: """Open a media file with the default system player (Linux). Uses xdg-open to open the file with the default application. Args: file_path: Absolute path to the media file Returns: True if successful, False otherwise """ try: process = await asyncio.create_subprocess_exec( 'xdg-open', file_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL ) await process.wait() logger.info(f"Opened file with default player: {file_path}") return True except Exception as e: logger.error(f"Failed to open file {file_path}: {e}") return False def _fetch_art_sync(self, url: str) -> Optional[bytes]: """Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://). Other schemes (data:, ftp:, …) are rejected — we only support the two cases real-world MPRIS providers use. The HTTP path is capped at _MAX_ART_BYTES and the file path is read with a size guard so a symlink to /dev/zero can't OOM the server. """ try: parsed = urlparse(url) except ValueError: return None scheme = parsed.scheme.lower() if scheme == "file": path = unquote(parsed.path) try: size = os.stat(path).st_size if size <= 0 or size > _MAX_ART_BYTES: return None with open(path, "rb") as f: return f.read(_MAX_ART_BYTES) except OSError as e: logger.debug("Could not read local art %s: %s", path, e) return None if scheme in ("http", "https"): import urllib.error import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"}) try: with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp: # Cap reads to defend against unbounded responses. return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e: logger.debug("Could not fetch remote art %s: %s", url, e) return None logger.debug("Unsupported art URL scheme: %s", scheme) return None async def get_album_art(self) -> Optional[bytes]: """Return cached MPRIS art, fetching on first access per track.""" with self._art_lock: url = self._art_url cached = self._art_bytes if cached is not None: return cached if not url: return None data = await asyncio.to_thread(self._fetch_art_sync, url) # Store even on None so we don't re-hammer a 404 every second. with self._art_lock: if url == self._art_url: self._art_bytes = data return data