fix(mpris): read properties independent of the introspection table
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lrx-cli"
|
name = "lrx-cli"
|
||||||
version = "0.7.11"
|
version = "0.7.12"
|
||||||
description = "Fetch line-synced lyrics for your music player."
|
description = "Fetch line-synced lyrics for your music player."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ MULTI_CANDIDATE_DELAY_S = 0.2 # delay between sequential lyric fetches
|
|||||||
LEGACY_CONFIDENCE = 50.0
|
LEGACY_CONFIDENCE = 50.0
|
||||||
|
|
||||||
# User-Agents
|
# User-Agents
|
||||||
UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
|
UA_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_8_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15"
|
||||||
UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
|
UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
|
||||||
|
|
||||||
MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes
|
MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes
|
||||||
|
|||||||
+36
-24
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dbus_next.aio.message_bus import MessageBus
|
from dbus_next.aio.message_bus import MessageBus
|
||||||
from dbus_next.constants import BusType
|
from dbus_next.constants import BusType, MessageType
|
||||||
from dbus_next.message import Message
|
from dbus_next.message import Message
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
@@ -38,17 +38,39 @@ async def _list_mpris_players(bus: MessageBus) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_player_property(
|
||||||
|
bus: MessageBus, player_name: str, prop: str
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Read an org.mpris.MediaPlayer2.Player property with a raw Properties.Get.
|
||||||
|
|
||||||
|
Raw calls keep property access independent of the peer's introspection data.
|
||||||
|
"""
|
||||||
|
reply = await bus.call(
|
||||||
|
Message(
|
||||||
|
destination=player_name,
|
||||||
|
path="/org/mpris/MediaPlayer2",
|
||||||
|
interface="org.freedesktop.DBus.Properties",
|
||||||
|
member="Get",
|
||||||
|
signature="ss",
|
||||||
|
body=["org.mpris.MediaPlayer2.Player", prop],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reply is None:
|
||||||
|
return None
|
||||||
|
if reply.message_type != MessageType.METHOD_RETURN:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Properties.Get({prop}) failed: "
|
||||||
|
f"{reply.body[0] if reply.body else reply.error_name or 'no error body'}"
|
||||||
|
)
|
||||||
|
if not reply.body:
|
||||||
|
return None
|
||||||
|
return reply.body[0]
|
||||||
|
|
||||||
|
|
||||||
async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[str]:
|
async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[str]:
|
||||||
"""Get PlaybackStatus ('Playing', 'Paused', 'Stopped') for a player."""
|
"""Get PlaybackStatus ('Playing', 'Paused', 'Stopped') for a player."""
|
||||||
try:
|
try:
|
||||||
introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2")
|
status_var = await get_player_property(bus, player_name, "PlaybackStatus")
|
||||||
proxy = bus.get_proxy_object(
|
|
||||||
player_name, "/org/mpris/MediaPlayer2", introspection
|
|
||||||
)
|
|
||||||
props = proxy.get_interface("org.freedesktop.DBus.Properties")
|
|
||||||
status_var = await getattr(props, "call_get")(
|
|
||||||
"org.mpris.MediaPlayer2.Player", "PlaybackStatus"
|
|
||||||
)
|
|
||||||
return status_var.value if status_var else None
|
return status_var.value if status_var else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not get playback status for {player_name}: {e}")
|
logger.debug(f"Could not get playback status for {player_name}: {e}")
|
||||||
@@ -128,8 +150,10 @@ async def _fetch_metadata_dbus(
|
|||||||
specific_player: Optional[str],
|
specific_player: Optional[str],
|
||||||
preferred_player: str,
|
preferred_player: str,
|
||||||
player_blacklist: tuple[str, ...],
|
player_blacklist: tuple[str, ...],
|
||||||
|
bus: Optional[MessageBus] = None,
|
||||||
) -> Optional[TrackMeta]:
|
) -> Optional[TrackMeta]:
|
||||||
bus = None
|
owns_bus = bus is None
|
||||||
|
if bus is None:
|
||||||
try:
|
try:
|
||||||
bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -148,20 +172,8 @@ async def _fetch_metadata_dbus(
|
|||||||
|
|
||||||
logger.debug(f"Using player: {player_name}")
|
logger.debug(f"Using player: {player_name}")
|
||||||
|
|
||||||
introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2")
|
|
||||||
proxy = bus.get_proxy_object(
|
|
||||||
player_name, "/org/mpris/MediaPlayer2", introspection
|
|
||||||
)
|
|
||||||
|
|
||||||
props_iface = proxy.get_interface("org.freedesktop.DBus.Properties")
|
|
||||||
if not props_iface:
|
|
||||||
logger.error(f"Player {player_name} doesn't support Properties interface.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata_var: Any = await getattr(props_iface, "call_get")(
|
metadata_var = await get_player_property(bus, player_name, "Metadata")
|
||||||
"org.mpris.MediaPlayer2.Player", "Metadata"
|
|
||||||
)
|
|
||||||
if not metadata_var:
|
if not metadata_var:
|
||||||
logger.error("Empty metadata received.")
|
logger.error("Empty metadata received.")
|
||||||
return None
|
return None
|
||||||
@@ -215,7 +227,7 @@ async def _fetch_metadata_dbus(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if bus:
|
if owns_bus and bus:
|
||||||
bus.disconnect()
|
bus.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from dbus_next.message import Message
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ..models import TrackMeta
|
from ..models import TrackMeta
|
||||||
from ..mpris import pick_active_player
|
from ..mpris import get_player_property, pick_active_player
|
||||||
|
|
||||||
|
|
||||||
def _variant_value(item: object) -> object | None:
|
def _variant_value(item: object) -> object | None:
|
||||||
@@ -70,7 +70,6 @@ class PlayerMonitor:
|
|||||||
_target: PlayerTarget
|
_target: PlayerTarget
|
||||||
players: dict[str, PlayerState]
|
players: dict[str, PlayerState]
|
||||||
_bus: MessageBus | None
|
_bus: MessageBus | None
|
||||||
_props_cache: dict[str, object]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -88,7 +87,6 @@ class PlayerMonitor:
|
|||||||
self._target = target or PlayerTarget()
|
self._target = target or PlayerTarget()
|
||||||
self.players: dict[str, PlayerState] = {}
|
self.players: dict[str, PlayerState] = {}
|
||||||
self._bus: MessageBus | None = None
|
self._bus: MessageBus | None = None
|
||||||
self._props_cache: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start DBus monitoring and populate initial player snapshot."""
|
"""Start DBus monitoring and populate initial player snapshot."""
|
||||||
@@ -99,33 +97,10 @@ class PlayerMonitor:
|
|||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Stop DBus monitoring and close bus connection."""
|
"""Stop DBus monitoring and close bus connection."""
|
||||||
self._props_cache.clear()
|
|
||||||
if self._bus:
|
if self._bus:
|
||||||
self._bus.disconnect()
|
self._bus.disconnect()
|
||||||
self._bus = None
|
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:
|
async def _add_match_rules(self) -> None:
|
||||||
"""Register signal subscriptions needed by monitor."""
|
"""Register signal subscriptions needed by monitor."""
|
||||||
if not self._bus:
|
if not self._bus:
|
||||||
@@ -190,16 +165,13 @@ class PlayerMonitor:
|
|||||||
|
|
||||||
async def _fetch_player_state(self, bus_name: str) -> Optional[PlayerState]:
|
async def _fetch_player_state(self, bus_name: str) -> Optional[PlayerState]:
|
||||||
"""Read current playback status and metadata from one player service."""
|
"""Read current playback status and metadata from one player service."""
|
||||||
props = await self._get_player_props(bus_name)
|
if not self._bus:
|
||||||
if props is None:
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
status_var = await getattr(props, "call_get")(
|
status_var = await get_player_property(
|
||||||
"org.mpris.MediaPlayer2.Player", "PlaybackStatus"
|
self._bus, bus_name, "PlaybackStatus"
|
||||||
)
|
|
||||||
metadata_var = await getattr(props, "call_get")(
|
|
||||||
"org.mpris.MediaPlayer2.Player", "Metadata"
|
|
||||||
)
|
)
|
||||||
|
metadata_var = await get_player_property(self._bus, bus_name, "Metadata")
|
||||||
status = status_var.value if status_var else "Stopped"
|
status = status_var.value if status_var else "Stopped"
|
||||||
track = self._track_from_metadata(
|
track = self._track_from_metadata(
|
||||||
metadata_var.value if metadata_var else {}
|
metadata_var.value if metadata_var else {}
|
||||||
@@ -207,7 +179,6 @@ class PlayerMonitor:
|
|||||||
return PlayerState(bus_name=bus_name, status=status, track=track)
|
return PlayerState(bus_name=bus_name, status=status, track=track)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to read state for {bus_name}: {e}")
|
logger.debug(f"Failed to read state for {bus_name}: {e}")
|
||||||
self._props_cache.pop(bus_name, None)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _track_from_metadata(self, metadata: dict[str, object]) -> Optional[TrackMeta]:
|
def _track_from_metadata(self, metadata: dict[str, object]) -> Optional[TrackMeta]:
|
||||||
@@ -270,9 +241,6 @@ class PlayerMonitor:
|
|||||||
added = sorted(after - before)
|
added = sorted(after - before)
|
||||||
removed = sorted(before - after)
|
removed = sorted(before - after)
|
||||||
|
|
||||||
for bus_name in removed:
|
|
||||||
self._props_cache.pop(bus_name, None)
|
|
||||||
|
|
||||||
self.players = updated
|
self.players = updated
|
||||||
|
|
||||||
if added or removed:
|
if added or removed:
|
||||||
@@ -371,19 +339,15 @@ class PlayerMonitor:
|
|||||||
|
|
||||||
async def get_position_ms(self, bus_name: str) -> Optional[int]:
|
async def get_position_ms(self, bus_name: str) -> Optional[int]:
|
||||||
"""Read player-reported position in milliseconds."""
|
"""Read player-reported position in milliseconds."""
|
||||||
props = await self._get_player_props(bus_name)
|
if not self._bus:
|
||||||
if props is None:
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
position_var = await getattr(props, "call_get")(
|
position_var = await get_player_property(self._bus, bus_name, "Position")
|
||||||
"org.mpris.MediaPlayer2.Player", "Position"
|
|
||||||
)
|
|
||||||
if position_var is None:
|
if position_var is None:
|
||||||
return None
|
return None
|
||||||
return max(0, int(position_var.value) // 1000)
|
return max(0, int(position_var.value) // 1000)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to read position from {bus_name}: {e}")
|
logger.debug(f"Failed to read position from {bus_name}: {e}")
|
||||||
self._props_cache.pop(bus_name, None)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Regression tests: MPRIS property reads must be issued as raw
|
||||||
|
org.freedesktop.DBus.Properties.Get messages, independent of introspection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dbus_next.constants import MessageType
|
||||||
|
from dbus_next.message import Message
|
||||||
|
from dbus_next.signature import Variant
|
||||||
|
|
||||||
|
from lrx_cli.models import TrackMeta
|
||||||
|
from lrx_cli.mpris import _fetch_metadata_dbus, _get_playback_status
|
||||||
|
from lrx_cli.watch.player import PlayerMonitor
|
||||||
|
|
||||||
|
PLAYER = "org.mpris.MediaPlayer2.generic1"
|
||||||
|
|
||||||
|
PLAYER_METADATA = {
|
||||||
|
"mpris:trackid": Variant("o", "/org/example/Tracks/42"),
|
||||||
|
"mpris:length": Variant("x", 239_761_000),
|
||||||
|
"mpris:artUrl": Variant("s", "http://example.com/cover.jpg"),
|
||||||
|
"xesam:album": Variant("s", "album"),
|
||||||
|
"xesam:artist": Variant("as", ["artist"]),
|
||||||
|
"xesam:title": Variant("s", "title"),
|
||||||
|
"xesam:url": Variant("s", "http://example.com/stream"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBus:
|
||||||
|
"""Test double exposing only the low-level call/disconnect surface."""
|
||||||
|
|
||||||
|
def __init__(self, reply: Callable[[Message], Optional[Message]]) -> None:
|
||||||
|
self._reply = reply
|
||||||
|
self.calls: list[Message] = []
|
||||||
|
self.disconnected = False
|
||||||
|
|
||||||
|
async def call(self, message: Message) -> Optional[Message]:
|
||||||
|
self.calls.append(message)
|
||||||
|
return self._reply(message)
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
self.disconnected = True
|
||||||
|
|
||||||
|
|
||||||
|
def _return(signature: str, body: list) -> Message:
|
||||||
|
return Message(
|
||||||
|
message_type=MessageType.METHOD_RETURN,
|
||||||
|
reply_serial=1,
|
||||||
|
signature=signature,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _names_reply(names: list[str]) -> Message:
|
||||||
|
return _return("as", [names])
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_reply() -> Message:
|
||||||
|
return _return("v", [Variant("a{sv}", PLAYER_METADATA)])
|
||||||
|
|
||||||
|
|
||||||
|
def _state_reply(msg: Message) -> Optional[Message]:
|
||||||
|
"""Reply to ListNames and the PlaybackStatus/Metadata property reads."""
|
||||||
|
if msg.member == "ListNames":
|
||||||
|
return _names_reply([PLAYER])
|
||||||
|
if msg.member == "Get":
|
||||||
|
if msg.body[1] == "PlaybackStatus":
|
||||||
|
return _return("v", [Variant("s", "Playing")])
|
||||||
|
if msg.body[1] == "Metadata":
|
||||||
|
return _metadata_reply()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor(bus: FakeBus) -> PlayerMonitor:
|
||||||
|
monitor = PlayerMonitor(
|
||||||
|
on_players_changed=lambda: None,
|
||||||
|
on_seeked=lambda bus_name, position_ms: None,
|
||||||
|
on_playback_status=lambda bus_name, status: None,
|
||||||
|
player_blacklist=(),
|
||||||
|
)
|
||||||
|
monitor._bus = bus # type: ignore[assignment]
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_playback_status_sends_raw_properties_get() -> None:
|
||||||
|
def reply(msg: Message) -> Optional[Message]:
|
||||||
|
if msg.member == "Get":
|
||||||
|
return _return("v", [Variant("s", "Playing")])
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus = FakeBus(reply)
|
||||||
|
status = asyncio.run(_get_playback_status(bus, PLAYER)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert status == "Playing"
|
||||||
|
request = bus.calls[0]
|
||||||
|
assert request.destination == PLAYER
|
||||||
|
assert request.path == "/org/mpris/MediaPlayer2"
|
||||||
|
assert request.interface == "org.freedesktop.DBus.Properties"
|
||||||
|
assert request.member == "Get"
|
||||||
|
assert request.body == ["org.mpris.MediaPlayer2.Player", "PlaybackStatus"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_metadata_dbus_reads_metadata_via_raw_properties_get() -> None:
|
||||||
|
bus = FakeBus(_state_reply)
|
||||||
|
track = asyncio.run(_fetch_metadata_dbus(None, "", (), bus=bus)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# trackid without a Spotify prefix normalizes to None
|
||||||
|
assert track == TrackMeta(
|
||||||
|
trackid=None,
|
||||||
|
length=239_761,
|
||||||
|
album="album",
|
||||||
|
artist="artist",
|
||||||
|
title="title",
|
||||||
|
url="http://example.com/stream",
|
||||||
|
)
|
||||||
|
assert not any(msg.member == "Introspect" for msg in bus.calls)
|
||||||
|
# injected bus stays owned by the caller
|
||||||
|
assert bus.disconnected is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_metadata_dbus_disconnects_self_created_bus(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
bus = FakeBus(_state_reply)
|
||||||
|
|
||||||
|
class FakeMessageBus:
|
||||||
|
@staticmethod
|
||||||
|
async def connect() -> FakeBus:
|
||||||
|
return bus
|
||||||
|
|
||||||
|
monkeypatch.setattr("lrx_cli.mpris.MessageBus", lambda bus_type: FakeMessageBus)
|
||||||
|
track = asyncio.run(_fetch_metadata_dbus(None, "", ()))
|
||||||
|
|
||||||
|
assert track is not None
|
||||||
|
assert track.title == "title"
|
||||||
|
assert bus.disconnected is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_monitor_refresh_reads_state_via_raw_properties_get() -> None:
|
||||||
|
monitor = _monitor(FakeBus(_state_reply))
|
||||||
|
asyncio.run(monitor.refresh())
|
||||||
|
|
||||||
|
state = monitor.players.get(PLAYER)
|
||||||
|
assert state is not None
|
||||||
|
assert state.status == "Playing"
|
||||||
|
assert state.track is not None
|
||||||
|
assert state.track.title == "title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_monitor_get_position_ms_reads_raw_position() -> None:
|
||||||
|
def reply(msg: Message) -> Optional[Message]:
|
||||||
|
if msg.member == "Get":
|
||||||
|
return _return("v", [Variant("x", 231_008_000)])
|
||||||
|
return None
|
||||||
|
|
||||||
|
monitor = _monitor(FakeBus(reply))
|
||||||
|
assert asyncio.run(monitor.get_position_ms(PLAYER)) == 231_008
|
||||||
Reference in New Issue
Block a user