Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6d9cfaf8be
|
|||
|
66a32c751a
|
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lrx-cli"
|
||||
version = "0.6.5"
|
||||
version = "0.7.0"
|
||||
description = "Fetch line-synced lyrics for your music player."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
+2
-2
@@ -430,7 +430,7 @@ def offset(delta: str) -> None:
|
||||
logger.error(parse_error or "Invalid offset delta")
|
||||
sys.exit(1)
|
||||
|
||||
response = ControlClient(config=_app_config).send(
|
||||
response = ControlClient(_app_config.watch.socket_path).send(
|
||||
{"cmd": "offset", "delta": parsed_delta}
|
||||
)
|
||||
if not response.get("ok"):
|
||||
@@ -442,7 +442,7 @@ def offset(delta: str) -> None:
|
||||
@ctl_app.command
|
||||
def status() -> None:
|
||||
"""Print current watch session status as JSON."""
|
||||
response = ControlClient(config=_app_config).send({"cmd": "status"})
|
||||
response = ControlClient(_app_config.watch.socket_path).send({"cmd": "status"})
|
||||
if not response.get("ok"):
|
||||
logger.error(response.get("error", "Unknown error"))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -8,11 +8,12 @@ import asyncio
|
||||
from dbus_next.aio.message_bus import MessageBus
|
||||
from dbus_next.constants import BusType
|
||||
from dbus_next.message import Message
|
||||
from lrx_cli.config import DEFAULT_PLAYER_BLACKLIST, DEFAULT_PREFERRED_PLAYER
|
||||
from lrx_cli.models import TrackMeta
|
||||
from loguru import logger
|
||||
from typing import Optional, List, Any
|
||||
|
||||
from .config import DEFAULT_PLAYER_BLACKLIST, DEFAULT_PREFERRED_PLAYER
|
||||
from .models import TrackMeta
|
||||
|
||||
|
||||
async def _list_mpris_players(
|
||||
bus: MessageBus,
|
||||
|
||||
@@ -7,8 +7,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ..config import AppConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .session import WatchCoordinator
|
||||
|
||||
@@ -19,13 +17,9 @@ class ControlServer:
|
||||
_socket_path: Path
|
||||
_server: asyncio.AbstractServer | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
socket_path: Path | None = None,
|
||||
) -> None:
|
||||
def __init__(self, socket_path: str) -> None:
|
||||
"""Initialize control server with socket path from config or explicit override."""
|
||||
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
|
||||
self._socket_path = Path(socket_path)
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
|
||||
async def start(self, session: "WatchCoordinator") -> bool:
|
||||
@@ -107,13 +101,9 @@ class ControlClient:
|
||||
|
||||
_socket_path: Path
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
socket_path: Path | None = None,
|
||||
) -> None:
|
||||
def __init__(self, socket_path: str) -> None:
|
||||
"""Initialize control client with socket path from config or explicit override."""
|
||||
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
|
||||
self._socket_path = Path(socket_path)
|
||||
|
||||
async def _send_async(self, cmd: dict[str, object]) -> dict[str, object]:
|
||||
"""Send one JSON command to control server and return JSON response."""
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from ..config import AppConfig
|
||||
from ..lrc import LRCData
|
||||
from ..models import TrackMeta
|
||||
|
||||
@@ -11,7 +10,7 @@ from ..models import TrackMeta
|
||||
class LyricFetcher:
|
||||
"""Debounces track updates and runs at most one lyric fetch task at a time."""
|
||||
|
||||
_config: AppConfig
|
||||
_watch_debounce_ms: int
|
||||
_fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]]
|
||||
_on_fetching: Callable[[], Awaitable[None] | None]
|
||||
_on_result: Callable[[Optional[LRCData]], Awaitable[None] | None]
|
||||
@@ -24,10 +23,10 @@ class LyricFetcher:
|
||||
fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]],
|
||||
on_fetching: Callable[[], Awaitable[None] | None],
|
||||
on_result: Callable[[Optional[LRCData]], Awaitable[None] | None],
|
||||
config: AppConfig,
|
||||
watch_debounce_ms: int,
|
||||
) -> None:
|
||||
"""Initialize fetch callbacks and runtime options."""
|
||||
self._config = config
|
||||
self._watch_debounce_ms = watch_debounce_ms
|
||||
self._fetch_func = fetch_func
|
||||
self._on_fetching = on_fetching
|
||||
self._on_result = on_result
|
||||
@@ -56,7 +55,7 @@ class LyricFetcher:
|
||||
|
||||
async def _debounce_then_fetch(self) -> None:
|
||||
"""Wait debounce window then start a fresh fetch task for latest pending track."""
|
||||
await asyncio.sleep(self._config.watch.debounce_ms / 1000.0)
|
||||
await asyncio.sleep(self._watch_debounce_ms / 1000.0)
|
||||
track = self._pending_track
|
||||
if track is None:
|
||||
return
|
||||
|
||||
@@ -9,7 +9,6 @@ from dbus_next.constants import BusType
|
||||
from dbus_next.message import Message
|
||||
from loguru import logger
|
||||
|
||||
from ..config import AppConfig
|
||||
from ..models import TrackMeta
|
||||
|
||||
|
||||
@@ -75,7 +74,7 @@ def _keyword_match(text: str, keyword: str) -> bool:
|
||||
class PlayerMonitor:
|
||||
"""Tracks MPRIS players and forwards signal-driven state updates to session callbacks."""
|
||||
|
||||
_config: AppConfig
|
||||
_player_blacklist: tuple[str, ...]
|
||||
_on_players_changed: Callable[[], None]
|
||||
_on_seeked: Callable[[str, int], None]
|
||||
_on_playback_status: Callable[[str, str], None]
|
||||
@@ -89,17 +88,15 @@ class PlayerMonitor:
|
||||
on_players_changed: Callable[[], None],
|
||||
on_seeked: Callable[[str, int], None],
|
||||
on_playback_status: Callable[[str, str], None],
|
||||
config: AppConfig,
|
||||
player_blacklist: tuple[str, ...],
|
||||
target: Optional[PlayerTarget] = None,
|
||||
) -> None:
|
||||
"""Initialize monitor callbacks, runtime options, and player target filter."""
|
||||
self._config = config
|
||||
self._player_blacklist = player_blacklist
|
||||
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._config.general.player_blacklist
|
||||
)
|
||||
self._target = target or PlayerTarget(player_blacklist=self._player_blacklist)
|
||||
self.players: dict[str, PlayerState] = {}
|
||||
self._bus: MessageBus | None = None
|
||||
self._props_cache: dict[str, object] = {}
|
||||
@@ -183,10 +180,7 @@ class PlayerMonitor:
|
||||
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._config.general.player_blacklist
|
||||
):
|
||||
if any(x.lower() in name.lower() for x in self._player_blacklist):
|
||||
continue
|
||||
if not self._target.allows(name):
|
||||
continue
|
||||
@@ -389,7 +383,7 @@ class ActivePlayerSelector:
|
||||
def select(
|
||||
players: dict[str, PlayerState],
|
||||
last_active: str | None,
|
||||
config: AppConfig,
|
||||
preferred_player: str,
|
||||
) -> str | None:
|
||||
"""Select active player by playing state, preferred keyword, and continuity."""
|
||||
if not players:
|
||||
@@ -399,7 +393,7 @@ class ActivePlayerSelector:
|
||||
if len(playing) == 1:
|
||||
return playing[0]
|
||||
|
||||
preferred = config.general.preferred_player.lower().strip()
|
||||
preferred = preferred_player.lower().strip()
|
||||
candidates = playing if playing else list(players.keys())
|
||||
if preferred:
|
||||
for name in candidates:
|
||||
|
||||
@@ -126,12 +126,12 @@ class WatchCoordinator:
|
||||
player_blacklist=self._config.general.player_blacklist,
|
||||
)
|
||||
|
||||
self._control = ControlServer(config=self._config)
|
||||
self._control = ControlServer(socket_path=config.watch.socket_path)
|
||||
self._player_monitor = PlayerMonitor(
|
||||
on_players_changed=self._on_player_change,
|
||||
on_seeked=self._on_seeked,
|
||||
on_playback_status=self._on_playback_status,
|
||||
config=self._config,
|
||||
player_blacklist=self._config.general.player_blacklist,
|
||||
target=self._target,
|
||||
)
|
||||
self._tracker = PositionTracker(
|
||||
@@ -143,7 +143,7 @@ class WatchCoordinator:
|
||||
fetch_func=self._fetch_lyrics,
|
||||
on_fetching=self._on_fetching,
|
||||
on_result=self._on_lyrics_update,
|
||||
config=self._config,
|
||||
watch_debounce_ms=self._config.watch.debounce_ms,
|
||||
)
|
||||
|
||||
async def run(self) -> bool:
|
||||
@@ -234,7 +234,7 @@ class WatchCoordinator:
|
||||
selected = ActivePlayerSelector.select(
|
||||
self._player_monitor.players,
|
||||
self._model.active_player,
|
||||
self._config,
|
||||
self._config.general.preferred_player,
|
||||
)
|
||||
self._model.active_player = selected
|
||||
|
||||
|
||||
+7
-5
@@ -57,7 +57,7 @@ def test_active_player_selector_prefers_single_playing() -> None:
|
||||
),
|
||||
}
|
||||
assert (
|
||||
ActivePlayerSelector.select(players, None, TEST_CONFIG)
|
||||
ActivePlayerSelector.select(players, None, TEST_CONFIG.general.preferred_player)
|
||||
== "org.mpris.MediaPlayer2.bar"
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_active_player_selector_uses_last_active_when_no_playing() -> None:
|
||||
ActivePlayerSelector.select(
|
||||
players,
|
||||
"org.mpris.MediaPlayer2.bar",
|
||||
TEST_CONFIG,
|
||||
TEST_CONFIG.general.preferred_player,
|
||||
)
|
||||
== "org.mpris.MediaPlayer2.bar"
|
||||
)
|
||||
@@ -182,11 +182,11 @@ def test_control_server_and_client_roundtrip(tmp_path: Path) -> None:
|
||||
return {"ok": True, "offset_ms": self.offset, "lyrics_status": "idle"}
|
||||
|
||||
socket_path = tmp_path / "watch.sock"
|
||||
server = ControlServer(socket_path=socket_path, config=TEST_CONFIG)
|
||||
server = ControlServer(socket_path=str(socket_path))
|
||||
session = _Session()
|
||||
|
||||
await server.start(session) # type: ignore
|
||||
client = ControlClient(socket_path=socket_path, config=TEST_CONFIG)
|
||||
client = ControlClient(socket_path=str(socket_path))
|
||||
r1 = await client._send_async({"cmd": "offset", "delta": 200})
|
||||
r2 = await client._send_async({"cmd": "status"})
|
||||
await server.stop()
|
||||
@@ -320,7 +320,9 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
|
||||
async def _on_result(_lyrics) -> None:
|
||||
return None
|
||||
|
||||
super().__init__(_fetch, _on_fetching, _on_result, TEST_CONFIG)
|
||||
super().__init__(
|
||||
_fetch, _on_fetching, _on_result, TEST_CONFIG.watch.debounce_ms
|
||||
)
|
||||
self.requested = []
|
||||
|
||||
def request(self, track: TrackMeta) -> None:
|
||||
|
||||
Reference in New Issue
Block a user