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: Clone this repository:
```bash ```bash
git clone https://github.com/Uyanide/LRX-CLI.git git clone https://github.com/Uyanide/lrx-cli.git
cd LRX-CLI cd lrx-cli
``` ```
Create a virtual environment and install dependencies (for example, using uv): 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 # via
# loguru # loguru
# pytest # pytest
cyclopts==4.10.1 \ cyclopts==4.10.2 \
--hash=sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd \ --hash=sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe \
--hash=sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0 --hash=sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1
# via lrx-cli # via lrx-cli
dbus-next==0.2.3 \ dbus-next==0.2.3 \
--hash=sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b \ --hash=sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b \
@@ -79,27 +79,23 @@ packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via pytest # via pytest
platformdirs==4.9.4 \ platformdirs==4.9.6 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \ --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868 --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via lrx-cli # via lrx-cli
pluggy==1.6.0 \ pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest # via pytest
pygments==2.19.2 \ pygments==2.20.0 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via # via
# pytest # pytest
# rich # rich
pytest==9.0.2 \ pytest==9.0.3 \
--hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
--hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11 --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via lrx-cli
rich==14.3.3 \ rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
@@ -110,25 +106,25 @@ rich-rst==1.3.2 \
--hash=sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4 \ --hash=sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4 \
--hash=sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a --hash=sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a
# via cyclopts # via cyclopts
ruff==0.15.8 \ ruff==0.15.10 \
--hash=sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89 \ --hash=sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f \
--hash=sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1 \ --hash=sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0 \
--hash=sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3 \ --hash=sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151 \
--hash=sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8 \ --hash=sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed \
--hash=sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762 \ --hash=sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609 \
--hash=sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3 \ --hash=sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188 \
--hash=sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49 \ --hash=sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1 \
--hash=sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb \ --hash=sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e \
--hash=sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e \ --hash=sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef \
--hash=sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec \ --hash=sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8 \
--hash=sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34 \ --hash=sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1 \
--hash=sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8 \ --hash=sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158 \
--hash=sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6 \ --hash=sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48 \
--hash=sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7 \ --hash=sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e \
--hash=sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2 \ --hash=sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e \
--hash=sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570 \ --hash=sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5 \
--hash=sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a \ --hash=sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07 \
--hash=sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94 --hash=sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f
win32-setctime==1.2.0 ; sys_platform == 'win32' \ win32-setctime==1.2.0 ; sys_platform == 'win32' \
--hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \ --hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \
--hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0 --hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0
+18 -39
View File
@@ -17,7 +17,7 @@ from ..models import TrackMeta
from .control import ControlServer from .control import ControlServer
from .fetcher import LyricFetcher from .fetcher import LyricFetcher
from ..config import AppConfig from ..config import AppConfig
from .view import BaseOutput, LyricView, WatchState from .view import BaseOutput, LyricView, WatchState, WatchStatus
from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget
from .tracker import PositionTracker from .tracker import PositionTracker
@@ -28,14 +28,14 @@ class WatchModel:
offset_ms: int offset_ms: int
active_player: str | None active_player: str | None
active_track_key: str | None active_track_key: str | None
status: str status: WatchStatus
lyrics: LyricView | None lyrics: LyricView | None
def __init__(self) -> None: def __init__(self) -> None:
self.offset_ms = 0 self.offset_ms = 0
self.active_player: str | None = None self.active_player: str | None = None
self.active_track_key: str | None = None self.active_track_key: str | None = None
self.status: str = "idle" self.status: WatchStatus = WatchStatus.IDLE
self.lyrics: LyricView | None = None self.lyrics: LyricView | None = None
def set_lyrics(self, lyrics: LRCData | None) -> None: def set_lyrics(self, lyrics: LRCData | None) -> None:
@@ -56,7 +56,7 @@ class WatchModel:
else None 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) return ("status", self.status, self.active_player, track_key)
at_ms = position_ms + self.offset_ms at_ms = position_ms + self.offset_ms
cursor = self.lyrics.signature_cursor(at_ms) cursor = self.lyrics.signature_cursor(at_ms)
@@ -82,7 +82,7 @@ class WatchViewModel:
lyrics=self._model.lyrics, lyrics=self._model.lyrics,
position_ms=position_ms, position_ms=position_ms,
offset_ms=self._model.offset_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 return False
if self._model.lyrics is not None: if self._model.lyrics is not None:
return False return False
if self._model.status == "fetching": if self._model.status == WatchStatus.FETCHING:
return False return False
logger.info("fetching lyrics for track ({}): {}", reason, track.display_name()) logger.info("fetching lyrics for track ({}): {}", reason, track.display_name())
self._fetcher.request(track) self._fetcher.request(track)
@@ -246,7 +246,7 @@ class WatchCoordinator:
) )
if selected is None: if selected is None:
self._model.status = "idle" self._model.status = WatchStatus.IDLE
self._model.active_track_key = None self._model.active_track_key = None
self._model.set_lyrics(None) self._model.set_lyrics(None)
self._schedule_emit() self._schedule_emit()
@@ -254,7 +254,7 @@ class WatchCoordinator:
state = self._player_monitor.players.get(selected) state = self._player_monitor.players.get(selected)
if state is None: if state is None:
self._model.status = "idle" self._model.status = WatchStatus.IDLE
self._model.active_track_key = None self._model.active_track_key = None
self._model.set_lyrics(None) self._model.set_lyrics(None)
self._schedule_emit() self._schedule_emit()
@@ -284,27 +284,16 @@ class WatchCoordinator:
) )
) )
if state.status != "Playing":
self._model.status = "paused"
self._schedule_emit()
return
started_fetch = False started_fetch = False
if track is not None and (player_changed or track_changed): if track is not None and (player_changed or track_changed):
started_fetch = self._request_fetch_for_active_track("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: if self._model.lyrics is not None:
self._model.status = "ok" self._model.status = WatchStatus.OK
elif started_fetch: elif started_fetch:
self._model.status = "fetching" self._model.status = WatchStatus.FETCHING
elif self._model.status != "fetching": elif self._model.status != WatchStatus.FETCHING:
self._model.status = "no_lyrics" self._model.status = WatchStatus.NO_LYRICS
self._schedule_emit() self._schedule_emit()
def _on_seeked(self, bus_name: str, position_ms: int) -> None: 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)) asyncio.create_task(self._tracker.on_seeked(bus_name, position_ms))
def _on_playback_status(self, bus_name: str, status: str) -> None: def _on_playback_status(self, bus_name: str, status: str) -> None:
"""React to playback status change and tracker sync.""" """Forward playback status change to position tracker."""
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()
asyncio.create_task(self._tracker.on_playback_status(bus_name, status)) asyncio.create_task(self._tracker.on_playback_status(bus_name, status))
def _on_tracker_tick(self) -> None: def _on_tracker_tick(self) -> None:
"""Emit updates from tracker tick only while lyrics are actively rendering.""" """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() self._schedule_emit()
def _schedule_emit(self) -> None: def _schedule_emit(self) -> None:
@@ -348,13 +325,15 @@ class WatchCoordinator:
async def _on_fetching(self) -> None: async def _on_fetching(self) -> None:
"""Mark model as fetching and emit state.""" """Mark model as fetching and emit state."""
self._model.status = "fetching" self._model.status = WatchStatus.FETCHING
await self._emit_state() await self._emit_state()
async def _on_lyrics_update(self, lyrics: Optional[LRCData]) -> None: async def _on_lyrics_update(self, lyrics: Optional[LRCData]) -> None:
"""Update model with fetched lyrics and emit state.""" """Update model with fetched lyrics and emit state."""
self._model.set_lyrics(lyrics) 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( logger.info(
"lyrics update result: {}", "lyrics update result: {}",
"found" if lyrics is not None else "not found", "found" if lyrics is not None else "not found",
+10 -2
View File
@@ -3,12 +3,20 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from bisect import bisect_right from bisect import bisect_right
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, Optional from enum import Enum
from typing import Optional
from ...lrc import LRCData, LyricLine from ...lrc import LRCData, LyricLine
from ...models import TrackMeta from ...models import TrackMeta
class WatchStatus(str, Enum):
IDLE = "idle"
FETCHING = "fetching"
OK = "ok"
NO_LYRICS = "no_lyrics"
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class LyricView: class LyricView:
"""View-ready immutable lyric data projected from one normalized LRC object.""" """View-ready immutable lyric data projected from one normalized LRC object."""
@@ -70,7 +78,7 @@ class WatchState:
lyrics: Optional[LyricView] lyrics: Optional[LyricView]
position_ms: int position_ms: int
offset_ms: int offset_ms: int
status: Literal["fetching", "ok", "no_lyrics", "paused", "idle"] status: WatchStatus
class BaseOutput(ABC): class BaseOutput(ABC):
+4 -6
View File
@@ -4,7 +4,7 @@ from bisect import bisect_right
from dataclasses import dataclass from dataclasses import dataclass
import sys import sys
from . import BaseOutput, WatchState from . import BaseOutput, WatchState, WatchStatus
@dataclass(slots=True) @dataclass(slots=True)
@@ -70,13 +70,11 @@ class PipeOutput(BaseOutput):
async def on_state(self, state: WatchState) -> None: async def on_state(self, state: WatchState) -> None:
"""Render and flush one frame for the latest watch state.""" """Render and flush one frame for the latest watch state."""
if state.status == "fetching": if state.status == WatchStatus.FETCHING:
lines = self._render_status("[fetching...]") lines = self._render_status("[fetching...]")
elif state.status == "no_lyrics": elif state.status == WatchStatus.NO_LYRICS:
lines = self._render_status("[no lyrics]") lines = self._render_status("[no lyrics]")
elif state.status == "paused": elif state.status == WatchStatus.IDLE:
lines = self._render_status("[paused]")
elif state.status == "idle":
lines = self._render_status("[idle]") lines = self._render_status("[idle]")
else: else:
lines = self._render_lyrics(state) 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.lrc import LRCData
from lrx_cli.models import TrackMeta from lrx_cli.models import TrackMeta
from lrx_cli.watch.control import ControlClient, ControlServer, parse_delta 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.view.pipe import PipeOutput
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
from lrx_cli.watch.fetcher import LyricFetcher 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" assert printed == "B\nX\nC\n"
def test_session_fetches_on_resume_playing_without_lyrics() -> None: # ── WatchCoordinator state machine ───────────────────────────────────────────
async def _run() -> None:
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: class _Manager:
def fetch_for_track(self, *_args, **_kwargs): def fetch_for_track(self, *_a, **_kw):
return None return None
class _Output(BaseOutput): class _Output(BaseOutput):
async def on_state(self, state: WatchState) -> None: async def on_state(self, state: WatchState) -> None:
return None pass
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( session = WatchCoordinator(
_Manager(), # type: ignore _Manager(), # type: ignore
@@ -334,27 +330,117 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
player_hint=None, player_hint=None,
config=TEST_CONFIG, config=TEST_CONFIG,
) )
fake_fetcher = _Fetcher()
session._fetcher = fake_fetcher
session._tracker = PositionTracker( session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0), lambda _bus: asyncio.sleep(0, result=0),
TEST_CONFIG, 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) await asyncio.sleep(0)
assert fake_fetcher.requested == ["Artist - Song"] assert fetcher.requested == ["Artist - Song"]
assert session._model.status == "fetching" 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()) 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) 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.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( await session._tracker.set_active_player(
bus_name, bus_name,
"Playing", "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) 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.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( await session._tracker.set_active_player(
bus_name, bus_name,
"Playing", "Playing",