From c864da8187586dabae630e6e7184a454e9275463 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Thu, 2 Apr 2026 02:57:50 +0200 Subject: [PATCH] refactor: add LRCData class --- lrx_cli/cache.py | 7 +- lrx_cli/cli.py | 10 +- lrx_cli/core.py | 10 +- lrx_cli/fetchers/cache_search.py | 4 +- lrx_cli/fetchers/local.py | 24 ++- lrx_cli/fetchers/lrclib.py | 14 +- lrx_cli/fetchers/lrclib_search.py | 14 +- lrx_cli/fetchers/netease.py | 12 +- lrx_cli/fetchers/qqmusic.py | 12 +- lrx_cli/fetchers/spotify.py | 4 +- lrx_cli/lrc.py | 287 +++++++++++++++++------------- lrx_cli/models.py | 9 +- pyproject.toml | 2 +- tests/test_cache.py | 6 +- tests/test_lrc.py | 64 ++++--- 15 files changed, 259 insertions(+), 220 deletions(-) diff --git a/lrx_cli/cache.py b/lrx_cli/cache.py index 8699298..4471855 100644 --- a/lrx_cli/cache.py +++ b/lrx_cli/cache.py @@ -12,6 +12,7 @@ import unicodedata from typing import Optional from loguru import logger +from .lrc import LRCData from .config import DURATION_TOLERANCE_MS from .models import TrackMeta, LyricResult, CacheStatus @@ -161,7 +162,7 @@ class CacheEngine: ) return LyricResult( status=CacheStatus(status_str), - lyrics=lyrics, + lyrics=LRCData(lyrics) if lyrics else None, source=src, ttl=remaining, ) @@ -212,7 +213,7 @@ class CacheEngine: key, source, result.status.value, - result.lyrics, + str(result.lyrics) if result.lyrics else None, now, expires_at, track.artist, @@ -316,7 +317,7 @@ class CacheEngine: row = dict(rows[0]) return LyricResult( status=CacheStatus(row["status"]), - lyrics=row["lyrics"], + lyrics=LRCData(row["lyrics"]) if row["lyrics"] else None, source="cache-search", ) diff --git a/lrx_cli/cli.py b/lrx_cli/cli.py index b63ef95..3063c07 100644 --- a/lrx_cli/cli.py +++ b/lrx_cli/cli.py @@ -18,7 +18,7 @@ from .models import TrackMeta, CacheStatus from .mpris import get_current_track from .core import LrcManager from .fetchers import FetcherMethodType -from .lrc import get_sidecar_path, print_lyrics, to_plain +from .lrc import get_sidecar_path app = cyclopts.App( @@ -120,7 +120,7 @@ def fetch( logger.error("Only unsynced lyrics available (--only-synced requested).") sys.exit(1) - print_lyrics(result.lyrics, plain=plain) + result.lyrics.print_lyrics(plain=plain) # search @@ -208,7 +208,7 @@ def search( logger.error("Only unsynced lyrics available (--only-synced requested).") sys.exit(1) - print_lyrics(result.lyrics, plain=plain) + result.lyrics.print_lyrics(plain=plain) # export @@ -282,9 +282,9 @@ def export( try: with open(output, "w", encoding="utf-8") as f: if plain: - f.write(to_plain(result.lyrics)) + f.write(result.lyrics.to_plain()) else: - f.write(result.lyrics) + f.write(str(result.lyrics)) logger.info(f"Exported lyrics to {output}") except Exception as e: logger.error(f"Failed to write file: {e}") diff --git a/lrx_cli/core.py b/lrx_cli/core.py index 5134a8d..b0225f2 100644 --- a/lrx_cli/core.py +++ b/lrx_cli/core.py @@ -18,7 +18,7 @@ from loguru import logger from .fetchers import FetcherMethodType, create_fetchers from .fetchers.base import BaseFetcher from .cache import CacheEngine -from .lrc import normalize_tags, normalize_unsynced, detect_sync_status +from .lrc import LRCData from .config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR from .models import TrackMeta, LyricResult, CacheStatus from .enrichers import enrich_track @@ -146,7 +146,7 @@ class LrcManager: ): best_result = LyricResult( status=best_result.status, - lyrics=normalize_unsynced(best_result.lyrics), + lyrics=best_result.lyrics.normalize_unsynced(), source=best_result.source, ttl=best_result.ttl, ) @@ -167,10 +167,10 @@ class LrcManager: """Manually insert lyrics into the cache for a track.""" track = enrich_track(track) logger.info(f"Manually inserting lyrics for: {track.display_name()}") - lyrics = normalize_tags(lyrics) + lrc = LRCData(lyrics) result = LyricResult( - status=detect_sync_status(lyrics), - lyrics=normalize_tags(lyrics), + status=lrc.detect_sync_status(), + lyrics=lrc, source="manual", ttl=None, ) diff --git a/lrx_cli/fetchers/cache_search.py b/lrx_cli/fetchers/cache_search.py index af973c5..303546e 100644 --- a/lrx_cli/fetchers/cache_search.py +++ b/lrx_cli/fetchers/cache_search.py @@ -13,9 +13,11 @@ albums or is played from different players. from typing import Optional from loguru import logger + from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus from ..cache import CacheEngine +from ..lrc import LRCData class CacheSearchFetcher(BaseFetcher): @@ -80,6 +82,6 @@ class CacheSearchFetcher(BaseFetcher): ) return LyricResult( status=status, - lyrics=best["lyrics"], + lyrics=LRCData(best["lyrics"]), source=self.source_name, ) diff --git a/lrx_cli/fetchers/local.py b/lrx_cli/fetchers/local.py index 82e3ecd..ea3e832 100644 --- a/lrx_cli/fetchers/local.py +++ b/lrx_cli/fetchers/local.py @@ -17,7 +17,7 @@ from mutagen.flac import FLAC from .base import BaseFetcher from ..models import TrackMeta, LyricResult -from ..lrc import detect_sync_status, normalize_tags, get_audio_path, get_sidecar_path +from ..lrc import get_audio_path, get_sidecar_path, LRCData class LocalFetcher(BaseFetcher): @@ -48,11 +48,15 @@ class LocalFetcher(BaseFetcher): with open(lrc_path, "r", encoding="utf-8") as f: content = f.read().strip() if content: - content = normalize_tags(content) - status = detect_sync_status(content) - logger.info(f"Local: found .lrc sidecar ({status.value})") + lrc = LRCData(content) + status = lrc.detect_sync_status() + logger.info( + f"Local: found .lrc sidecar ({status.value}) for {audio_path.name}" + ) return LyricResult( - status=status, lyrics=content, source=self.source_name + status=status, + lyrics=lrc, + source=self.source_name, ) except Exception as e: logger.error(f"Local: error reading {lrc_path}: {e}") @@ -81,12 +85,14 @@ class LocalFetcher(BaseFetcher): break if lyrics: - lyrics = normalize_tags(lyrics.strip()) - status = detect_sync_status(lyrics) - logger.info(f"Local: found embedded lyrics ({status.value})") + lrc = LRCData(lyrics) + status = lrc.detect_sync_status() + logger.info( + f"Local: found embedded lyrics ({status.value}) for {audio_path.name}" + ) return LyricResult( status=status, - lyrics=lyrics, + lyrics=lrc, source=f"{self.source_name} (embedded)", ) else: diff --git a/lrx_cli/fetchers/lrclib.py b/lrx_cli/fetchers/lrclib.py index 6c4eea6..c236fe9 100644 --- a/lrx_cli/fetchers/lrclib.py +++ b/lrx_cli/fetchers/lrclib.py @@ -15,7 +15,7 @@ from urllib.parse import urlencode from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus -from ..lrc import normalize_tags +from ..lrc import LRCData from ..config import ( HTTP_TIMEOUT, TTL_UNSYNCED, @@ -79,20 +79,16 @@ class LrclibFetcher(BaseFetcher): unsynced = data.get("plainLyrics") if isinstance(synced, str) and synced.strip(): - lyrics = normalize_tags(synced.strip()) - logger.info( - f"LRCLIB: got synced lyrics ({len(lyrics.splitlines())} lines)" - ) + lyrics = LRCData(synced) + logger.info(f"LRCLIB: got synced lyrics ({len(lyrics)} lines)") return LyricResult( status=CacheStatus.SUCCESS_SYNCED, lyrics=lyrics, source=self.source_name, ) elif isinstance(unsynced, str) and unsynced.strip(): - lyrics = normalize_tags(unsynced.strip()) - logger.info( - f"LRCLIB: got unsynced lyrics ({len(lyrics.splitlines())} lines)" - ) + lyrics = LRCData(unsynced) + logger.info(f"LRCLIB: got unsynced lyrics ({len(lyrics)} lines)") return LyricResult( status=CacheStatus.SUCCESS_UNSYNCED, lyrics=lyrics, diff --git a/lrx_cli/fetchers/lrclib_search.py b/lrx_cli/fetchers/lrclib_search.py index 3cb18a5..c8e1fab 100644 --- a/lrx_cli/fetchers/lrclib_search.py +++ b/lrx_cli/fetchers/lrclib_search.py @@ -16,7 +16,7 @@ from urllib.parse import urlencode from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus -from ..lrc import normalize_tags +from ..lrc import LRCData from ..config import ( HTTP_TIMEOUT, TTL_UNSYNCED, @@ -82,20 +82,16 @@ class LrclibSearchFetcher(BaseFetcher): unsynced = best.get("plainLyrics") if isinstance(synced, str) and synced.strip(): - lyrics = normalize_tags(synced.strip()) - logger.info( - f"LRCLIB-search: got synced lyrics ({len(lyrics.splitlines())} lines)" - ) + lyrics = LRCData(synced) + logger.info(f"LRCLIB-search: got synced lyrics ({len(lyrics)} lines)") return LyricResult( status=CacheStatus.SUCCESS_SYNCED, lyrics=lyrics, source=self.source_name, ) elif isinstance(unsynced, str) and unsynced.strip(): - lyrics = normalize_tags(unsynced.strip()) - logger.info( - f"LRCLIB-search: got unsynced lyrics ({len(lyrics.splitlines())} lines)" - ) + lyrics = LRCData(unsynced) + logger.info(f"LRCLIB-search: got unsynced lyrics ({len(lyrics)} lines)") return LyricResult( status=CacheStatus.SUCCESS_UNSYNCED, lyrics=lyrics, diff --git a/lrx_cli/fetchers/netease.py b/lrx_cli/fetchers/netease.py index eecc8d7..50530ca 100644 --- a/lrx_cli/fetchers/netease.py +++ b/lrx_cli/fetchers/netease.py @@ -18,7 +18,7 @@ from loguru import logger from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus -from ..lrc import detect_sync_status, normalize_tags +from ..lrc import LRCData from ..config import ( HTTP_TIMEOUT, TTL_NOT_FOUND, @@ -181,15 +181,13 @@ class NeteaseFetcher(BaseFetcher): return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) # Determine sync status - lrc = normalize_tags(lrc) - status = detect_sync_status(lrc) + lrcdata = LRCData(lrc) + status = lrcdata.detect_sync_status() logger.info( f"Netease: got {status.value} lyrics for song_id={song_id} " - f"({len(lrc.splitlines())} lines)" - ) - return LyricResult( - status=status, lyrics=lrc.strip(), source=self.source_name + f"({len(lrcdata)} lines)" ) + return LyricResult(status=status, lyrics=lrcdata, source=self.source_name) except Exception as e: logger.error(f"Netease: lyric fetch failed for song_id={song_id}: {e}") diff --git a/lrx_cli/fetchers/qqmusic.py b/lrx_cli/fetchers/qqmusic.py index a5d0b63..e363238 100644 --- a/lrx_cli/fetchers/qqmusic.py +++ b/lrx_cli/fetchers/qqmusic.py @@ -17,7 +17,7 @@ from loguru import logger from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus -from ..lrc import detect_sync_status, normalize_tags +from ..lrc import LRCData from ..config import ( HTTP_TIMEOUT, TTL_NOT_FOUND, @@ -142,15 +142,13 @@ class QQMusicFetcher(BaseFetcher): logger.debug(f"QQMusic: empty lyrics for mid={mid}") return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) - lrc = normalize_tags(lrc) - status = detect_sync_status(lrc) + lrcdata = LRCData(lrc) + status = lrcdata.detect_sync_status() logger.info( f"QQMusic: got {status.value} lyrics for mid={mid} " - f"({len(lrc.splitlines())} lines)" - ) - return LyricResult( - status=status, lyrics=lrc.strip(), source=self.source_name + f"({len(lrcdata)} lines)" ) + return LyricResult(status=status, lyrics=lrcdata, source=self.source_name) except Exception as e: logger.error(f"QQMusic: lyric fetch failed for mid={mid}: {e}") diff --git a/lrx_cli/fetchers/spotify.py b/lrx_cli/fetchers/spotify.py index fcd568c..8175959 100644 --- a/lrx_cli/fetchers/spotify.py +++ b/lrx_cli/fetchers/spotify.py @@ -28,7 +28,7 @@ from loguru import logger from .base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus -from ..lrc import normalize_tags +from ..lrc import LRCData from ..config import ( HTTP_TIMEOUT, SPOTIFY_APP_VERSION, @@ -358,7 +358,7 @@ class SpotifyFetcher(BaseFetcher): # Unsynced: emit with zero timestamps lrc_lines.append(f"[00:00.00]{words}") - content = normalize_tags("\n".join(lrc_lines)) + content = LRCData("\n".join(lrc_lines)) status = ( CacheStatus.SUCCESS_SYNCED if is_synced diff --git a/lrx_cli/lrc.py b/lrx_cli/lrc.py index cbb9f63..b62e34b 100644 --- a/lrx_cli/lrc.py +++ b/lrx_cli/lrc.py @@ -70,7 +70,7 @@ def _sanitize_lyric_text(text: str) -> str: return _remove_pattern(text, _WORD_SYNC_TAG_RE) -def _reformat(text: str) -> str: +def _reformat(text: str) -> list[str]: """Parse each line and reformat to standard [mm:ss.cc]...content form. Handles any mix of time tag formats on input. Lines with no time tags @@ -99,83 +99,179 @@ def _reformat(text: str) -> str: else: out.append(line) # Empty lines with no tags are also preserved - return "\n".join(out) + + # Remove empty lines at the start and end of the whole text, but preserve blank lines in the middle + while out and not out[0].strip(): + out.pop(0) + while out and not out[-1].strip(): + out.pop() + + return out -def _apply_offset(text: str) -> str: - """Parse [offset:±ms] and shift all standard [mm:ss.cc] tags accordingly. +class LRCData: + _lines: list[str] - Per LRC spec, positive offset = lyrics appear sooner (subtract from timestamps). - """ - m = _OFFSET_RE.search(text) - if not m: - return text - offset_ms = int(m.group(1)) - text = _OFFSET_RE.sub("", text).strip("\n") - if offset_ms == 0: - return text + def __init__(self, text: str | None = None) -> None: + if not text: + self._lines = [] + return + self._lines = _reformat(text) + self._apply_offset() - def _shift(match: re.Match) -> str: - total_ms = max( - 0, - (int(match.group(1)) * 60 + int(match.group(2))) * 1000 - + int(match.group(3)) * 10 - - offset_ms, + def __str__(self) -> str: + return "\n".join(self._lines) + + def __repr__(self) -> str: + return f"LRCData(lines={self._lines!r})" + + def __bool__(self) -> bool: + return len(self._lines) > 0 + + def __len__(self) -> int: + return len(self._lines) + + def _apply_offset(self): + """Parse [offset:±ms] and shift all standard [mm:ss.cc] tags accordingly. + + Per LRC spec, positive offset = lyrics appear sooner (subtract from timestamps). + """ + m: Optional[re.Match] = None + for i, line in enumerate(self._lines): + m = _OFFSET_RE.search(line) + if m: + self._lines.pop(i) + break + if not m: + return + offset_ms = int(m.group(1)) + if offset_ms == 0: + return + + def _shift(match: re.Match) -> str: + total_ms = max( + 0, + (int(match.group(1)) * 60 + int(match.group(2))) * 1000 + + int(match.group(3)) * 10 + - offset_ms, + ) + new_mm = total_ms // 60000 + new_ss = (total_ms % 60000) // 1000 + new_cs = min(round((total_ms % 1000) / 10), 99) + return f"[{new_mm:02d}:{new_ss:02d}.{new_cs:02d}]" + + self._lines = [_STD_TAG_CAPTURE_RE.sub(_shift, line) for line in self._lines] + + def is_synced(self) -> bool: + """Check whether text contains non-zero LRC time tags. + + Assumes text has been normalized by normalize (standard [mm:ss.cc] format). + """ + for line in self._lines: + for m in _STD_TAG_CAPTURE_RE.finditer(line): + if m.group(1) != "00" or m.group(2) != "00" or m.group(3) != "00": + return True + return False + + def detect_sync_status(self) -> CacheStatus: + """Determine whether lyrics contain meaningful LRC time tags. + + Assumes text has been normalized by normalize. + """ + return ( + CacheStatus.SUCCESS_SYNCED + if self.is_synced() + else CacheStatus.SUCCESS_UNSYNCED ) - new_mm = total_ms // 60000 - new_ss = (total_ms % 60000) // 1000 - new_cs = min(round((total_ms % 1000) / 10), 99) - return f"[{new_mm:02d}:{new_ss:02d}.{new_cs:02d}]" - return _STD_TAG_CAPTURE_RE.sub(_shift, text) + def normalize_unsynced(self): + """Normalize unsynced lyrics so every line has a [00:00.00] tag. + Assumes lyrics have been normalized by normalize. + - Lines that already have time tags: replace with [00:00.00] + - Lines without leading tags: prepend [00:00.00] + - Blank lines in middle are converted to [00:00.00] + """ + out: list[str] = [] + first = True + for i, line in enumerate(self._lines): + stripped = line.strip() + if not stripped and not first: + out.append("[00:00.00]") + continue + elif not stripped: + # Skip leading blank lines + continue + first = False + cleaned = _remove_pattern(line, _LINE_START_STD_TAGS_RE) + out.append(f"[00:00.00]{cleaned}") + ret = LRCData() + ret._lines = out + return ret -def normalize_tags(text: str) -> str: - """Normalize LRC to standard form: reformat all tags to [mm:ss.cc], then apply offset.""" - return _apply_offset(_reformat(text)) + def to_plain( + self, + deduplicate: bool = False, + ) -> str: + """Convert lyrics to plain text with all tags stripped. + If deduplicate is True, only keep the first line of consecutive lines with the same lyric text (after stripping tags). + Otherwise, lines with multiple time tags will be duplicated as many times as the number of tags. + Assumes text has been normalized by normalize. + """ -def is_synced(text: str) -> bool: - """Check whether text contains non-zero LRC time tags. + if not self.is_synced(): + return "\n".join( + _remove_pattern(line, _LINE_START_TAGS_RE) for line in self._lines + ).strip("\n") - Assumes text has been normalized by normalize (standard [mm:ss.cc] format). - """ - tags = _STD_TAG_RE.findall(text) - return bool(tags) and any(tag != "[00:00.00]" for tag in tags) + lines = [] + for line in self._lines: + pos = 0 + cnt = 0 + plain_line = "" + while True: + # Only match strictly repeated standard time tags at the start of the line + # Lines without any time tags are ignored. + # Lyric lines are considered already stripped of whitespaces, so no strips here. + m = _STD_TAG_RE.match(line, pos) + if not m: + plain_line += line[pos:] + break + pos = m.end() + cnt += 1 + # Also avoid dulplicating blank lines + if deduplicate or not plain_line: + if cnt > 0: + lines.append(plain_line) + else: + for _ in range(cnt): + lines.append(plain_line) + if deduplicate: + # Remove consecutive duplicates + deduped_lines = [] + prev_line = None + for line in lines: + if line != prev_line: + deduped_lines.append(line) + prev_line = line + lines = deduped_lines -def detect_sync_status(text: str) -> CacheStatus: - """Determine whether lyrics contain meaningful LRC time tags. + return "\n".join(lines).strip() - Assumes text has been normalized by normalize. - """ - return ( - CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED - ) + def print_lyrics( + self, + plain: bool = False, + ) -> None: + """Print lyrics, optionally stripping tags. - -def normalize_unsynced(lyrics: str) -> str: - """Normalize unsynced lyrics so every line has a [00:00.00] tag. - - Assumes lyrics have been normalized by normalize. - - Lines that already have time tags: replace with [00:00.00] - - Lines without leading tags: prepend [00:00.00] - - Blank lines in middle are converted to [00:00.00] - """ - out: list[str] = [] - first = True - for line in lyrics.splitlines(): - stripped = line.strip() - if not stripped and not first: - out.append("[00:00.00]") - continue - elif not stripped: - # Skip leading blank lines - continue - first = False - cleaned = _remove_pattern(line, _LINE_START_STD_TAGS_RE) - out.append(f"[00:00.00]{cleaned}") - return "\n".join(out) + Assumes text has been normalized by normalize. + """ + if plain: + print(self.to_plain()) + else: + print("\n".join(self._lines)) def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]: @@ -204,68 +300,3 @@ def get_sidecar_path( if ensure_exists and not lrc_path.exists(): return None return lrc_path - - -def to_plain( - text: str, - deduplicate: bool = False, -) -> str: - """Convert lyrics to plain text with all tags stripped. - - If deduplicate is True, only keep the first line of consecutive lines with the same lyric text (after stripping tags). - Otherwise, lines with multiple time tags will be duplicated as many times as the number of tags. - Assumes text has been normalized by normalize. - """ - - if not is_synced(text): - # If there are no meaningful time tags, just strip all tags and return - return _remove_pattern(text, _LINE_START_TAGS_RE) - - lines = [] - for line in text.splitlines(): - pos = 0 - cnt = 0 - plain_line = "" - while True: - # Only match strictly repeated standard time tags at the start of the line - # Lines without any time tags are ignored. - # Lyric lines are considered already stripped of whitespaces, so no strips here. - m = _STD_TAG_RE.match(line, pos) - if not m: - plain_line += line[pos:] - break - pos = m.end() - cnt += 1 - # Also avoid dulplicating blank lines - if deduplicate or not plain_line: - if cnt > 0: - lines.append(plain_line) - else: - for _ in range(cnt): - lines.append(plain_line) - - if deduplicate: - # Remove consecutive duplicates - deduped_lines = [] - prev_line = None - for line in lines: - if line != prev_line: - deduped_lines.append(line) - prev_line = line - lines = deduped_lines - - return "\n".join(lines).strip("\n") - - -def print_lyrics( - text: str, - plain: bool = False, -) -> None: - """Print lyrics, optionally stripping tags. - - Assumes text has been normalized by normalize. - """ - if plain: - print(to_plain(text)) - else: - print(text) diff --git a/lrx_cli/models.py b/lrx_cli/models.py index 775922a..5597d0f 100644 --- a/lrx_cli/models.py +++ b/lrx_cli/models.py @@ -4,10 +4,15 @@ Date: 2026-03-25 04:09:36 Description: Data models """ +from __future__ import annotations + from enum import Enum -from typing import Optional +from typing import Optional, TYPE_CHECKING from dataclasses import dataclass +if TYPE_CHECKING: + from .lrc import LRCData + class CacheStatus(str, Enum): """Status of a cached lyric entry.""" @@ -54,6 +59,6 @@ class LyricResult: """Result of a lyric fetch attempt, also used as cache record.""" status: CacheStatus - lyrics: Optional[str] = None + lyrics: Optional[LRCData] = None source: Optional[str] = None # Which fetcher produced this result ttl: Optional[int] = None # Hint for cache TTL (seconds) diff --git a/pyproject.toml b/pyproject.toml index 9df3150..12d8d0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lrx-cli" -version = "0.2.0" +version = "0.2.1" description = "Fetch line-synced lyrics for your music player." readme = "README.md" requires-python = ">=3.13" diff --git a/tests/test_cache.py b/tests/test_cache.py index 7a4047e..de591ad 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -100,7 +100,7 @@ def test_set_and_get_roundtrip_with_ttl( assert cached is not None assert cached.status is CacheStatus.SUCCESS_SYNCED - assert cached.lyrics == "[00:01.00]line" + assert str(cached.lyrics) == "[00:01.00]line" assert cached.source == "lrclib" assert cached.ttl == 120 @@ -181,7 +181,7 @@ def test_get_best_prefers_synced_over_unsynced_and_negative( assert best is not None assert best.status is CacheStatus.SUCCESS_SYNCED - assert best.lyrics == "synced" + assert str(best.lyrics) == "synced" def test_clear_track_and_clear_all_affect_expected_rows(cache_db: CacheEngine) -> None: @@ -239,7 +239,7 @@ def test_find_best_positive_uses_exact_match_and_prefers_synced( assert best is not None assert best.status is CacheStatus.SUCCESS_SYNCED - assert best.lyrics == "s" + assert str(best.lyrics) == "s" # find_best_positive always reports cache-search source assert best.source == "cache-search" diff --git a/tests/test_lrc.py b/tests/test_lrc.py index 15d670c..67c3299 100644 --- a/tests/test_lrc.py +++ b/tests/test_lrc.py @@ -1,15 +1,13 @@ from __future__ import annotations -from lrx_cli.lrc import ( - detect_sync_status, - is_synced, - normalize_tags, - normalize_unsynced, - to_plain, -) +from lrx_cli.lrc import LRCData from lrx_cli.models import CacheStatus +def _normalize(text: str) -> str: + return str(LRCData(text)) + + def test_normalize_tags_supports_all_raw_time_formats() -> None: raw = "\n".join( [ @@ -21,7 +19,7 @@ def test_normalize_tags_supports_all_raw_time_formats() -> None: ] ) - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "\n".join( [ @@ -37,7 +35,7 @@ def test_normalize_tags_supports_all_raw_time_formats() -> None: def test_normalize_tags_keeps_non_timed_lines_trimmed_and_unchanged() -> None: raw = " plain line \n\n [ar:Meta Header] " - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "plain line\n\n[ar:Meta Header]" @@ -51,7 +49,7 @@ def test_normalize_tags_removes_word_sync_patterns() -> None: "[00:05.00]<1,2,3>baz" ) - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "\n".join( [ @@ -67,14 +65,14 @@ def test_normalize_tags_removes_word_sync_patterns() -> None: def test_normalize_tags_keeps_midline_timestamps_as_is() -> None: raw = "[00:01.00]Lyric [00:02.00]line" - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "[00:01.00]Lyric [00:02.00]line" def test_normalize_tags_applies_positive_and_negative_offset_per_spec() -> None: - positive = normalize_tags("[offset:+1000]\n[00:10.00]line") - negative = normalize_tags("[offset:-500]\n[00:10.00]line") + positive = _normalize("[offset:+1000]\n[00:10.00]line") + negative = _normalize("[offset:-500]\n[00:10.00]line") assert positive == "[00:09.00]line" assert negative == "[00:10.50]line" @@ -83,7 +81,7 @@ def test_normalize_tags_applies_positive_and_negative_offset_per_spec() -> None: def test_normalize_tags_accepts_leading_spaces_and_tabs_before_tags() -> None: raw = "\t [00:01.2] hello" - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "[00:01.20]hello" @@ -91,7 +89,7 @@ def test_normalize_tags_accepts_leading_spaces_and_tabs_before_tags() -> None: def test_normalize_tags_handles_consecutive_start_tags_with_spaces_between() -> None: raw = "[00:01] [00:02.3] chorus" - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "[00:01.00][00:02.30]chorus" @@ -99,7 +97,7 @@ def test_normalize_tags_handles_consecutive_start_tags_with_spaces_between() -> def test_normalize_tags_preserves_non_leading_raw_like_tags() -> None: raw = "intro [00:01]line" - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "intro [00:01]line" @@ -107,7 +105,7 @@ def test_normalize_tags_preserves_non_leading_raw_like_tags() -> None: def test_normalize_tags_removes_offset_tag_line_even_without_lyrics() -> None: raw = "[offset:+500]" - normalized = normalize_tags(raw) + normalized = _normalize(raw) assert normalized == "" @@ -117,20 +115,20 @@ def test_is_synced_and_detect_sync_status_follow_non_zero_rule() -> None: unsynced_text = "[00:00.00]a\n[00:00.00]b" synced_text = "[00:00.00]a\n[00:01.00]b" - assert is_synced(plain_text) is False - assert detect_sync_status(plain_text) is CacheStatus.SUCCESS_UNSYNCED + assert LRCData(plain_text).is_synced() is False + assert LRCData(plain_text).detect_sync_status() is CacheStatus.SUCCESS_UNSYNCED - assert is_synced(unsynced_text) is False - assert detect_sync_status(unsynced_text) is CacheStatus.SUCCESS_UNSYNCED + assert LRCData(unsynced_text).is_synced() is False + assert LRCData(unsynced_text).detect_sync_status() is CacheStatus.SUCCESS_UNSYNCED - assert is_synced(synced_text) is True - assert detect_sync_status(synced_text) is CacheStatus.SUCCESS_SYNCED + assert LRCData(synced_text).is_synced() is True + assert LRCData(synced_text).detect_sync_status() is CacheStatus.SUCCESS_SYNCED def test_normalize_unsynced_covers_documented_blank_and_tag_rules() -> None: lyrics = "\n[00:12.34]first\nsecond\n\n[00:00.00]third" - normalized = normalize_unsynced(lyrics) + normalized = str(LRCData(lyrics).normalize_unsynced()) assert normalized == "\n".join( [ @@ -152,7 +150,7 @@ def test_to_plain_duplicates_lines_by_leading_repeated_timestamps() -> None: ] ) - plain = to_plain(text) + plain = LRCData(text).to_plain() # In synced mode, lines with standard tags are kept (including [00:00.00]), # while lines without leading standard tags are ignored. @@ -171,7 +169,7 @@ def test_to_plain_deduplicate_collapses_only_consecutive_equals() -> None: ] ) - plain = to_plain(text, deduplicate=True) + plain = LRCData(text).to_plain(deduplicate=True) assert plain == "\n".join(["hello", "", "world", "hello"]) @@ -179,14 +177,22 @@ def test_to_plain_deduplicate_collapses_only_consecutive_equals() -> None: def test_to_plain_fallback_for_non_synced_text_strips_start_tags() -> None: text = "\n".join(["[ar:Artist]", "[00:00.00]only-zero", "plain line"]) - plain = to_plain(text) + plain = LRCData(text).to_plain() assert plain == "only-zero\nplain line" def test_to_plain_trims_leading_and_trailing_blank_lines() -> None: - text = "\n\n[00:01.00]line1\n\n[00:01.00]\n[00:02.00]line2\nline3\n\n" + text = "\n\n[00:01.00]line1\n\n[00:01.00]\n[00:02.00]line2\nline3\n \n" - plain = to_plain(text) + plain = LRCData(text).to_plain() assert plain == "line1\n\nline2" + + +def test_reformat_pipeline_trims_outer_blanks_and_preserves_inner_blanks() -> None: + text = "\n\n[00:01]a\n\n[00:02]b\n\n" + + normalized = str(LRCData(text)) + + assert normalized == "[00:01.00]a\n\n[00:02.00]b"