feat: watch mode fetch immediatly on track changes regardless of player status

This commit is contained in:
2026-04-10 07:24:29 +02:00
parent 1c160d5ccb
commit 633983ed98
6 changed files with 206 additions and 139 deletions
+2 -2
View File
@@ -143,8 +143,8 @@ socket_path = "" # Unix socket path; defaults to <cache_dir>/
Clone this repository:
```bash
git clone https://github.com/Uyanide/LRX-CLI.git
cd LRX-CLI
git clone https://github.com/Uyanide/lrx-cli.git
cd lrx-cli
```
Create a virtual environment and install dependencies (for example, using uv):
+31 -35
View File
@@ -21,9 +21,9 @@ colorama==0.4.6 ; sys_platform == 'win32' \
# via
# loguru
# pytest
cyclopts==4.10.1 \
--hash=sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd \
--hash=sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0
cyclopts==4.10.2 \
--hash=sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe \
--hash=sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1
# via lrx-cli
dbus-next==0.2.3 \
--hash=sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b \
@@ -79,27 +79,23 @@ packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via pytest
platformdirs==4.9.4 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via lrx-cli
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# pytest
# rich
pytest==9.0.2 \
--hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \
--hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via lrx-cli
pytest==9.0.3 \
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
@@ -110,25 +106,25 @@ rich-rst==1.3.2 \
--hash=sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4 \
--hash=sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a
# via cyclopts
ruff==0.15.8 \
--hash=sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89 \
--hash=sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1 \
--hash=sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3 \
--hash=sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8 \
--hash=sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762 \
--hash=sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3 \
--hash=sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49 \
--hash=sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb \
--hash=sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e \
--hash=sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec \
--hash=sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34 \
--hash=sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8 \
--hash=sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6 \
--hash=sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7 \
--hash=sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2 \
--hash=sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570 \
--hash=sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a \
--hash=sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94
ruff==0.15.10 \
--hash=sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f \
--hash=sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0 \
--hash=sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151 \
--hash=sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed \
--hash=sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609 \
--hash=sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188 \
--hash=sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1 \
--hash=sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e \
--hash=sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef \
--hash=sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8 \
--hash=sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1 \
--hash=sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158 \
--hash=sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48 \
--hash=sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e \
--hash=sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e \
--hash=sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5 \
--hash=sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07 \
--hash=sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f
win32-setctime==1.2.0 ; sys_platform == 'win32' \
--hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \
--hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0
+18 -39
View File
@@ -17,7 +17,7 @@ from ..models import TrackMeta
from .control import ControlServer
from .fetcher import LyricFetcher
from ..config import AppConfig
from .view import BaseOutput, LyricView, WatchState
from .view import BaseOutput, LyricView, WatchState, WatchStatus
from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget
from .tracker import PositionTracker
@@ -28,14 +28,14 @@ class WatchModel:
offset_ms: int
active_player: str | None
active_track_key: str | None
status: str
status: WatchStatus
lyrics: LyricView | None
def __init__(self) -> None:
self.offset_ms = 0
self.active_player: str | None = None
self.active_track_key: str | None = None
self.status: str = "idle"
self.status: WatchStatus = WatchStatus.IDLE
self.lyrics: LyricView | None = None
def set_lyrics(self, lyrics: LRCData | None) -> None:
@@ -56,7 +56,7 @@ class WatchModel:
else None
)
if self.status != "ok" or self.lyrics is None:
if self.status != WatchStatus.OK or self.lyrics is None:
return ("status", self.status, self.active_player, track_key)
at_ms = position_ms + self.offset_ms
cursor = self.lyrics.signature_cursor(at_ms)
@@ -82,7 +82,7 @@ class WatchViewModel:
lyrics=self._model.lyrics,
position_ms=position_ms,
offset_ms=self._model.offset_ms,
status=self._model.status, # type: ignore[arg-type]
status=self._model.status,
)
@@ -207,7 +207,7 @@ class WatchCoordinator:
return False
if self._model.lyrics is not None:
return False
if self._model.status == "fetching":
if self._model.status == WatchStatus.FETCHING:
return False
logger.info("fetching lyrics for track ({}): {}", reason, track.display_name())
self._fetcher.request(track)
@@ -246,7 +246,7 @@ class WatchCoordinator:
)
if selected is None:
self._model.status = "idle"
self._model.status = WatchStatus.IDLE
self._model.active_track_key = None
self._model.set_lyrics(None)
self._schedule_emit()
@@ -254,7 +254,7 @@ class WatchCoordinator:
state = self._player_monitor.players.get(selected)
if state is None:
self._model.status = "idle"
self._model.status = WatchStatus.IDLE
self._model.active_track_key = None
self._model.set_lyrics(None)
self._schedule_emit()
@@ -284,27 +284,16 @@ class WatchCoordinator:
)
)
if state.status != "Playing":
self._model.status = "paused"
self._schedule_emit()
return
started_fetch = False
if track is not None and (player_changed or track_changed):
started_fetch = self._request_fetch_for_active_track("track-changed")
elif (
track is not None
and self._model.lyrics is None
and self._model.status == "paused"
):
started_fetch = self._request_fetch_for_active_track("resume-playing")
if self._model.lyrics is not None:
self._model.status = "ok"
self._model.status = WatchStatus.OK
elif started_fetch:
self._model.status = "fetching"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
self._model.status = WatchStatus.FETCHING
elif self._model.status != WatchStatus.FETCHING:
self._model.status = WatchStatus.NO_LYRICS
self._schedule_emit()
def _on_seeked(self, bus_name: str, position_ms: int) -> None:
@@ -312,24 +301,12 @@ class WatchCoordinator:
asyncio.create_task(self._tracker.on_seeked(bus_name, position_ms))
def _on_playback_status(self, bus_name: str, status: str) -> None:
"""React to playback status change and tracker sync."""
if bus_name == self._model.active_player:
if status == "Playing":
started_fetch = self._request_fetch_for_active_track("resume-playing")
if self._model.lyrics is not None:
self._model.status = "ok"
elif started_fetch:
self._model.status = "fetching"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
else:
self._model.status = "paused"
self._schedule_emit()
"""Forward playback status change to position tracker."""
asyncio.create_task(self._tracker.on_playback_status(bus_name, status))
def _on_tracker_tick(self) -> None:
"""Emit updates from tracker tick only while lyrics are actively rendering."""
if self._model.status == "ok":
if self._model.status == WatchStatus.OK:
self._schedule_emit()
def _schedule_emit(self) -> None:
@@ -348,13 +325,15 @@ class WatchCoordinator:
async def _on_fetching(self) -> None:
"""Mark model as fetching and emit state."""
self._model.status = "fetching"
self._model.status = WatchStatus.FETCHING
await self._emit_state()
async def _on_lyrics_update(self, lyrics: Optional[LRCData]) -> None:
"""Update model with fetched lyrics and emit state."""
self._model.set_lyrics(lyrics)
self._model.status = "ok" if lyrics is not None else "no_lyrics"
self._model.status = (
WatchStatus.OK if lyrics is not None else WatchStatus.NO_LYRICS
)
logger.info(
"lyrics update result: {}",
"found" if lyrics is not None else "not found",
+10 -2
View File
@@ -3,12 +3,20 @@
from abc import ABC, abstractmethod
from bisect import bisect_right
from dataclasses import dataclass
from typing import Literal, Optional
from enum import Enum
from typing import Optional
from ...lrc import LRCData, LyricLine
from ...models import TrackMeta
class WatchStatus(str, Enum):
IDLE = "idle"
FETCHING = "fetching"
OK = "ok"
NO_LYRICS = "no_lyrics"
@dataclass(slots=True, frozen=True)
class LyricView:
"""View-ready immutable lyric data projected from one normalized LRC object."""
@@ -70,7 +78,7 @@ class WatchState:
lyrics: Optional[LyricView]
position_ms: int
offset_ms: int
status: Literal["fetching", "ok", "no_lyrics", "paused", "idle"]
status: WatchStatus
class BaseOutput(ABC):
+4 -6
View File
@@ -4,7 +4,7 @@ from bisect import bisect_right
from dataclasses import dataclass
import sys
from . import BaseOutput, WatchState
from . import BaseOutput, WatchState, WatchStatus
@dataclass(slots=True)
@@ -70,13 +70,11 @@ class PipeOutput(BaseOutput):
async def on_state(self, state: WatchState) -> None:
"""Render and flush one frame for the latest watch state."""
if state.status == "fetching":
if state.status == WatchStatus.FETCHING:
lines = self._render_status("[fetching...]")
elif state.status == "no_lyrics":
elif state.status == WatchStatus.NO_LYRICS:
lines = self._render_status("[no lyrics]")
elif state.status == "paused":
lines = self._render_status("[paused]")
elif state.status == "idle":
elif state.status == WatchStatus.IDLE:
lines = self._render_status("[idle]")
else:
lines = self._render_lyrics(state)
+125 -39
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from lrx_cli.lrc import LRCData
from lrx_cli.models import TrackMeta
from lrx_cli.watch.control import ControlClient, ControlServer, parse_delta
from lrx_cli.watch.view import BaseOutput, LyricView, WatchState
from lrx_cli.watch.view import BaseOutput, LyricView, WatchState, WatchStatus
from lrx_cli.watch.view.pipe import PipeOutput
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
from lrx_cli.watch.fetcher import LyricFetcher
@@ -299,34 +299,30 @@ def test_pipe_output_repeated_text_uses_correct_timed_occurrence(capsys) -> None
assert printed == "B\nX\nC\n"
def test_session_fetches_on_resume_playing_without_lyrics() -> None:
async def _run() -> None:
# ── WatchCoordinator state machine ───────────────────────────────────────────
class _CaptureFetcher:
"""Records fetch requests without doing real network calls."""
def __init__(self) -> None:
self.requested: list[str] = []
def request(self, track: TrackMeta) -> None:
self.requested.append(track.display_name())
async def stop(self) -> None:
pass
def _make_coordinator() -> WatchCoordinator:
class _Manager:
def fetch_for_track(self, *_args, **_kwargs):
def fetch_for_track(self, *_a, **_kw):
return None
class _Output(BaseOutput):
async def on_state(self, state: WatchState) -> None:
return None
class _Fetcher(LyricFetcher):
def __init__(self):
async def _fetch(_track: TrackMeta):
return None
async def _on_fetching() -> None:
return None
async def _on_result(_lyrics) -> None:
return None
super().__init__(
_fetch, _on_fetching, _on_result, TEST_CONFIG.watch.debounce_ms
)
self.requested = []
def request(self, track: TrackMeta) -> None:
self.requested.append(track.display_name())
pass
session = WatchCoordinator(
_Manager(), # type: ignore
@@ -334,27 +330,117 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
player_hint=None,
config=TEST_CONFIG,
)
fake_fetcher = _Fetcher()
session._fetcher = fake_fetcher
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_CONFIG,
)
return session
bus_name = "org.mpris.MediaPlayer2.spotify"
track = TrackMeta(title="Song", artist="Artist")
session._model.active_player = bus_name
session._player_monitor.players = {
bus_name: PlayerState(bus_name=bus_name, status="Playing", track=track)
}
session._model.set_lyrics(None)
session._model.status = "paused"
session._on_playback_status(bus_name, "Playing")
BUS = "org.mpris.MediaPlayer2.spotify"
def _pstate(status: str = "Playing", title: str = "Song") -> PlayerState:
return PlayerState(
bus_name=BUS,
status=status,
track=TrackMeta(title=title, artist="Artist"),
)
def test_coordinator_fetches_on_initial_player() -> None:
async def _run() -> None:
session = _make_coordinator()
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
session._player_monitor.players = {BUS: _pstate("Playing")}
session._on_player_change()
await asyncio.sleep(0)
assert fake_fetcher.requested == ["Artist - Song"]
assert session._model.status == "fetching"
assert fetcher.requested == ["Artist - Song"]
assert session._model.status == WatchStatus.FETCHING
asyncio.run(_run())
def test_coordinator_fetches_while_paused() -> None:
"""Fetch is triggered immediately even when player is paused."""
async def _run() -> None:
session = _make_coordinator()
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
session._player_monitor.players = {BUS: _pstate("Paused")}
session._on_player_change()
await asyncio.sleep(0)
assert fetcher.requested == ["Artist - Song"]
asyncio.run(_run())
def test_coordinator_fetches_on_track_change() -> None:
async def _run() -> None:
session = _make_coordinator()
session._model.active_player = BUS
session._model.active_track_key = "Artist - Old Song"
session._model.set_lyrics(LRCData("[00:01.00]old"))
session._model.status = WatchStatus.OK
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
session._player_monitor.players = {BUS: _pstate("Playing", title="New Song")}
session._on_player_change()
await asyncio.sleep(0)
assert fetcher.requested == ["Artist - New Song"]
assert session._model.lyrics is None # cleared on track change
asyncio.run(_run())
def test_coordinator_no_refetch_on_calibration_no_lyrics() -> None:
"""Calibration with same player/track and no_lyrics must NOT trigger a second fetch."""
async def _run() -> None:
session = _make_coordinator()
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
session._player_monitor.players = {BUS: _pstate("Playing")}
session._on_player_change() # first call: player appears → fetch
await asyncio.sleep(0)
assert len(fetcher.requested) == 1
session._model.status = WatchStatus.NO_LYRICS # simulate fetch returned nothing
session._on_player_change() # calibration: same player/track
await asyncio.sleep(0)
assert len(fetcher.requested) == 1 # no second fetch
asyncio.run(_run())
def test_coordinator_no_fetch_when_lyrics_present() -> None:
async def _run() -> None:
session = _make_coordinator()
session._model.active_player = BUS
session._model.active_track_key = "Artist - Song"
session._model.set_lyrics(LRCData("[00:01.00]line"))
session._model.status = WatchStatus.OK
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
session._player_monitor.players = {BUS: _pstate("Playing")}
session._on_player_change()
await asyncio.sleep(0)
assert fetcher.requested == []
assert session._model.status == WatchStatus.OK
asyncio.run(_run())
@@ -391,7 +477,7 @@ def test_session_emit_state_only_when_lyric_cursor_changes() -> None:
bus_name: PlayerState(bus_name=bus_name, status="Playing", track=track)
}
session._model.set_lyrics(LRCData("[00:01.00]a\n[00:03.00]b"))
session._model.status = "ok"
session._model.status = WatchStatus.OK
await session._tracker.set_active_player(
bus_name,
"Playing",
@@ -441,7 +527,7 @@ def test_session_emits_when_crossing_first_timestamp() -> None:
bus_name: PlayerState(bus_name=bus_name, status="Playing", track=track)
}
session._model.set_lyrics(LRCData("[00:02.00]a\n[00:03.00]b"))
session._model.status = "ok"
session._model.status = WatchStatus.OK
await session._tracker.set_active_player(
bus_name,
"Playing",