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:
@@ -95,6 +95,46 @@ Shell completion (zsh/fish/bash):
|
|||||||
lrx --install-completion
|
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
|
## Credits
|
||||||
|
|
||||||
- [lrclib.net](https://lrclib.net)
|
- [lrclib.net](https://lrclib.net)
|
||||||
|
|||||||
+50
-53
@@ -13,7 +13,6 @@ from loguru import logger
|
|||||||
|
|
||||||
from .lrc import LRCData
|
from .lrc import LRCData
|
||||||
from .normalize import normalize_for_match as _normalize_for_match
|
from .normalize import normalize_for_match as _normalize_for_match
|
||||||
from .normalize import normalize_artist as _normalize_artist
|
|
||||||
from .config import (
|
from .config import (
|
||||||
DURATION_TOLERANCE_MS,
|
DURATION_TOLERANCE_MS,
|
||||||
LEGACY_CONFIDENCE_SYNCED,
|
LEGACY_CONFIDENCE_SYNCED,
|
||||||
@@ -22,6 +21,26 @@ from .config import (
|
|||||||
from .models import TrackMeta, LyricResult, CacheStatus
|
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:
|
def _generate_key(track: TrackMeta, source: str) -> str:
|
||||||
"""Generate a unique cache key from track metadata and source.
|
"""Generate a unique cache key from track metadata and source.
|
||||||
|
|
||||||
@@ -235,13 +254,14 @@ class CacheEngine:
|
|||||||
|
|
||||||
def clear_track(self, track: TrackMeta) -> None:
|
def clear_track(self, track: TrackMeta) -> None:
|
||||||
"""Remove all cached entries (every source) for a single track."""
|
"""Remove all cached entries (every source) for a single track."""
|
||||||
conditions, params = self._track_where(track)
|
if not self._track_has_meta(track):
|
||||||
if not conditions:
|
|
||||||
logger.info(f"No cache entries found for {track.display_name()}.")
|
logger.info(f"No cache entries found for {track.display_name()}.")
|
||||||
return
|
return
|
||||||
where = " AND ".join(conditions)
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
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()
|
conn.commit()
|
||||||
if cur.rowcount:
|
if cur.rowcount:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -263,20 +283,8 @@ class CacheEngine:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _track_where(track: TrackMeta) -> tuple[list[str], list[str]]:
|
def _track_has_meta(track: TrackMeta) -> bool:
|
||||||
"""Build WHERE conditions to match a track across all sources."""
|
return bool(track.artist or track.title or track.album)
|
||||||
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
|
|
||||||
|
|
||||||
# Exact cross-source search
|
# Exact cross-source search
|
||||||
|
|
||||||
@@ -286,30 +294,27 @@ class CacheEngine:
|
|||||||
Uses exact metadata match (artist + title + album) across all sources.
|
Uses exact metadata match (artist + title + album) across all sources.
|
||||||
Returns the highest-confidence entry, or None.
|
Returns the highest-confidence entry, or None.
|
||||||
"""
|
"""
|
||||||
conditions, params = self._track_where(track)
|
if not self._track_has_meta(track):
|
||||||
if not conditions:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
now = int(time.time())
|
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:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
rows = conn.execute(
|
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,"
|
" ORDER BY COALESCE(confidence,"
|
||||||
" CASE status WHEN ? THEN ? ELSE ? END"
|
" CASE status WHEN ? THEN ? ELSE ? END"
|
||||||
" ) DESC,"
|
" ) DESC,"
|
||||||
" CASE status WHEN ? THEN 0 ELSE 1 END,"
|
" CASE status WHEN ? THEN 0 ELSE 1 END,"
|
||||||
" created_at DESC LIMIT 1",
|
" created_at DESC LIMIT 1",
|
||||||
params
|
_track_where_params(track)
|
||||||
+ [
|
+ [
|
||||||
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED.value,
|
||||||
|
now,
|
||||||
CacheStatus.SUCCESS_SYNCED.value,
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
LEGACY_CONFIDENCE_SYNCED,
|
LEGACY_CONFIDENCE_SYNCED,
|
||||||
LEGACY_CONFIDENCE_UNSYNCED,
|
LEGACY_CONFIDENCE_UNSYNCED,
|
||||||
@@ -339,15 +344,18 @@ class CacheEngine:
|
|||||||
|
|
||||||
def search_by_meta(
|
def search_by_meta(
|
||||||
self,
|
self,
|
||||||
artist: Optional[str],
|
|
||||||
title: Optional[str],
|
title: Optional[str],
|
||||||
length: Optional[int] = None,
|
length: Optional[int] = None,
|
||||||
) -> list[dict]:
|
) -> 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)
|
Artist is intentionally not filtered here — artist names can differ
|
||||||
that have not expired. When *length* is provided, filters by duration
|
significantly across languages (e.g. Japanese romanization vs. kanji),
|
||||||
tolerance and sorts by closest match.
|
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:
|
if not title:
|
||||||
return []
|
return []
|
||||||
@@ -367,7 +375,6 @@ class CacheEngine:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
norm_title = _normalize_for_match(title)
|
norm_title = _normalize_for_match(title)
|
||||||
norm_artist = _normalize_artist(artist) if artist else None
|
|
||||||
|
|
||||||
matches: list[dict] = []
|
matches: list[dict] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -376,11 +383,6 @@ class CacheEngine:
|
|||||||
row_title = row_dict.get("title") or ""
|
row_title = row_dict.get("title") or ""
|
||||||
if _normalize_for_match(row_title) != norm_title:
|
if _normalize_for_match(row_title) != norm_title:
|
||||||
continue
|
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)
|
matches.append(row_dict)
|
||||||
|
|
||||||
# Duration filtering
|
# Duration filtering
|
||||||
@@ -419,16 +421,12 @@ class CacheEngine:
|
|||||||
|
|
||||||
Returns the number of rows updated.
|
Returns the number of rows updated.
|
||||||
"""
|
"""
|
||||||
conditions, params = self._track_where(track)
|
if not self._track_has_meta(track):
|
||||||
if not conditions:
|
|
||||||
return 0
|
return 0
|
||||||
conditions.append("source = ?")
|
|
||||||
params.append(source)
|
|
||||||
where = " AND ".join(conditions)
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
f"UPDATE cache SET confidence = ? WHERE {where}",
|
f"UPDATE cache SET confidence = ? WHERE {_TRACK_WHERE} AND source = ?",
|
||||||
[confidence] + params,
|
[confidence] + _track_where_params(track) + [source],
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
@@ -437,16 +435,15 @@ class CacheEngine:
|
|||||||
|
|
||||||
def query_track(self, track: TrackMeta) -> list[dict]:
|
def query_track(self, track: TrackMeta) -> list[dict]:
|
||||||
"""Return all cached rows for a given track (across all sources)."""
|
"""Return all cached rows for a given track (across all sources)."""
|
||||||
conditions, params = self._track_where(track)
|
if not self._track_has_meta(track):
|
||||||
if not conditions:
|
|
||||||
return []
|
return []
|
||||||
where = " AND ".join(conditions)
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return [
|
return [
|
||||||
dict(r)
|
dict(r)
|
||||||
for r in conn.execute(
|
for r in conn.execute(
|
||||||
f"SELECT * FROM cache WHERE {where}", params
|
f"SELECT * FROM cache WHERE {_TRACK_WHERE}",
|
||||||
|
_track_where_params(track),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -120,7 +120,7 @@ def fetch(
|
|||||||
logger.error("Only unsynced lyrics available (--only-synced requested).")
|
logger.error("Only unsynced lyrics available (--only-synced requested).")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
result.lyrics.print_lyrics(plain=plain)
|
print(result.lyrics.to_lrc(plain=plain))
|
||||||
|
|
||||||
|
|
||||||
# search
|
# search
|
||||||
@@ -208,7 +208,7 @@ def search(
|
|||||||
logger.error("Only unsynced lyrics available (--only-synced requested).")
|
logger.error("Only unsynced lyrics available (--only-synced requested).")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
result.lyrics.print_lyrics(plain=plain)
|
print(result.lyrics.to_lrc(plain=plain))
|
||||||
|
|
||||||
|
|
||||||
# export
|
# export
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ class CacheSearchFetcher(BaseFetcher):
|
|||||||
|
|
||||||
# Slow path: fuzzy cross-album search
|
# Slow path: fuzzy cross-album search
|
||||||
matches = self._cache.search_by_meta(
|
matches = self._cache.search_by_meta(
|
||||||
artist=track.artist,
|
|
||||||
title=track.title,
|
title=track.title,
|
||||||
length=track.length,
|
length=track.length,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-6
@@ -271,18 +271,17 @@ class LRCData:
|
|||||||
|
|
||||||
return "\n".join(sorted_lines).strip()
|
return "\n".join(sorted_lines).strip()
|
||||||
|
|
||||||
def print_lyrics(
|
def to_lrc(
|
||||||
self,
|
self,
|
||||||
plain: bool = False,
|
plain: bool = False,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Print lyrics, optionally stripping tags.
|
"""Return lyrics, optionally stripping tags.
|
||||||
|
|
||||||
Assumes text has been normalized by normalize.
|
Assumes text has been normalized by normalize.
|
||||||
"""
|
"""
|
||||||
if plain:
|
if plain:
|
||||||
print(self.to_plain())
|
return self.to_plain()
|
||||||
else:
|
return "\n".join(self._lines)
|
||||||
print("\n".join(self._lines))
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
|
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lrx-cli"
|
name = "lrx-cli"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
description = "Fetch line-synced lyrics for your music player."
|
description = "Fetch line-synced lyrics for your music player."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
markers = network: marks tests that require real network access to external APIs
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from lrx_cli.config import enable_debug
|
||||||
|
|
||||||
|
enable_debug()
|
||||||
@@ -289,7 +289,6 @@ def test_search_by_meta_fuzzy_rules_and_duration_sorting(cache_db: CacheEngine)
|
|||||||
)
|
)
|
||||||
|
|
||||||
rows = cache_db.search_by_meta(
|
rows = cache_db.search_by_meta(
|
||||||
artist="B ; A",
|
|
||||||
title=" hello world ",
|
title=" hello world ",
|
||||||
length=200000,
|
length=200000,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user