feat: add watch print mode

test: refactor test_watch
style: add inline comments for watch
This commit is contained in:
2026-04-10 07:45:02 +02:00
parent 633983ed98
commit 60732f2986
13 changed files with 497 additions and 261 deletions
+32
View File
@@ -29,6 +29,7 @@ from .lrc import get_sidecar_path
from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta
from .watch.view.pipe import PipeOutput
from .watch.view.print import PrintOutput
app = cyclopts.App(
@@ -432,6 +433,37 @@ def pipe(
logger.info("Watch stopped.")
@watch_app.command(name="print")
def watch_print(
plain: Annotated[
bool,
cyclopts.Parameter(
name="--plain",
negative="",
help="Output plain text (strips all tags). Takes priority over --normalize.",
),
] = False,
) -> None:
"""Watch active player and print all lyrics to stdout once per track change."""
logger.info(
"Starting watch print (player filter: {})",
_player or "<none>",
)
output = PrintOutput(plain=plain)
try:
session = WatchCoordinator(
manager,
output,
player_hint=_player,
config=_app_config,
)
success = asyncio.run(session.run())
if not success:
sys.exit(1)
except KeyboardInterrupt:
logger.info("Watch stopped.")
@ctl_app.command
def offset(delta: str) -> None:
"""Adjust watch offset. Examples: +200, -200, 0."""
-2
View File
@@ -1,5 +1,3 @@
"""Watch subsystem public exports."""
from .session import WatchCoordinator
__all__ = ["WatchCoordinator"]
+10 -1
View File
@@ -1,4 +1,8 @@
"""Unix-socket control channel for communicating with a running watch session."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:14:58
Description: Unix-socket control channel for communicating with a running watch session.
"""
import asyncio
import json
@@ -40,14 +44,17 @@ class ControlServer:
return True
try:
# probe the socket to distinguish a live session from a stale socket file
reader, writer = await asyncio.open_unix_connection(str(self._socket_path))
writer.close()
await writer.wait_closed()
# connection succeeded → another watch session is actively listening
logger.error(
"A watch session is already running. Use 'lrx watch ctl status'."
)
return False
except Exception:
# connection refused / file is stale → safe to remove and reuse
try:
self._socket_path.unlink(missing_ok=True)
except Exception:
@@ -136,6 +143,8 @@ def parse_delta(raw: str) -> tuple[bool, int | None, str | None]:
if value.startswith("+"):
return True, int(value[1:]), None
if value.startswith("-"):
# keep the sign by negating; bare int() would accept "-123" too but
# explicit split is clearer about intent and avoids double-negative edge cases
return True, -int(value[1:]), None
return True, int(value), None
except ValueError:
+8 -1
View File
@@ -1,4 +1,8 @@
"""Debounced lyric fetch orchestration for watch session."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:14:41
Description: Debounced lyric fetch orchestration for watch session.
"""
import asyncio
from typing import Awaitable, Callable, Optional
@@ -50,6 +54,7 @@ class LyricFetcher:
"""Request lyrics for track with debounce collapsing."""
self._pending_track = track
if self._debounce_task is not None:
# cancel any pending debounce window — the new request supersedes it
self._debounce_task.cancel()
self._debounce_task = asyncio.create_task(self._debounce_then_fetch())
@@ -61,6 +66,7 @@ class LyricFetcher:
return
if self._fetch_task is not None:
# abort any in-flight fetch for a previous track before starting the new one
self._fetch_task.cancel()
await asyncio.gather(self._fetch_task, return_exceptions=True)
@@ -68,6 +74,7 @@ class LyricFetcher:
async def _do_fetch(self, track: TrackMeta) -> None:
"""Execute fetch lifecycle callbacks and fetch lyrics for a track."""
# callbacks may be plain functions or coroutines — handle both
fetching_callback_result = self._on_fetching()
if asyncio.iscoroutine(fetching_callback_result):
await fetching_callback_result
+21 -2
View File
@@ -1,4 +1,8 @@
"""Player discovery, state monitoring, and active-player selection for watch mode."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:14:27
Description: Player discovery, state monitoring, and active-player selection for watch mode.
"""
from dataclasses import dataclass
from typing import Callable, Optional
@@ -219,6 +223,7 @@ class PlayerMonitor:
trackid = metadata.get("mpris:trackid")
if trackid is not None:
trackid = _variant_value(trackid)
# normalize Spotify track IDs — the raw MPRIS value varies by client version
if isinstance(trackid, str) and trackid.startswith("spotify:track:"):
trackid = trackid.removeprefix("spotify:track:")
elif isinstance(trackid, str) and trackid.startswith("/com/spotify/track/"):
@@ -230,12 +235,14 @@ class PlayerMonitor:
length_ms = None
length_value = _variant_value(length) if length is not None else None
if isinstance(length_value, int):
# MPRIS reports length in microseconds; convert to milliseconds
length_ms = length_value // 1000
artist = metadata.get("xesam:artist")
artist_v = None
artist_value = _variant_value(artist) if artist is not None else None
if isinstance(artist_value, list) and artist_value:
# xesam:artist is a list; take the first entry as primary artist
artist_v = artist_value[0]
title = metadata.get("xesam:title")
@@ -286,10 +293,14 @@ class PlayerMonitor:
async def _resolve_well_known_name(self, unique_sender: str) -> str | None:
"""Map a DBus unique sender (e.g. :1.42) to a tracked MPRIS bus name."""
if unique_sender in self.players:
# sender is already a well-known name we track (unlikely but fast path)
return unique_sender
if not self._bus:
return None
# Seeked signals arrive with the unique connection name (:1.N), not the
# well-known bus name (org.mpris.MediaPlayer2.X). Ask D-Bus which
# well-known name owns that unique name.
for bus_name in self.players:
try:
reply = await self._bus.call(
@@ -325,6 +336,7 @@ class PlayerMonitor:
message.interface == "org.freedesktop.DBus"
and message.member == "NameOwnerChanged"
):
# a player appeared or disappeared — rescan the full player list
if message.body and str(message.body[0]).startswith(
"org.mpris.MediaPlayer2."
):
@@ -335,7 +347,9 @@ class PlayerMonitor:
message.interface == "org.freedesktop.DBus.Properties"
and message.member == "PropertiesChanged"
):
# Message.sender is a DBus unique name, so match by path+iface.
# message.sender is a unique connection name, not the well-known bus
# name, so we can't filter by sender here — match by object path and
# interface instead to scope it to MPRIS Player properties only
path_ok = message.path == "/org/mpris/MediaPlayer2"
iface = message.body[0] if message.body else None
if path_ok and iface == "org.mpris.MediaPlayer2.Player":
@@ -348,6 +362,7 @@ class PlayerMonitor:
):
sender = message.sender or ""
if sender and message.body:
# MPRIS Seeked position is in microseconds; convert to ms
position_us = int(message.body[0])
asyncio.create_task(
self._handle_seeked_signal(
@@ -391,15 +406,19 @@ class ActivePlayerSelector:
playing = [name for name, st in players.items() if st.status == "Playing"]
if len(playing) == 1:
# unambiguous — only one player is currently playing
return playing[0]
preferred = preferred_player.lower().strip()
# when multiple players are playing, narrow candidates to those; otherwise
# fall back to all known players so a paused preferred player still wins
candidates = playing if playing else list(players.keys())
if preferred:
for name in candidates:
if preferred in name.lower():
return name
# preserve the last selection to avoid jitter when nothing else changes
if last_active and last_active in players:
return last_active
+30 -6
View File
@@ -1,8 +1,11 @@
"""Watch orchestration with explicit MVVM role boundaries.
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:10:52
Description: Watch orchestration with explicit MVVM role boundaries.
- Model: WatchModel stores domain state.
- ViewModel: WatchViewModel projects model to output-facing state/signature.
- Coordinator: WatchCoordinator wires services and drives async workflows.
- Model: WatchModel stores domain state.
- ViewModel: WatchViewModel projects model to output-facing state/signature.
- Coordinator: WatchCoordinator wires services and drives async workflows.
"""
import asyncio
@@ -48,6 +51,8 @@ class WatchModel:
def state_signature(self, track: TrackMeta | None, position_ms: int) -> tuple:
"""Build dedupe signature from model state and current lyric cursor."""
# prefer trackid when available; fall back to display name for players
# that don't expose a stable ID (e.g. some MPRIS implementations)
track_key = (
track.trackid
if track and track.trackid
@@ -57,6 +62,7 @@ class WatchModel:
)
if self.status != WatchStatus.OK or self.lyrics is None:
# non-OK states don't have cursor position — discriminate by status alone
return ("status", self.status, self.active_player, track_key)
at_ms = position_ms + self.offset_ms
cursor = self.lyrics.signature_cursor(at_ms)
@@ -164,7 +170,9 @@ class WatchCoordinator:
await self._player_monitor.start()
await self._tracker.start()
self._calibration_task = asyncio.create_task(self._calibration_loop())
# emit once at startup so outputs don't sit blank until the first event
self._schedule_emit()
# block forever; CancelledError from signal handler exits the loop cleanly
await asyncio.Event().wait()
return True
except asyncio.CancelledError:
@@ -206,8 +214,10 @@ class WatchCoordinator:
if track is None:
return False
if self._model.lyrics is not None:
# lyrics already loaded — nothing to fetch
return False
if self._model.status == WatchStatus.FETCHING:
# a fetch is already in flight — don't queue another
return False
logger.info("fetching lyrics for track ({}): {}", reason, track.display_name())
self._fetcher.request(track)
@@ -272,6 +282,7 @@ class WatchCoordinator:
track_changed = track_key != prev_track_key
player_changed = selected != prev_player
if track_changed or player_changed:
# clear stale lyrics immediately so the old track's lines don't flash
self._model.set_lyrics(None)
self._model.active_track_key = track_key
@@ -284,15 +295,19 @@ class WatchCoordinator:
)
)
# only fetch on identity change — calibration ticks must not re-trigger fetches
started_fetch = False
if track is not None and (player_changed or track_changed):
started_fetch = self._request_fetch_for_active_track("track-changed")
# derive status from what actually happened this tick; preserve FETCHING
# if an in-flight request was started before this snapshot arrived
if self._model.lyrics is not None:
self._model.status = WatchStatus.OK
elif started_fetch:
self._model.status = WatchStatus.FETCHING
elif self._model.status != WatchStatus.FETCHING:
# don't overwrite FETCHING with NO_LYRICS while a request is in flight
self._model.status = WatchStatus.NO_LYRICS
self._schedule_emit()
@@ -306,12 +321,13 @@ class WatchCoordinator:
def _on_tracker_tick(self) -> None:
"""Emit updates from tracker tick only while lyrics are actively rendering."""
if self._model.status == WatchStatus.OK:
if self._model.status == WatchStatus.OK and self._output.position_sensitive:
self._schedule_emit()
def _schedule_emit(self) -> None:
"""Coalesce frequent events into at most one in-flight emit task."""
if self._emit_scheduled:
# a task is already queued; it will pick up the latest model state when it runs
return
self._emit_scheduled = True
asyncio.create_task(self._run_scheduled_emit())
@@ -321,6 +337,7 @@ class WatchCoordinator:
try:
await self._emit_state()
finally:
# release the gate even on error so future events can still schedule
self._emit_scheduled = False
async def _on_fetching(self) -> None:
@@ -344,10 +361,17 @@ class WatchCoordinator:
"""Emit output state only when semantic signature changes."""
player = self._player_monitor.players.get(self._model.active_player or "")
track = player.track if player else None
position = await self._tracker.get_position_ms()
# position=0 for non-position-sensitive outputs so the signature is stable
# across ticks and on_state fires at most once per track+status transition
position = (
await self._tracker.get_position_ms()
if self._output.position_sensitive
else 0
)
signature = self._view_model.signature(track, position)
if signature == self._last_emit_signature:
# state hasn't changed semantically — skip redundant render
return
self._last_emit_signature = signature
state = self._view_model.state(track, position)
+12 -1
View File
@@ -1,4 +1,8 @@
"""Playback position tracking utilities for watch mode."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:13:35
Description: Playback position tracking utilities for watch mode.
"""
import asyncio
import time
@@ -69,7 +73,10 @@ class PositionTracker:
self._is_playing = playback_status == "Playing"
status_changed_to_playing = self._is_playing and not was_playing
if player_changed or track_changed:
# reset to 0 so stale position from a previous track doesn't bleed through
self._position_ms = 0
# only poll MPRIS when something changed and the player is actually running;
# avoids an unnecessary D-Bus round-trip on every calibration-loop tick
should_calibrate_now = (
self._is_playing
and bool(self._active_player)
@@ -97,6 +104,7 @@ class PositionTracker:
return
was_playing = self._is_playing
self._is_playing = playback_status == "Playing"
# re-anchor last_tick when resuming so the gap while paused isn't counted
should_calibrate_now = self._is_playing and not was_playing
self._last_tick = time.monotonic()
@@ -112,10 +120,13 @@ class PositionTracker:
async with self._lock:
now = time.monotonic()
if self._is_playing and self._active_player:
# accumulate elapsed wall-clock time as playback position;
# seek events and calibration snapshots correct drift periodically
delta_ms = int((now - self._last_tick) * 1000)
if delta_ms > 0:
self._position_ms += delta_ms
should_notify = True
# always update last_tick so paused time isn't counted on resume
self._last_tick = now
if should_notify and self._on_tick is not None:
+12
View File
@@ -37,13 +37,16 @@ class LyricView:
line_index = 0
for line in normalized.lines:
if not isinstance(line, LyricLine):
# skip metadata/tag lines that carry no renderable text
continue
text = line.text
lines.append(text)
# use first timestamp; clamp to 0 so bisect always works with non-negative ms
timestamp = line.line_times_ms[0] if line.line_times_ms else 0
entries.append((max(0, timestamp), line_index))
line_index += 1
# extract timestamps into a flat tuple so bisect_right can binary-search it
timestamps = tuple(timestamp for timestamp, _ in entries)
return LyricView(
normalized=normalized,
@@ -55,12 +58,16 @@ class LyricView:
def signature_cursor(self, at_ms: int) -> tuple:
"""Build a stable cursor signature for dedupe decisions."""
if not self.timed_line_entries:
# untimed lyrics: signature is the full line set — changes only on track change
return ("plain", self.lines)
first_ts = self.timed_line_entries[0][0]
if at_ms < first_ts:
# playback hasn't reached the first lyric yet; hold until it does
return ("before_first", first_ts)
# bisect_right gives the insertion point after equal timestamps, so -1 gives
# the last line whose timestamp <= at_ms (i.e. the currently active line)
idx = bisect_right(self.timestamps, at_ms) - 1
if idx < 0:
idx = 0
@@ -82,6 +89,11 @@ class WatchState:
class BaseOutput(ABC):
# When False, the coordinator passes position=0 for signature computation and
# skips tracker-tick-driven emits, so on_state fires at most once per
# track+status transition rather than on every lyric cursor advance.
position_sensitive: bool = True
@abstractmethod
async def on_state(self, state: WatchState) -> None:
"""Render or deliver one watch state frame."""
+11 -2
View File
@@ -1,4 +1,8 @@
"""Pipe output implementation for watch mode."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:15:17
Description: Pipe output implementation for watch mode.
"""
from bisect import bisect_right
from dataclasses import dataclass
@@ -38,12 +42,14 @@ class PipeOutput(BaseOutput):
effective_ms = state.position_ms + state.offset_ms
current_line_idx: int | None
if entries and effective_ms < entries[0][0]:
# Before first timestamp, current lyric is empty and after-window shows upcoming lines.
# playback hasn't reached the first lyric yet; treat current slot as empty
# so the after-window can show upcoming lines without a "current" anchor
current_line_idx = None
else:
if not entries:
current_line_idx = 0
else:
# bisect_right - 1 gives the last entry whose timestamp <= effective_ms
current_entry_idx = (
bisect_right(state.lyrics.timestamps, effective_ms) - 1
)
@@ -54,6 +60,8 @@ class PipeOutput(BaseOutput):
out: list[str] = []
for rel in range(-self.before, self.after + 1):
if current_line_idx is None:
# before-first-timestamp: before/current slots are empty; after slots
# show lines starting from index 0 (rel=1 → line 0, rel=2 → line 1, …)
if rel <= 0:
out.append("")
continue
@@ -80,5 +88,6 @@ class PipeOutput(BaseOutput):
lines = self._render_lyrics(state)
for line in lines:
# no_newline mode lets callers use \r to overwrite the previous frame in-place
sys.stdout.write(line + ("\n" if not self.no_newline else ""))
sys.stdout.flush()
+44
View File
@@ -0,0 +1,44 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:15:31
Description: Print output implementation for watch mode — one shot per track.
"""
import sys
from . import BaseOutput, WatchState, WatchStatus
class PrintOutput(BaseOutput):
"""Emit full lyrics to stdout once per track transition, then stay silent.
Deduplication is delegated to the coordinator via position_sensitive=False:
the coordinator uses a fixed position for signatures, so on_state fires at
most once per (status, track_key) transition rather than on every tick.
"""
# fixed position=0 in signatures → coordinator calls on_state only on
# track/status transitions, never on lyric cursor advances
position_sensitive = False
plain: bool
def __init__(self, plain: bool = False) -> None:
self.plain = plain
async def on_state(self, state: WatchState) -> None:
if state.status == WatchStatus.FETCHING or state.status == WatchStatus.IDLE:
return
if state.status == WatchStatus.NO_LYRICS:
# emit a blank line as a machine-readable sentinel for "track changed, no lyrics"
sys.stdout.write("\n")
sys.stdout.flush()
elif state.status == WatchStatus.OK and state.lyrics is not None:
lrc = state.lyrics.normalized
if self.plain:
text = lrc.to_plain()
else:
text = str(lrc)
sys.stdout.write(text + "\n")
sys.stdout.flush()