feat: add watch command and pipe view
This commit is contained in:
@@ -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."""
|
||||
...
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user