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
+141 -55
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,62 +299,148 @@ 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:
# ── 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, *_a, **_kw):
return None
class _Output(BaseOutput):
async def on_state(self, state: WatchState) -> None:
pass
session = WatchCoordinator(
_Manager(), # type: ignore
_Output(),
player_hint=None,
config=TEST_CONFIG,
)
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_CONFIG,
)
return session
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:
class _Manager:
def fetch_for_track(self, *_args, **_kwargs):
return None
session = _make_coordinator()
fetcher = _CaptureFetcher()
session._fetcher = fetcher # type: ignore[assignment]
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())
session = WatchCoordinator(
_Manager(), # type: ignore
_Output(),
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,
)
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")
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",