diff --git a/lrx_cli/authenticators/musixmatch.py b/lrx_cli/authenticators/musixmatch.py index f602bd5..932ba38 100644 --- a/lrx_cli/authenticators/musixmatch.py +++ b/lrx_cli/authenticators/musixmatch.py @@ -15,8 +15,8 @@ from ..cache import CacheEngine from ..config import ( HTTP_TIMEOUT, MUSIXMATCH_TOKEN_URL, - MUSIXMATCH_USERTOKEN, MUSIXMATCH_COOLDOWN_MS, + credentials, ) _MXM_HEADERS = {"Cookie": "x-mxm-token-guid="} @@ -105,8 +105,8 @@ class MusixmatchAuthenticator(BaseAuthenticator): async def _get_token(self) -> Optional[str]: """Return a valid token: env var > memory > DB > fresh fetch.""" - if MUSIXMATCH_USERTOKEN: - return MUSIXMATCH_USERTOKEN + if credentials.MUSIXMATCH_USERTOKEN: + return credentials.MUSIXMATCH_USERTOKEN if self._cached_token: return self._cached_token diff --git a/lrx_cli/authenticators/qqmusic.py b/lrx_cli/authenticators/qqmusic.py index 93b94ad..e4f4a8d 100644 --- a/lrx_cli/authenticators/qqmusic.py +++ b/lrx_cli/authenticators/qqmusic.py @@ -7,7 +7,7 @@ Description: QQ Music API authenticator - currently only a proxy from typing import Optional from .base import BaseAuthenticator -from ..config import QQ_MUSIC_API_URL +from ..config import credentials class QQMusicAuthenticator(BaseAuthenticator): @@ -19,7 +19,7 @@ class QQMusicAuthenticator(BaseAuthenticator): return "qqmusic" def is_configured(self) -> bool: - return bool(QQ_MUSIC_API_URL) + return bool(credentials.QQ_MUSIC_API_URL) async def authenticate(self) -> Optional[str]: - return QQ_MUSIC_API_URL + return credentials.QQ_MUSIC_API_URL diff --git a/lrx_cli/authenticators/spotify.py b/lrx_cli/authenticators/spotify.py index 934b171..fe3e758 100644 --- a/lrx_cli/authenticators/spotify.py +++ b/lrx_cli/authenticators/spotify.py @@ -18,9 +18,9 @@ from ..config import ( HTTP_TIMEOUT, SPOTIFY_SERVER_TIME_URL, SPOTIFY_SECRET_URL, - SPOTIFY_SP_DC, SPOTIFY_TOKEN_URL, UA_BROWSER, + credentials, ) _SPOTIFY_BASE_HEADERS = { @@ -43,7 +43,7 @@ class SpotifyAuthenticator(BaseAuthenticator): return "spotify" def is_configured(self) -> bool: - return bool(SPOTIFY_SP_DC) + return bool(credentials.SPOTIFY_SP_DC) @staticmethod def _generate_totp(server_time_s: int, secret: str) -> str: @@ -133,14 +133,16 @@ class SpotifyAuthenticator(BaseAuthenticator): if db_token and time.time() < self._token_expires_at - 30: return db_token - if not SPOTIFY_SP_DC: - logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate") + if not credentials.SPOTIFY_SP_DC: + logger.error( + "Spotify: settings.SPOTIFY_SP_DC env var not set — cannot authenticate" + ) return None headers = { "User-Agent": UA_BROWSER, "Accept": "*/*", - "Cookie": f"sp_dc={SPOTIFY_SP_DC}", + "Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}", **_SPOTIFY_BASE_HEADERS, } diff --git a/lrx_cli/config.py b/lrx_cli/config.py index b76da0b..c0ef5b5 100644 --- a/lrx_cli/config.py +++ b/lrx_cli/config.py @@ -65,21 +65,20 @@ SPOTIFY_SECRET_URL = ( "https://raw.githubusercontent.com/xyloflake/spot-secrets-go" "/refs/heads/main/secrets/secrets.json" ) -SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "") # Netease api NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc" NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric" +# QQ api endpoints +QQ_MUSIC_API_SEARCH_ENDPOINT = "/api/search" +QQ_MUSIC_API_LYRIC_ENDPOINT = "/api/lyric" + # LRCLIB api LRCLIB_API_URL = "https://lrclib.net/api/get" LRCLIB_SEARCH_URL = "https://lrclib.net/api/search" -# QQ Music API (self-hosted proxy) -QQ_MUSIC_API_URL = os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/") - # Musixmatch desktop API -MUSIXMATCH_USERTOKEN = os.environ.get("MUSIXMATCH_USERTOKEN", "") MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get" MUSIXMATCH_SEARCH_URL = "https://apic-desktop.musixmatch.com/ws/1.1/track.search" MUSIXMATCH_MACRO_URL = "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get" @@ -91,6 +90,30 @@ MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes # Player preference (used when multiple MPRIS players are active) PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify") + +class _Credentials: + """Credential config with lazy os.environ reads. + + Stable constants live as module-level names above. + Credentials are @property so monkeypatch.setenv / monkeypatch.delenv + affect them without needing to patch each consumer separately. + """ + + @property + def SPOTIFY_SP_DC(self) -> str: + return os.environ.get("SPOTIFY_SP_DC", "") + + @property + def QQ_MUSIC_API_URL(self) -> str: + return os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/") + + @property + def MUSIXMATCH_USERTOKEN(self) -> str: + return os.environ.get("MUSIXMATCH_USERTOKEN", "") + + +credentials = _Credentials() + # User-Agents UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0" UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)" diff --git a/lrx_cli/fetchers/qqmusic.py b/lrx_cli/fetchers/qqmusic.py index d1daad0..d6d0a17 100644 --- a/lrx_cli/fetchers/qqmusic.py +++ b/lrx_cli/fetchers/qqmusic.py @@ -25,6 +25,8 @@ from ..config import ( TTL_NOT_FOUND, TTL_NETWORK_ERROR, MULTI_CANDIDATE_DELAY_S, + QQ_MUSIC_API_LYRIC_ENDPOINT, + QQ_MUSIC_API_SEARCH_ENDPOINT, ) from ..authenticators import QQMusicAuthenticator @@ -52,7 +54,7 @@ class QQMusicFetcher(BaseFetcher): try: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.get( - f"{await self.auth.authenticate()}/api/search", + f"{await self.auth.authenticate()}{QQ_MUSIC_API_SEARCH_ENDPOINT}", params={"keyword": query, "type": "song", "num": limit}, ) resp.raise_for_status() @@ -111,7 +113,7 @@ class QQMusicFetcher(BaseFetcher): try: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: resp = await client.get( - f"{await self.auth.authenticate()}/api/lyric", + f"{await self.auth.authenticate()}{QQ_MUSIC_API_LYRIC_ENDPOINT}", params={"mid": mid}, ) resp.raise_for_status() diff --git a/tests/conftest.py b/tests/conftest.py index 4f0aabb..caab05f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,13 @@ +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) diff --git a/tests/marks.py b/tests/marks.py new file mode 100644 index 0000000..af345a6 --- /dev/null +++ b/tests/marks.py @@ -0,0 +1,16 @@ +import os + +import pytest + +requires_spotify = pytest.mark.skipif( + not os.environ.get("SPOTIFY_SP_DC"), + reason="requires SPOTIFY_SP_DC", +) +requires_qq_music = pytest.mark.skipif( + not os.environ.get("QQ_MUSIC_API_URL"), + reason="requires QQ_MUSIC_API_URL", +) +requires_musixmatch_token = pytest.mark.skipif( + not os.environ.get("MUSIXMATCH_USERTOKEN"), + reason="requires MUSIXMATCH_USERTOKEN", +) diff --git a/tests/test_fetchers.py b/tests/test_fetchers.py index 3e6934f..8598961 100644 --- a/tests/test_fetchers.py +++ b/tests/test_fetchers.py @@ -5,6 +5,11 @@ from dataclasses import replace 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, +) SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta( title="One Last Kiss", @@ -94,13 +99,46 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager): ("lrclib", False), ("lrclib-search", False), ("netease", False), + ("spotify", True), # requires auth + ("qqmusic", True), # requires api ], ) def test_anonymous_remote_fetchers( - lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool + no_credentials, + lrc_manager: LrcManager, + method: FetcherMethodType, + expect_fail: bool, ): _fetch_and_assert(lrc_manager, method, expect_fail) +@pytest.mark.network +@requires_spotify +def test_spotify_fetcher(lrc_manager: LrcManager): + _fetch_and_assert(lrc_manager, "spotify") + + +@pytest.mark.network +@requires_qq_music +def test_qqmusic_fetcher(lrc_manager: LrcManager): + _fetch_and_assert(lrc_manager, "qqmusic") + + +@pytest.mark.network +def test_musixmatch_anonymous_fetcher(no_credentials, 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) + _fetch_and_assert(lrc_manager, "musixmatch-spotify", expect_fail=False) + + +@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_local_fetcher(lrc_manager: LrcManager): + # Since this not a local track _fetch_and_assert(lrc_manager, "local", True)