feat: config file

refactor: as the config module changed
test: add test for config
test: add test for local fetcher and local enrichers
test: add test for manual insertion
fix: some random bugs left by the last commit
This commit is contained in:
2026-04-09 15:16:21 +02:00
parent e6b8583868
commit d2a3e64b89
34 changed files with 749 additions and 413 deletions
View File
-10
View File
@@ -1,13 +1,3 @@
import pytest
from lrx_cli.config import enable_debug
enable_debug()
@pytest.fixture
def no_credentials(monkeypatch):
"""Clear all credential env vars so only anonymous fetchers are active."""
monkeypatch.delenv("SPOTIFY_SP_DC", raising=False)
monkeypatch.delenv("QQ_MUSIC_API_URL", raising=False)
monkeypatch.delenv("MUSIXMATCH_USERTOKEN", raising=False)
+10 -8
View File
@@ -1,16 +1,18 @@
import os
import pytest
from lrx_cli.config import load_config
_credentials = load_config().credentials
requires_spotify = pytest.mark.skipif(
not os.environ.get("SPOTIFY_SP_DC"),
reason="requires SPOTIFY_SP_DC",
not _credentials.spotify_sp_dc,
reason="requires credentials.spotify_sp_dc in config.toml",
)
requires_qq_music = pytest.mark.skipif(
not os.environ.get("QQ_MUSIC_API_URL"),
reason="requires QQ_MUSIC_API_URL",
not _credentials.qq_music_api_url,
reason="requires credentials.qq_music_api_url in config.toml",
)
requires_musixmatch_token = pytest.mark.skipif(
not os.environ.get("MUSIXMATCH_USERTOKEN"),
reason="requires MUSIXMATCH_USERTOKEN",
not _credentials.musixmatch_usertoken,
reason="requires credentials.musixmatch_usertoken in config.toml",
)
+59
View File
@@ -0,0 +1,59 @@
import pytest
from lrx_cli.config import AppConfig, CredentialConfig, WatchConfig, load_config
def test_missing_file_returns_defaults(tmp_path):
assert load_config(tmp_path / "nonexistent.toml") == AppConfig()
def test_empty_file_returns_defaults(tmp_path):
p = tmp_path / "config.toml"
p.write_text("")
assert load_config(p) == AppConfig()
def test_partial_section_keeps_other_defaults(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[watch]\ndebounce_ms = 200\n")
cfg = load_config(p)
assert cfg.watch.debounce_ms == 200
assert cfg.watch.calibration_interval_s == WatchConfig().calibration_interval_s
def test_credentials_roundtrip(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(
b"[credentials]\n"
b'spotify_sp_dc = "abc"\n'
b'qq_music_api_url = "http://localhost:3000"\n'
)
assert load_config(p).credentials == CredentialConfig(
spotify_sp_dc="abc", qq_music_api_url="http://localhost:3000"
)
def test_int_coerced_to_float(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[general]\nhttp_timeout = 5\n")
assert load_config(p).general.http_timeout == 5.0
def test_unknown_key_raises(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[general]\ntypo_key = 1\n")
with pytest.raises(ValueError, match="Unknown config keys"):
load_config(p)
def test_wrong_type_raises(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[watch]\ndebounce_ms = true\n")
with pytest.raises(ValueError, match="expected int"):
load_config(p)
def test_app_config_is_frozen():
cfg = AppConfig()
with pytest.raises(Exception):
cfg.general = None # type: ignore[misc]
+23 -15
View File
@@ -1,14 +1,16 @@
from pathlib import Path
import pytest
from dataclasses import replace
from pathlib import Path
import pytest
from lrx_cli.config import AppConfig, load_config
from lrx_cli.core import LrcManager
from lrx_cli.fetchers import FetcherMethodType
from lrx_cli.models import TrackMeta
from lrx_cli.core import LrcManager
from tests.marks import (
requires_spotify,
requires_qq_music,
requires_musixmatch_token,
requires_qq_music,
requires_spotify,
)
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
@@ -33,7 +35,14 @@ SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
@pytest.fixture
def lrc_manager(tmp_path: Path) -> LrcManager:
return LrcManager(str(tmp_path / "cache.db"))
"""LrcManager with empty credentials (no auth required)."""
return LrcManager(str(tmp_path / "cache.db"), AppConfig())
@pytest.fixture
def cred_lrc_manager(tmp_path: Path) -> LrcManager:
"""LrcManager with credentials from config.toml (for CI/network tests)."""
return LrcManager(str(tmp_path / "cache.db"), load_config())
def _fetch_and_assert(
@@ -112,7 +121,6 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
],
)
def test_anonymous_remote_fetchers(
no_credentials,
lrc_manager: LrcManager,
method: FetcherMethodType,
expect_fail: bool,
@@ -122,18 +130,18 @@ def test_anonymous_remote_fetchers(
@pytest.mark.network
@requires_spotify
def test_spotify_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "spotify")
def test_spotify_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "spotify")
@pytest.mark.network
@requires_qq_music
def test_qqmusic_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "qqmusic")
def test_qqmusic_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "qqmusic")
@pytest.mark.network
def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
def test_musixmatch_anonymous_fetcher(lrc_manager: LrcManager):
# These fetchers should be tested in a single test to share the same usertoken
# Otherwise the second may fail due to rate limits
_fetch_and_assert(lrc_manager, "musixmatch", expect_fail=False)
@@ -142,9 +150,9 @@ def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
@pytest.mark.network
@requires_musixmatch_token
def test_musixmatch_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "musixmatch")
_fetch_and_assert(lrc_manager, "musixmatch-spotify")
def test_musixmatch_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "musixmatch")
_fetch_and_assert(cred_lrc_manager, "musixmatch-spotify")
def test_local_fetcher(lrc_manager: LrcManager):
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from lrx_cli.config import AppConfig
from lrx_cli.enrichers.audio_tag import AudioTagEnricher
from lrx_cli.enrichers.file_name import FileNameEnricher
from lrx_cli.models import CacheStatus, TrackMeta
from lrx_cli.fetchers.local import LocalFetcher
_GENERAL = AppConfig().general
def _local_track(path: Path) -> TrackMeta:
return TrackMeta(url=f"file://{path}")
def test_local_fetcher_unavailable_for_non_local_track():
fetcher = LocalFetcher(_GENERAL)
assert not fetcher.is_available(TrackMeta(title="Song", artist="Artist"))
def test_local_fetcher_available_for_local_track(tmp_path):
fetcher = LocalFetcher(_GENERAL)
assert fetcher.is_available(_local_track(tmp_path / "song.flac"))
def test_local_fetcher_returns_empty_for_non_file_url():
fetcher = LocalFetcher(_GENERAL)
track = TrackMeta(url="https://example.com/song.mp3")
result = asyncio.run(fetcher.fetch(track))
assert result.synced is None and result.unsynced is None
def test_local_fetcher_reads_synced_sidecar(tmp_path):
audio = tmp_path / "song.flac"
lrc = audio.with_suffix(".lrc")
lrc.write_text("[00:01.00]Hello\n[00:03.00]World\n")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.synced is not None
assert result.synced.status == CacheStatus.SUCCESS_SYNCED
assert result.synced.source is not None
assert "sidecar" in result.synced.source
def test_local_fetcher_reads_unsynced_sidecar(tmp_path):
audio = tmp_path / "song.flac"
lrc = audio.with_suffix(".lrc")
lrc.write_text("Hello\nWorld\n")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.unsynced is not None
assert result.synced is None
def test_local_fetcher_empty_sidecar_ignored(tmp_path):
audio = tmp_path / "song.flac"
(audio.with_suffix(".lrc")).write_text(" ")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.synced is None and result.unsynced is None
def _enrich(path: str, **existing) -> dict | None:
enricher = FileNameEnricher()
track = TrackMeta(url=f"file://{path}", **existing)
return asyncio.run(enricher.enrich(track))
def test_filename_enricher_artist_title_split(tmp_path):
result = _enrich(str(tmp_path / "Utada Hikaru - First Love.flac"))
assert result == {
"artist": "Utada Hikaru",
"title": "First Love",
"album": tmp_path.name,
}
def test_filename_enricher_track_number_prefix(tmp_path):
# "01. Title" — no " - " separator, regex strips leading "01. "
result = _enrich(str(tmp_path / "01. First Love.flac"))
assert result and result.get("title") == "First Love"
assert "artist" not in result
def test_filename_enricher_title_only(tmp_path):
result = _enrich(str(tmp_path / "First Love.flac"))
assert result and result.get("title") == "First Love"
def test_filename_enricher_does_not_overwrite_existing_fields(tmp_path):
result = _enrich(
str(tmp_path / "Artist - Title.flac"),
artist="Existing Artist",
title="Existing Title",
)
assert result is None or ("artist" not in result and "title" not in result)
def test_filename_enricher_non_local_returns_none():
enricher = FileNameEnricher()
track = TrackMeta(title="Song", artist="Artist")
assert asyncio.run(enricher.enrich(track)) is None
def test_audio_tag_enricher_non_local_returns_none():
enricher = AudioTagEnricher()
track = TrackMeta(title="Song", artist="Artist")
assert asyncio.run(enricher.enrich(track)) is None
def test_audio_tag_enricher_missing_file_returns_none(tmp_path):
enricher = AudioTagEnricher()
track = _local_track(tmp_path / "nonexistent.flac")
assert asyncio.run(enricher.enrich(track)) is None
+50
View File
@@ -277,3 +277,53 @@ def test_unsynced_cache_only_still_fetches_when_unsynced_disallowed(tmp_path):
assert fetcher.called
assert result is not None
assert result.status == CacheStatus.SUCCESS_SYNCED
# manual_insert
def test_manual_insert_synced_stored_with_correct_status(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "[00:01.00]Hello\n[00:03.00]World\n")
rows = manager.cache.query_track(_track())
assert any(r["status"] == CacheStatus.SUCCESS_SYNCED.value for r in rows)
def test_manual_insert_unsynced_stored_with_correct_status(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "Hello\nWorld\n")
rows = manager.cache.query_track(_track())
assert any(r["status"] == CacheStatus.SUCCESS_UNSYNCED.value for r in rows)
def test_manual_insert_source_and_ttl(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "[00:01.00]line\n")
rows = manager.cache.query_track(_track())
assert all(r["source"] == "manual" for r in rows)
assert all(r["expires_at"] is None for r in rows)
def test_manual_insert_overwrites_previous_entry(tmp_path):
manager = make_manager(tmp_path)
track = _track()
manager.manual_insert(track, "[00:01.00]old\n")
manager.manual_insert(track, "[00:01.00]new\n")
best = manager.cache.get_best(track, ["manual"])
assert best is not None
assert str(best.lyrics) == "[00:01.00]new"
def test_manual_insert_is_returned_by_fetch(tmp_path):
manager = make_manager(tmp_path)
track = _track()
manager.manual_insert(track, "[00:01.00]cached\n")
result = manager.fetch_for_track(track)
assert result is not None
assert result.lyrics is not None
assert str(result.lyrics) == "[00:01.00]cached"
+21 -28
View File
@@ -10,19 +10,12 @@ from lrx_cli.watch.view import BaseOutput, LyricView, WatchState
from lrx_cli.watch.view.pipe import PipeOutput
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
from lrx_cli.watch.fetcher import LyricFetcher
from lrx_cli.watch.options import WatchOptions
from lrx_cli.config import AppConfig
from lrx_cli.watch.tracker import PositionTracker
from lrx_cli.watch.session import WatchCoordinator
TEST_WATCH_OPTIONS = WatchOptions(
preferred_player="spotify",
player_blacklist=(),
debounce_ms=400,
position_tick_ms=50,
calibration_interval_s=3.0,
socket_path=Path("/tmp/lrx-watch-test.sock"),
)
TEST_CONFIG = AppConfig()
def test_parse_delta_supports_plus_minus_and_reset() -> None:
@@ -64,7 +57,7 @@ def test_active_player_selector_prefers_single_playing() -> None:
),
}
assert (
ActivePlayerSelector.select(players, None, TEST_WATCH_OPTIONS)
ActivePlayerSelector.select(players, None, TEST_CONFIG)
== "org.mpris.MediaPlayer2.bar"
)
@@ -87,7 +80,7 @@ def test_active_player_selector_uses_last_active_when_no_playing() -> None:
ActivePlayerSelector.select(
players,
"org.mpris.MediaPlayer2.bar",
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
== "org.mpris.MediaPlayer2.bar"
)
@@ -98,7 +91,7 @@ def test_position_tracker_seeked_calibrates_immediately() -> None:
async def _poll(_bus: str):
return 1200
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
@@ -116,7 +109,7 @@ def test_position_tracker_playback_status_pause_stops_fast_growth() -> None:
async def _poll(_bus: str):
return 0
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
@@ -140,7 +133,7 @@ def test_position_tracker_playback_status_playing_calibrates_once() -> None:
async def _poll(_bus: str):
return 50000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
@@ -159,7 +152,7 @@ def test_position_tracker_set_active_player_playing_calibrates_on_resume() -> No
async def _poll(_bus: str):
return 42000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
@@ -189,11 +182,11 @@ def test_control_server_and_client_roundtrip(tmp_path: Path) -> None:
return {"ok": True, "offset_ms": self.offset, "lyrics_status": "idle"}
socket_path = tmp_path / "watch.sock"
server = ControlServer(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
server = ControlServer(socket_path=socket_path, config=TEST_CONFIG)
session = _Session()
await server.start(session)
client = ControlClient(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
await server.start(session) # type: ignore
client = ControlClient(socket_path=socket_path, config=TEST_CONFIG)
r1 = await client._send_async({"cmd": "offset", "delta": 200})
r2 = await client._send_async({"cmd": "status"})
await server.stop()
@@ -327,23 +320,23 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
async def _on_result(_lyrics) -> None:
return None
super().__init__(_fetch, _on_fetching, _on_result, TEST_WATCH_OPTIONS)
super().__init__(_fetch, _on_fetching, _on_result, TEST_CONFIG)
self.requested = []
def request(self, track: TrackMeta) -> None:
self.requested.append(track.display_name())
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
_Output(),
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
fake_fetcher = _Fetcher()
session._fetcher = fake_fetcher
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -379,14 +372,14 @@ def test_session_emit_state_only_when_lyric_cursor_changes() -> None:
output = _Output()
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
output,
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -429,14 +422,14 @@ def test_session_emits_when_crossing_first_timestamp() -> None:
output = _Output()
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
output,
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"