feat: add watch command and pipe view
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
"""Player discovery, state monitoring, and active-player selection for watch mode."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
import asyncio
|
||||
|
||||
from dbus_next.aio.message_bus import MessageBus
|
||||
from dbus_next.constants import BusType
|
||||
from dbus_next.message import Message
|
||||
from loguru import logger
|
||||
|
||||
from ..models import TrackMeta
|
||||
from .options import WatchOptions
|
||||
|
||||
|
||||
def _variant_value(item: object) -> object | None:
|
||||
"""Extract .value from DBus variant-like objects when available."""
|
||||
if hasattr(item, "value"):
|
||||
return getattr(item, "value")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PlayerState:
|
||||
"""Current observable state for one MPRIS player."""
|
||||
|
||||
bus_name: str
|
||||
status: str
|
||||
track: Optional[TrackMeta]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlayerTarget:
|
||||
"""Constraint for choosing which players are visible to watch."""
|
||||
|
||||
hint: Optional[str] = None
|
||||
player_blacklist: tuple[str, ...] = ()
|
||||
|
||||
def validation_error(self) -> str | None:
|
||||
"""Return validation message when hint conflicts with blacklist, else None."""
|
||||
normalized_hint = self.normalized_hint
|
||||
if not normalized_hint:
|
||||
return None
|
||||
for blocked in self.player_blacklist:
|
||||
normalized_blocked = blocked.strip().lower()
|
||||
if not normalized_blocked:
|
||||
continue
|
||||
if _keyword_match(normalized_hint, normalized_blocked) or _keyword_match(
|
||||
normalized_blocked, normalized_hint
|
||||
):
|
||||
return (
|
||||
f"Requested player '{self.hint}' is blocked by "
|
||||
f"PLAYER_BLACKLIST entry '{blocked}'."
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def normalized_hint(self) -> str:
|
||||
"""Return normalized lowercase player hint string."""
|
||||
return (self.hint or "").strip().lower()
|
||||
|
||||
def allows(self, bus_name: str) -> bool:
|
||||
"""Return whether given MPRIS bus name passes this target constraint."""
|
||||
normalized_hint = self.normalized_hint
|
||||
if not normalized_hint:
|
||||
return True
|
||||
return _keyword_match(bus_name, normalized_hint)
|
||||
|
||||
|
||||
def _keyword_match(text: str, keyword: str) -> bool:
|
||||
"""Return True when keyword exists in text, case-insensitively."""
|
||||
return keyword.strip().lower() in text.lower()
|
||||
|
||||
|
||||
class PlayerMonitor:
|
||||
"""Tracks MPRIS players and forwards signal-driven state updates to session callbacks."""
|
||||
|
||||
_options: WatchOptions
|
||||
_on_players_changed: Callable[[], None]
|
||||
_on_seeked: Callable[[str, int], None]
|
||||
_on_playback_status: Callable[[str, str], None]
|
||||
_target: PlayerTarget
|
||||
players: dict[str, PlayerState]
|
||||
_bus: MessageBus | None
|
||||
_props_cache: dict[str, object]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_players_changed: Callable[[], None],
|
||||
on_seeked: Callable[[str, int], None],
|
||||
on_playback_status: Callable[[str, str], None],
|
||||
options: WatchOptions,
|
||||
target: Optional[PlayerTarget] = None,
|
||||
) -> None:
|
||||
"""Initialize monitor callbacks, runtime options, and player target filter."""
|
||||
self._options = options
|
||||
self._on_players_changed = on_players_changed
|
||||
self._on_seeked = on_seeked
|
||||
self._on_playback_status = on_playback_status
|
||||
self._target = target or PlayerTarget(
|
||||
player_blacklist=self._options.player_blacklist
|
||||
)
|
||||
self.players: dict[str, PlayerState] = {}
|
||||
self._bus: MessageBus | None = None
|
||||
self._props_cache: dict[str, object] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start DBus monitoring and populate initial player snapshot."""
|
||||
self._bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||
self._bus.add_message_handler(self._on_message)
|
||||
await self._add_match_rules()
|
||||
await self.refresh()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop DBus monitoring and close bus connection."""
|
||||
self._props_cache.clear()
|
||||
if self._bus:
|
||||
self._bus.disconnect()
|
||||
self._bus = None
|
||||
|
||||
async def _get_player_props(self, bus_name: str) -> object | None:
|
||||
"""Return cached DBus Properties interface for player, creating it if missing."""
|
||||
if not self._bus:
|
||||
return None
|
||||
if bus_name in self._props_cache:
|
||||
return self._props_cache[bus_name]
|
||||
|
||||
try:
|
||||
introspection = await self._bus.introspect(
|
||||
bus_name, "/org/mpris/MediaPlayer2"
|
||||
)
|
||||
proxy = self._bus.get_proxy_object(
|
||||
bus_name, "/org/mpris/MediaPlayer2", introspection
|
||||
)
|
||||
props = proxy.get_interface("org.freedesktop.DBus.Properties")
|
||||
self._props_cache[bus_name] = props
|
||||
return props
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to prepare DBus props for {bus_name}: {e}")
|
||||
self._props_cache.pop(bus_name, None)
|
||||
return None
|
||||
|
||||
async def _add_match_rules(self) -> None:
|
||||
"""Register signal subscriptions needed by monitor."""
|
||||
if not self._bus:
|
||||
return
|
||||
rules = [
|
||||
"type='signal',interface='org.freedesktop.DBus',member='NameOwnerChanged'",
|
||||
"type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'",
|
||||
"type='signal',interface='org.mpris.MediaPlayer2.Player',member='Seeked'",
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
await self._bus.call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
member="AddMatch",
|
||||
signature="s",
|
||||
body=[rule],
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to add DBus match rule {rule}: {e}")
|
||||
|
||||
async def _list_mpris_players(self) -> list[str]:
|
||||
"""List visible MPRIS players after applying blacklist and target filter."""
|
||||
if not self._bus:
|
||||
return []
|
||||
try:
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
member="ListNames",
|
||||
)
|
||||
)
|
||||
if not reply or not reply.body:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for name in reply.body[0]:
|
||||
if not name.startswith("org.mpris.MediaPlayer2."):
|
||||
continue
|
||||
if any(
|
||||
x.lower() in name.lower() for x in self._options.player_blacklist
|
||||
):
|
||||
continue
|
||||
if not self._target.allows(name):
|
||||
continue
|
||||
out.append(name)
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to list mpris players: {e}")
|
||||
return []
|
||||
|
||||
async def _fetch_player_state(self, bus_name: str) -> Optional[PlayerState]:
|
||||
"""Read current playback status and metadata from one player service."""
|
||||
props = await self._get_player_props(bus_name)
|
||||
if props is None:
|
||||
return None
|
||||
try:
|
||||
status_var = await getattr(props, "call_get")(
|
||||
"org.mpris.MediaPlayer2.Player", "PlaybackStatus"
|
||||
)
|
||||
metadata_var = await getattr(props, "call_get")(
|
||||
"org.mpris.MediaPlayer2.Player", "Metadata"
|
||||
)
|
||||
status = status_var.value if status_var else "Stopped"
|
||||
track = self._track_from_metadata(
|
||||
metadata_var.value if metadata_var else {}
|
||||
)
|
||||
return PlayerState(bus_name=bus_name, status=status, track=track)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read state for {bus_name}: {e}")
|
||||
self._props_cache.pop(bus_name, None)
|
||||
return None
|
||||
|
||||
def _track_from_metadata(self, metadata: dict[str, object]) -> Optional[TrackMeta]:
|
||||
"""Build TrackMeta object from MPRIS metadata map."""
|
||||
if not metadata:
|
||||
return None
|
||||
trackid = metadata.get("mpris:trackid")
|
||||
if trackid is not None:
|
||||
trackid = _variant_value(trackid)
|
||||
if isinstance(trackid, str) and trackid.startswith("spotify:track:"):
|
||||
trackid = trackid.removeprefix("spotify:track:")
|
||||
elif isinstance(trackid, str) and trackid.startswith("/com/spotify/track/"):
|
||||
trackid = trackid.removeprefix("/com/spotify/track/")
|
||||
elif not isinstance(trackid, str):
|
||||
trackid = None
|
||||
|
||||
length = metadata.get("mpris:length")
|
||||
length_ms = None
|
||||
length_value = _variant_value(length) if length is not None else None
|
||||
if isinstance(length_value, int):
|
||||
length_ms = length_value // 1000
|
||||
|
||||
artist = metadata.get("xesam:artist")
|
||||
artist_v = None
|
||||
artist_value = _variant_value(artist) if artist is not None else None
|
||||
if isinstance(artist_value, list) and artist_value:
|
||||
artist_v = artist_value[0]
|
||||
|
||||
title = metadata.get("xesam:title")
|
||||
album = metadata.get("xesam:album")
|
||||
url = metadata.get("xesam:url")
|
||||
|
||||
title_value = _variant_value(title) if title is not None else None
|
||||
album_value = _variant_value(album) if album is not None else None
|
||||
url_value = _variant_value(url) if url is not None else None
|
||||
|
||||
return TrackMeta(
|
||||
trackid=trackid,
|
||||
length=length_ms,
|
||||
album=album_value if isinstance(album_value, str) else None,
|
||||
artist=artist_v,
|
||||
title=title_value if isinstance(title_value, str) else None,
|
||||
url=url_value if isinstance(url_value, str) else None,
|
||||
)
|
||||
|
||||
async def refresh(self) -> None:
|
||||
"""Refresh full player snapshot and notify session when visible set changes."""
|
||||
players = await self._list_mpris_players()
|
||||
updated: dict[str, PlayerState] = {}
|
||||
for bus_name in players:
|
||||
st = await self._fetch_player_state(bus_name)
|
||||
if st is not None:
|
||||
updated[bus_name] = st
|
||||
|
||||
before = set(self.players.keys())
|
||||
after = set(updated.keys())
|
||||
added = sorted(after - before)
|
||||
removed = sorted(before - after)
|
||||
|
||||
for bus_name in removed:
|
||||
self._props_cache.pop(bus_name, None)
|
||||
|
||||
self.players = updated
|
||||
|
||||
if added or removed:
|
||||
logger.info(
|
||||
"MPRIS players updated: added={}, removed={}",
|
||||
added,
|
||||
removed,
|
||||
)
|
||||
|
||||
self._on_players_changed()
|
||||
|
||||
async def _resolve_well_known_name(self, unique_sender: str) -> str | None:
|
||||
"""Map a DBus unique sender (e.g. :1.42) to a tracked MPRIS bus name."""
|
||||
if unique_sender in self.players:
|
||||
return unique_sender
|
||||
if not self._bus:
|
||||
return None
|
||||
|
||||
for bus_name in self.players:
|
||||
try:
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
member="GetNameOwner",
|
||||
signature="s",
|
||||
body=[bus_name],
|
||||
)
|
||||
)
|
||||
if reply and reply.body and str(reply.body[0]) == unique_sender:
|
||||
return bus_name
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def _handle_seeked_signal(self, sender: str, position_ms: int) -> None:
|
||||
"""Route Seeked signal to session using well-known bus name when possible."""
|
||||
bus_name = await self._resolve_well_known_name(sender)
|
||||
if bus_name is not None:
|
||||
self._on_seeked(bus_name, position_ms)
|
||||
return
|
||||
|
||||
# If we cannot map sender reliably, force a state refresh to converge.
|
||||
await self.refresh()
|
||||
|
||||
def _on_message(self, message: Message) -> bool:
|
||||
"""Low-level DBus signal handler for player lifecycle/status/seek events."""
|
||||
try:
|
||||
if (
|
||||
message.interface == "org.freedesktop.DBus"
|
||||
and message.member == "NameOwnerChanged"
|
||||
):
|
||||
if message.body and str(message.body[0]).startswith(
|
||||
"org.mpris.MediaPlayer2."
|
||||
):
|
||||
asyncio.create_task(self.refresh())
|
||||
return False
|
||||
|
||||
if (
|
||||
message.interface == "org.freedesktop.DBus.Properties"
|
||||
and message.member == "PropertiesChanged"
|
||||
):
|
||||
# Message.sender is a DBus unique name, so match by path+iface.
|
||||
path_ok = message.path == "/org/mpris/MediaPlayer2"
|
||||
iface = message.body[0] if message.body else None
|
||||
if path_ok and iface == "org.mpris.MediaPlayer2.Player":
|
||||
asyncio.create_task(self.refresh())
|
||||
return False
|
||||
|
||||
if (
|
||||
message.interface == "org.mpris.MediaPlayer2.Player"
|
||||
and message.member == "Seeked"
|
||||
):
|
||||
sender = message.sender or ""
|
||||
if sender and message.body:
|
||||
position_us = int(message.body[0])
|
||||
asyncio.create_task(
|
||||
self._handle_seeked_signal(
|
||||
sender,
|
||||
max(0, position_us // 1000),
|
||||
)
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"PlayerMonitor signal handling error: {e}")
|
||||
return False
|
||||
|
||||
async def get_position_ms(self, bus_name: str) -> Optional[int]:
|
||||
"""Read player-reported position in milliseconds."""
|
||||
props = await self._get_player_props(bus_name)
|
||||
if props is None:
|
||||
return None
|
||||
try:
|
||||
position_var = await getattr(props, "call_get")(
|
||||
"org.mpris.MediaPlayer2.Player", "Position"
|
||||
)
|
||||
if position_var is None:
|
||||
return None
|
||||
return max(0, int(position_var.value) // 1000)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read position from {bus_name}: {e}")
|
||||
self._props_cache.pop(bus_name, None)
|
||||
return None
|
||||
|
||||
|
||||
class ActivePlayerSelector:
|
||||
@staticmethod
|
||||
def select(
|
||||
players: dict[str, PlayerState],
|
||||
last_active: str | None,
|
||||
options: WatchOptions,
|
||||
) -> str | None:
|
||||
"""Select active player by playing state, preferred keyword, and continuity."""
|
||||
if not players:
|
||||
return None
|
||||
|
||||
playing = [name for name, st in players.items() if st.status == "Playing"]
|
||||
if len(playing) == 1:
|
||||
return playing[0]
|
||||
|
||||
preferred = options.preferred_player.lower().strip()
|
||||
candidates = playing if playing else list(players.keys())
|
||||
if preferred:
|
||||
for name in candidates:
|
||||
if preferred in name.lower():
|
||||
return name
|
||||
|
||||
if last_active and last_active in players:
|
||||
return last_active
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
Reference in New Issue
Block a user