refactor: lazy load credentials for testing
This commit is contained in:
@@ -15,8 +15,8 @@ from ..cache import CacheEngine
|
|||||||
from ..config import (
|
from ..config import (
|
||||||
HTTP_TIMEOUT,
|
HTTP_TIMEOUT,
|
||||||
MUSIXMATCH_TOKEN_URL,
|
MUSIXMATCH_TOKEN_URL,
|
||||||
MUSIXMATCH_USERTOKEN,
|
|
||||||
MUSIXMATCH_COOLDOWN_MS,
|
MUSIXMATCH_COOLDOWN_MS,
|
||||||
|
credentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
|
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
|
||||||
@@ -105,8 +105,8 @@ class MusixmatchAuthenticator(BaseAuthenticator):
|
|||||||
|
|
||||||
async def _get_token(self) -> Optional[str]:
|
async def _get_token(self) -> Optional[str]:
|
||||||
"""Return a valid token: env var > memory > DB > fresh fetch."""
|
"""Return a valid token: env var > memory > DB > fresh fetch."""
|
||||||
if MUSIXMATCH_USERTOKEN:
|
if credentials.MUSIXMATCH_USERTOKEN:
|
||||||
return MUSIXMATCH_USERTOKEN
|
return credentials.MUSIXMATCH_USERTOKEN
|
||||||
|
|
||||||
if self._cached_token:
|
if self._cached_token:
|
||||||
return self._cached_token
|
return self._cached_token
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Description: QQ Music API authenticator - currently only a proxy
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import BaseAuthenticator
|
from .base import BaseAuthenticator
|
||||||
from ..config import QQ_MUSIC_API_URL
|
from ..config import credentials
|
||||||
|
|
||||||
|
|
||||||
class QQMusicAuthenticator(BaseAuthenticator):
|
class QQMusicAuthenticator(BaseAuthenticator):
|
||||||
@@ -19,7 +19,7 @@ class QQMusicAuthenticator(BaseAuthenticator):
|
|||||||
return "qqmusic"
|
return "qqmusic"
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(QQ_MUSIC_API_URL)
|
return bool(credentials.QQ_MUSIC_API_URL)
|
||||||
|
|
||||||
async def authenticate(self) -> Optional[str]:
|
async def authenticate(self) -> Optional[str]:
|
||||||
return QQ_MUSIC_API_URL
|
return credentials.QQ_MUSIC_API_URL
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ from ..config import (
|
|||||||
HTTP_TIMEOUT,
|
HTTP_TIMEOUT,
|
||||||
SPOTIFY_SERVER_TIME_URL,
|
SPOTIFY_SERVER_TIME_URL,
|
||||||
SPOTIFY_SECRET_URL,
|
SPOTIFY_SECRET_URL,
|
||||||
SPOTIFY_SP_DC,
|
|
||||||
SPOTIFY_TOKEN_URL,
|
SPOTIFY_TOKEN_URL,
|
||||||
UA_BROWSER,
|
UA_BROWSER,
|
||||||
|
credentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
_SPOTIFY_BASE_HEADERS = {
|
_SPOTIFY_BASE_HEADERS = {
|
||||||
@@ -43,7 +43,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
|
|||||||
return "spotify"
|
return "spotify"
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(SPOTIFY_SP_DC)
|
return bool(credentials.SPOTIFY_SP_DC)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_totp(server_time_s: int, secret: str) -> str:
|
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:
|
if db_token and time.time() < self._token_expires_at - 30:
|
||||||
return db_token
|
return db_token
|
||||||
|
|
||||||
if not SPOTIFY_SP_DC:
|
if not credentials.SPOTIFY_SP_DC:
|
||||||
logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate")
|
logger.error(
|
||||||
|
"Spotify: settings.SPOTIFY_SP_DC env var not set — cannot authenticate"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": UA_BROWSER,
|
"User-Agent": UA_BROWSER,
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
"Cookie": f"sp_dc={SPOTIFY_SP_DC}",
|
"Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}",
|
||||||
**_SPOTIFY_BASE_HEADERS,
|
**_SPOTIFY_BASE_HEADERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-5
@@ -65,21 +65,20 @@ SPOTIFY_SECRET_URL = (
|
|||||||
"https://raw.githubusercontent.com/xyloflake/spot-secrets-go"
|
"https://raw.githubusercontent.com/xyloflake/spot-secrets-go"
|
||||||
"/refs/heads/main/secrets/secrets.json"
|
"/refs/heads/main/secrets/secrets.json"
|
||||||
)
|
)
|
||||||
SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "")
|
|
||||||
|
|
||||||
# Netease api
|
# Netease api
|
||||||
NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc"
|
NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc"
|
||||||
NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric"
|
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
|
||||||
LRCLIB_API_URL = "https://lrclib.net/api/get"
|
LRCLIB_API_URL = "https://lrclib.net/api/get"
|
||||||
LRCLIB_SEARCH_URL = "https://lrclib.net/api/search"
|
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 desktop API
|
||||||
MUSIXMATCH_USERTOKEN = os.environ.get("MUSIXMATCH_USERTOKEN", "")
|
|
||||||
MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
|
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_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"
|
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)
|
# Player preference (used when multiple MPRIS players are active)
|
||||||
PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify")
|
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
|
# User-Agents
|
||||||
UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
|
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)"
|
UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from ..config import (
|
|||||||
TTL_NOT_FOUND,
|
TTL_NOT_FOUND,
|
||||||
TTL_NETWORK_ERROR,
|
TTL_NETWORK_ERROR,
|
||||||
MULTI_CANDIDATE_DELAY_S,
|
MULTI_CANDIDATE_DELAY_S,
|
||||||
|
QQ_MUSIC_API_LYRIC_ENDPOINT,
|
||||||
|
QQ_MUSIC_API_SEARCH_ENDPOINT,
|
||||||
)
|
)
|
||||||
from ..authenticators import QQMusicAuthenticator
|
from ..authenticators import QQMusicAuthenticator
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ class QQMusicFetcher(BaseFetcher):
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||||
resp = await client.get(
|
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},
|
params={"keyword": query, "type": "song", "num": limit},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@@ -111,7 +113,7 @@ class QQMusicFetcher(BaseFetcher):
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{await self.auth.authenticate()}/api/lyric",
|
f"{await self.auth.authenticate()}{QQ_MUSIC_API_LYRIC_ENDPOINT}",
|
||||||
params={"mid": mid},
|
params={"mid": mid},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
from lrx_cli.config import enable_debug
|
from lrx_cli.config import enable_debug
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
+39
-1
@@ -5,6 +5,11 @@ from dataclasses import replace
|
|||||||
from lrx_cli.fetchers import FetcherMethodType
|
from lrx_cli.fetchers import FetcherMethodType
|
||||||
from lrx_cli.models import TrackMeta
|
from lrx_cli.models import TrackMeta
|
||||||
from lrx_cli.core import LrcManager
|
from lrx_cli.core import LrcManager
|
||||||
|
from tests.marks import (
|
||||||
|
requires_spotify,
|
||||||
|
requires_qq_music,
|
||||||
|
requires_musixmatch_token,
|
||||||
|
)
|
||||||
|
|
||||||
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
|
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
|
||||||
title="One Last Kiss",
|
title="One Last Kiss",
|
||||||
@@ -94,13 +99,46 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
|
|||||||
("lrclib", False),
|
("lrclib", False),
|
||||||
("lrclib-search", False),
|
("lrclib-search", False),
|
||||||
("netease", False),
|
("netease", False),
|
||||||
|
("spotify", True), # requires auth
|
||||||
|
("qqmusic", True), # requires api
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_anonymous_remote_fetchers(
|
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)
|
_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):
|
def test_local_fetcher(lrc_manager: LrcManager):
|
||||||
|
# Since this not a local track
|
||||||
_fetch_and_assert(lrc_manager, "local", True)
|
_fetch_and_assert(lrc_manager, "local", True)
|
||||||
|
|||||||
Reference in New Issue
Block a user