test: add basic tests for cache-search & network fetchers

test: enable debug
test: add pytest.ini to mark tests that require network
fix: cache-search should not aquire full match of artists due to different translations
fix: replace dynamic f-string SQL in track WHERE clauses with parameterized nullable conditions
refactor: lrc.py should never directly call print, return a str instead
chore: add requirements.txt (via 'uv export')
chore: update README.md with dev instructions
This commit is contained in:
2026-04-05 09:42:42 +02:00
parent 84a3e1076e
commit 0d56cde927
12 changed files with 360 additions and 70 deletions
+40
View File
@@ -95,6 +95,46 @@ Shell completion (zsh/fish/bash):
lrx --install-completion
```
## Development
Clone this repository:
```bash
git clone https://github.com/Uyanide/LRX-CLI.git
cd LRX-CLI
```
Create a virtual environment and install dependencies (for example, using uv):
```bash
uv venv .venv
uv sync
```
Run tests without network calls
```bash
uv run pytest -m "not network"
```
or full tests:
```bash
uv run pytest
```
Run the CLI:
```bash
uv run lrx --help
```
Install to user-level (optional):
```bash
uv tool install .
```
## Credits
- [lrclib.net](https://lrclib.net)
+50 -53
View File
@@ -13,7 +13,6 @@ from loguru import logger
from .lrc import LRCData
from .normalize import normalize_for_match as _normalize_for_match
from .normalize import normalize_artist as _normalize_artist
from .config import (
DURATION_TOLERANCE_MS,
LEGACY_CONFIDENCE_SYNCED,
@@ -22,6 +21,26 @@ from .config import (
from .models import TrackMeta, LyricResult, CacheStatus
# Fixed WHERE clause for exact track matching. Column names are hardcoded
# literals; only the *values* come from user-supplied params — no injection risk.
_TRACK_WHERE = (
"(? IS NULL OR artist = ?) AND "
"(? IS NULL OR title = ?) AND "
"(? IS NULL OR album = ?)"
)
def _track_where_params(track: TrackMeta) -> list:
return [
track.artist,
track.artist,
track.title,
track.title,
track.album,
track.album,
]
def _generate_key(track: TrackMeta, source: str) -> str:
"""Generate a unique cache key from track metadata and source.
@@ -235,13 +254,14 @@ class CacheEngine:
def clear_track(self, track: TrackMeta) -> None:
"""Remove all cached entries (every source) for a single track."""
conditions, params = self._track_where(track)
if not conditions:
if not self._track_has_meta(track):
logger.info(f"No cache entries found for {track.display_name()}.")
return
where = " AND ".join(conditions)
with sqlite3.connect(self.db_path) as conn:
cur = conn.execute(f"DELETE FROM cache WHERE {where}", params)
cur = conn.execute(
f"DELETE FROM cache WHERE {_TRACK_WHERE}",
_track_where_params(track),
)
conn.commit()
if cur.rowcount:
logger.info(
@@ -263,20 +283,8 @@ class CacheEngine:
return count
@staticmethod
def _track_where(track: TrackMeta) -> tuple[list[str], list[str]]:
"""Build WHERE conditions to match a track across all sources."""
conditions: list[str] = []
params: list[str] = []
if track.artist:
conditions.append("artist = ?")
params.append(track.artist)
if track.title:
conditions.append("title = ?")
params.append(track.title)
if track.album:
conditions.append("album = ?")
params.append(track.album)
return conditions, params
def _track_has_meta(track: TrackMeta) -> bool:
return bool(track.artist or track.title or track.album)
# Exact cross-source search
@@ -286,30 +294,27 @@ class CacheEngine:
Uses exact metadata match (artist + title + album) across all sources.
Returns the highest-confidence entry, or None.
"""
conditions, params = self._track_where(track)
if not conditions:
if not self._track_has_meta(track):
return None
now = int(time.time())
conditions.append("status IN (?, ?)")
params.extend(
[CacheStatus.SUCCESS_SYNCED.value, CacheStatus.SUCCESS_UNSYNCED.value]
)
conditions.append("(expires_at IS NULL OR expires_at > ?)")
params.append(str(now))
where = " AND ".join(conditions)
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"SELECT status, lyrics, source, confidence FROM cache WHERE {where} "
f"SELECT status, lyrics, source, confidence FROM cache"
f" WHERE {_TRACK_WHERE}"
" AND status IN (?, ?)"
" AND (expires_at IS NULL OR expires_at > ?)"
" ORDER BY COALESCE(confidence,"
" CASE status WHEN ? THEN ? ELSE ? END"
" ) DESC,"
" CASE status WHEN ? THEN 0 ELSE 1 END,"
" created_at DESC LIMIT 1",
params
_track_where_params(track)
+ [
CacheStatus.SUCCESS_SYNCED.value,
CacheStatus.SUCCESS_UNSYNCED.value,
now,
CacheStatus.SUCCESS_SYNCED.value,
LEGACY_CONFIDENCE_SYNCED,
LEGACY_CONFIDENCE_UNSYNCED,
@@ -339,15 +344,18 @@ class CacheEngine:
def search_by_meta(
self,
artist: Optional[str],
title: Optional[str],
length: Optional[int] = None,
) -> list[dict]:
"""Search cache for lyrics matching artist/title with fuzzy normalization.
"""Search cache for lyrics matching title with fuzzy normalization.
Ignores album and source. Only returns positive results (synced/unsynced)
that have not expired. When *length* is provided, filters by duration
tolerance and sorts by closest match.
Artist is intentionally not filtered here — artist names can differ
significantly across languages (e.g. Japanese romanization vs. kanji),
making hard artist filtering unreliable for cross-language queries.
Ignores artist, album and source. Only returns positive results
(synced/unsynced) that have not expired. When *length* is provided,
filters by duration tolerance and sorts by closest match.
"""
if not title:
return []
@@ -367,7 +375,6 @@ class CacheEngine:
).fetchall()
norm_title = _normalize_for_match(title)
norm_artist = _normalize_artist(artist) if artist else None
matches: list[dict] = []
for row in rows:
@@ -376,11 +383,6 @@ class CacheEngine:
row_title = row_dict.get("title") or ""
if _normalize_for_match(row_title) != norm_title:
continue
# Artist must match if provided
if norm_artist:
row_artist = row_dict.get("artist") or ""
if _normalize_artist(row_artist) != norm_artist:
continue
matches.append(row_dict)
# Duration filtering
@@ -419,16 +421,12 @@ class CacheEngine:
Returns the number of rows updated.
"""
conditions, params = self._track_where(track)
if not conditions:
if not self._track_has_meta(track):
return 0
conditions.append("source = ?")
params.append(source)
where = " AND ".join(conditions)
with sqlite3.connect(self.db_path) as conn:
cur = conn.execute(
f"UPDATE cache SET confidence = ? WHERE {where}",
[confidence] + params,
f"UPDATE cache SET confidence = ? WHERE {_TRACK_WHERE} AND source = ?",
[confidence] + _track_where_params(track) + [source],
)
conn.commit()
return cur.rowcount
@@ -437,16 +435,15 @@ class CacheEngine:
def query_track(self, track: TrackMeta) -> list[dict]:
"""Return all cached rows for a given track (across all sources)."""
conditions, params = self._track_where(track)
if not conditions:
if not self._track_has_meta(track):
return []
where = " AND ".join(conditions)
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
return [
dict(r)
for r in conn.execute(
f"SELECT * FROM cache WHERE {where}", params
f"SELECT * FROM cache WHERE {_TRACK_WHERE}",
_track_where_params(track),
).fetchall()
]
+2 -2
View File
@@ -120,7 +120,7 @@ def fetch(
logger.error("Only unsynced lyrics available (--only-synced requested).")
sys.exit(1)
result.lyrics.print_lyrics(plain=plain)
print(result.lyrics.to_lrc(plain=plain))
# search
@@ -208,7 +208,7 @@ def search(
logger.error("Only unsynced lyrics available (--only-synced requested).")
sys.exit(1)
result.lyrics.print_lyrics(plain=plain)
print(result.lyrics.to_lrc(plain=plain))
# export
-1
View File
@@ -55,7 +55,6 @@ class CacheSearchFetcher(BaseFetcher):
# Slow path: fuzzy cross-album search
matches = self._cache.search_by_meta(
artist=track.artist,
title=track.title,
length=track.length,
)
+5 -6
View File
@@ -271,18 +271,17 @@ class LRCData:
return "\n".join(sorted_lines).strip()
def print_lyrics(
def to_lrc(
self,
plain: bool = False,
) -> None:
"""Print lyrics, optionally stripping tags.
) -> str:
"""Return lyrics, optionally stripping tags.
Assumes text has been normalized by normalize.
"""
if plain:
print(self.to_plain())
else:
print("\n".join(self._lines))
return self.to_plain()
return "\n".join(self._lines)
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lrx-cli"
version = "0.5.1"
version = "0.5.2"
description = "Fetch line-synced lyrics for your music player."
readme = "README.md"
requires-python = ">=3.13"
+2
View File
@@ -0,0 +1,2 @@
[pytest]
markers = network: marks tests that require real network access to external APIs
+135
View File
@@ -0,0 +1,135 @@
# This file was autogenerated by uv via the following command:
# uv export
-e .
anyio==4.13.0 \
--hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \
--hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc
# via httpx
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
# via cyclopts
certifi==2026.2.25 \
--hash=sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa \
--hash=sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7
# via
# httpcore
# httpx
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# loguru
# pytest
cyclopts==4.10.1 \
--hash=sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd \
--hash=sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0
# via lrx-cli
dbus-next==0.2.3 \
--hash=sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b \
--hash=sha256:f4eae26909332ada528c0a3549dda8d4f088f9b365153952a408e28023a626a5
# via lrx-cli
docstring-parser==0.17.0 \
--hash=sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912 \
--hash=sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708
# via cyclopts
docutils==0.22.4 \
--hash=sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968 \
--hash=sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de
# via rich-rst
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via httpcore
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via lrx-cli
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via
# anyio
# httpx
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
loguru==0.7.3 \
--hash=sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6 \
--hash=sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c
# via lrx-cli
markdown-it-py==4.0.0 \
--hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \
--hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
mutagen==1.47.0 \
--hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \
--hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
# via lrx-cli
packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via pytest
platformdirs==4.9.4 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
# via lrx-cli
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
# via
# pytest
# rich
pytest==9.0.2 \
--hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \
--hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via lrx-cli
rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
# via
# cyclopts
# rich-rst
rich-rst==1.3.2 \
--hash=sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4 \
--hash=sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a
# via cyclopts
ruff==0.15.8 \
--hash=sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89 \
--hash=sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1 \
--hash=sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3 \
--hash=sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8 \
--hash=sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762 \
--hash=sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3 \
--hash=sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49 \
--hash=sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb \
--hash=sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e \
--hash=sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec \
--hash=sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34 \
--hash=sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8 \
--hash=sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6 \
--hash=sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7 \
--hash=sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2 \
--hash=sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570 \
--hash=sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a \
--hash=sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94
win32-setctime==1.2.0 ; sys_platform == 'win32' \
--hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \
--hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0
# via loguru
+3
View File
@@ -0,0 +1,3 @@
from lrx_cli.config import enable_debug
enable_debug()
-1
View File
@@ -289,7 +289,6 @@ def test_search_by_meta_fuzzy_rules_and_duration_sorting(cache_db: CacheEngine)
)
rows = cache_db.search_by_meta(
artist=" ; A",
title=" hello world ",
length=200000,
)
+116
View File
@@ -0,0 +1,116 @@
from pathlib import Path
import pytest
from dataclasses import replace
from lrx_cli.fetchers import FetcherMethodType
from lrx_cli.models import TrackMeta
from lrx_cli.core import LrcManager
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
title="One Last Kiss",
artist="Hikaru Utada",
album="One Last Kiss",
length=252026,
trackid="5RhWszHMSKzb7KiXk4Ae0M",
url="https://open.spotify.com/track/5RhWszHMSKzb7KiXk4Ae0M",
)
SAMPLE_SPOTIFY_TRACK_ALBUM_MODIFIED = replace(SAMPLE_SPOTIFY_TRACK, album="BADモード")
SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED = replace(
SAMPLE_SPOTIFY_TRACK, artist="宇多田ヒカル"
)
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
SAMPLE_SPOTIFY_TRACK, artist="宇多田ヒカル", album="BADモード"
)
@pytest.fixture
def lrc_manager(tmp_path: Path) -> LrcManager:
return LrcManager(str(tmp_path / "cache.db"))
def _fetch_and_assert(
lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool = False
) -> None:
result = lrc_manager.fetch_for_track(SAMPLE_SPOTIFY_TRACK, force_method=method)
if expect_fail:
assert result is None
else:
assert result is not None
assert result.lyrics is not None
def test_cache_search_fetcher_without_cache(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "cache-search", expect_fail=True)
@pytest.mark.parametrize(
"query_track",
[
pytest.param(SAMPLE_SPOTIFY_TRACK, id="exact_match"),
pytest.param(SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED, id="artist_modified"),
pytest.param(SAMPLE_SPOTIFY_TRACK_ALBUM_MODIFIED, id="album_modified"),
pytest.param(
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED, id="album_artist_modified"
),
],
)
def test_cache_search_fetcher_with_fuzzy_metadata(
lrc_manager: LrcManager, query_track: TrackMeta
):
expected_lrc = "[00:00.01]lyrics"
lrc_manager.manual_insert(SAMPLE_SPOTIFY_TRACK, expected_lrc)
result = lrc_manager.fetch_for_track(query_track, force_method="cache-search")
assert result is not None
assert result.lyrics is not None
assert result.lyrics.to_lrc() == expected_lrc
def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
lrc_manager.manual_insert(
SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED, "[00:00.01]artist modified"
)
lrc_manager.manual_insert(
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED, "[00:00.01]artist+album modified"
)
result = lrc_manager.fetch_for_track(
SAMPLE_SPOTIFY_TRACK, force_method="cache-search"
)
assert result is not None
assert result.lyrics is not None
assert result.lyrics.to_lrc() == "[00:00.01]artist modified"
@pytest.mark.network
@pytest.mark.parametrize(
"method, expect_fail",
[
("spotify", False),
("lrclib", False),
("lrclib-search", False),
("musixmatch", False),
("musixmatch-spotify", False),
("netease", False),
("qqmusic", False),
],
)
def test_remote_fetchers(
lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool
):
_fetch_and_assert(lrc_manager, method, expect_fail)
@pytest.mark.parametrize(
"method, expect_fail",
[("local", True)],
)
def test_local_fetcher(
lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool
):
_fetch_and_assert(lrc_manager, method, expect_fail)
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "lrx-cli"
version = "0.5.0"
version = "0.5.1"
source = { editable = "." }
dependencies = [
{ name = "cyclopts" },