diff --git a/README.md b/README.md index 45e0fff..5464777 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/lrx_cli/cache.py b/lrx_cli/cache.py index 3fc8b76..659e55b 100644 --- a/lrx_cli/cache.py +++ b/lrx_cli/cache.py @@ -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} " - "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 + 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", + _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() ] diff --git a/lrx_cli/cli.py b/lrx_cli/cli.py index 6955695..4cf80f0 100644 --- a/lrx_cli/cli.py +++ b/lrx_cli/cli.py @@ -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 diff --git a/lrx_cli/fetchers/cache_search.py b/lrx_cli/fetchers/cache_search.py index c29d525..b66f7a7 100644 --- a/lrx_cli/fetchers/cache_search.py +++ b/lrx_cli/fetchers/cache_search.py @@ -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, ) diff --git a/lrx_cli/lrc.py b/lrx_cli/lrc.py index ffc64be..f7aa99e 100644 --- a/lrx_cli/lrc.py +++ b/lrx_cli/lrc.py @@ -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]: diff --git a/pyproject.toml b/pyproject.toml index ce61f01..1201313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..599a5a0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +markers = network: marks tests that require real network access to external APIs diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00ea56d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4f0aabb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +from lrx_cli.config import enable_debug + +enable_debug() diff --git a/tests/test_cache.py b/tests/test_cache.py index 91f4f4a..4210f1f 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -289,7 +289,6 @@ def test_search_by_meta_fuzzy_rules_and_duration_sorting(cache_db: CacheEngine) ) rows = cache_db.search_by_meta( - artist="B ; A", title=" hello world ", length=200000, ) diff --git a/tests/test_fetchers.py b/tests/test_fetchers.py new file mode 100644 index 0000000..1a010f4 --- /dev/null +++ b/tests/test_fetchers.py @@ -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) diff --git a/uv.lock b/uv.lock index 17090e9..726bf79 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "lrx-cli" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "cyclopts" },