feat: add watch command and pipe view

This commit is contained in:
2026-04-09 10:15:14 +02:00
parent 03970bf17f
commit e6b8583868
13 changed files with 1990 additions and 15 deletions
+80
View File
@@ -0,0 +1,80 @@
"""Output abstraction types for watch mode rendering."""
from abc import ABC, abstractmethod
from bisect import bisect_right
from dataclasses import dataclass
from typing import Literal, Optional
from ...lrc import LRCData, LyricLine
from ...models import TrackMeta
@dataclass(slots=True, frozen=True)
class LyricView:
"""View-ready immutable lyric data projected from one normalized LRC object."""
normalized: LRCData
lines: tuple[str, ...]
timed_line_entries: tuple[tuple[int, int], ...]
timestamps: tuple[int, ...]
@staticmethod
def from_lrc(lyrics: LRCData) -> "LyricView":
"""Build a view projection once from normalized lyrics."""
normalized = lyrics.normalize()
lines: list[str] = []
entries: list[tuple[int, int]] = []
line_index = 0
for line in normalized.lines:
if not isinstance(line, LyricLine):
continue
text = line.text
lines.append(text)
timestamp = line.line_times_ms[0] if line.line_times_ms else 0
entries.append((max(0, timestamp), line_index))
line_index += 1
timestamps = tuple(timestamp for timestamp, _ in entries)
return LyricView(
normalized=normalized,
lines=tuple(lines),
timed_line_entries=tuple(entries),
timestamps=timestamps,
)
def signature_cursor(self, at_ms: int) -> tuple:
"""Build a stable cursor signature for dedupe decisions."""
if not self.timed_line_entries:
return ("plain", self.lines)
first_ts = self.timed_line_entries[0][0]
if at_ms < first_ts:
return ("before_first", first_ts)
idx = bisect_right(self.timestamps, at_ms) - 1
if idx < 0:
idx = 0
ts, line_idx = self.timed_line_entries[idx]
text = self.lines[line_idx] if line_idx < len(self.lines) else ""
return ("ok", idx, ts, text)
@dataclass(slots=True)
class WatchState:
"""Immutable snapshot payload delivered from session to output implementations."""
track: Optional[TrackMeta]
lyrics: Optional[LyricView]
position_ms: int
offset_ms: int
status: Literal["fetching", "ok", "no_lyrics", "paused", "idle"]
class BaseOutput(ABC):
@abstractmethod
async def on_state(self, state: WatchState) -> None:
"""Render or deliver one watch state frame."""
...
+85
View File
@@ -0,0 +1,85 @@
"""Pipe output implementation for watch mode."""
from bisect import bisect_right
from dataclasses import dataclass
import sys
from . import BaseOutput, WatchState
@dataclass(slots=True)
class PipeOutput(BaseOutput):
"""Render a fixed lyric context window to stdout for streaming/pipe usage."""
before: int = 0
after: int = 0
def _window_size(self) -> int:
"""Return rendered lyric window size."""
return self.before + 1 + self.after
def _render_status(self, message: str) -> list[str]:
"""Render centered status line in fixed-size window."""
lines = [""] * self._window_size()
lines[self.before] = message
return lines
def _render_lyrics(self, state: WatchState) -> list[str]:
"""Render context lines centered on current timed lyric entry."""
if state.lyrics is None:
return self._render_status("[no lyrics]")
all_lines = state.lyrics.lines
if not all_lines:
return self._render_status("[no lyrics]")
entries = state.lyrics.timed_line_entries
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.
current_line_idx = None
else:
if not entries:
current_line_idx = 0
else:
current_entry_idx = (
bisect_right(state.lyrics.timestamps, effective_ms) - 1
)
if current_entry_idx < 0:
current_entry_idx = 0
current_line_idx = entries[current_entry_idx][1]
out: list[str] = []
for rel in range(-self.before, self.after + 1):
if current_line_idx is None:
if rel <= 0:
out.append("")
continue
line_idx = rel - 1
else:
line_idx = current_line_idx + rel
if 0 <= line_idx < len(all_lines):
out.append(all_lines[line_idx])
else:
out.append("")
return out
async def on_state(self, state: WatchState) -> None:
"""Render and flush one frame for the latest watch state."""
if state.status == "fetching":
lines = self._render_status("[fetching...]")
elif state.status == "no_lyrics":
lines = self._render_status("[no lyrics]")
elif state.status == "paused":
lines = self._render_status("[paused]")
elif state.status == "idle":
lines = self._render_status("[idle]")
else:
lines = self._render_lyrics(state)
for line in lines:
print(line)
sys.stdout.flush()