Compare commits

..

3 Commits

16 changed files with 161 additions and 117 deletions
+7 -10
View File
@@ -12,12 +12,9 @@ from loguru import logger
from .base import BaseAuthenticator from .base import BaseAuthenticator
from ..cache import CacheEngine from ..cache import CacheEngine
from ..config import ( from ..config import HTTP_TIMEOUT, MUSIXMATCH_COOLDOWN_MS, credentials
HTTP_TIMEOUT,
MUSIXMATCH_TOKEN_URL, _MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
MUSIXMATCH_USERTOKEN,
MUSIXMATCH_COOLDOWN_MS,
)
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="} _MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
_MXM_BASE_PARAMS = { _MXM_BASE_PARAMS = {
@@ -61,7 +58,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
{"until_ms": until_ms}, {"until_ms": until_ms},
expires_at_ms=until_ms, expires_at_ms=until_ms,
) )
logger.warning("Musixmatch: token unavailable, entering cooldown for 1 hour") logger.warning("Musixmatch: token unavailable, entering cooldown")
def _invalidate_token(self) -> None: def _invalidate_token(self) -> None:
"""Discard the current token from memory and DB.""" """Discard the current token from memory and DB."""
@@ -76,7 +73,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
"user_language": "en", "user_language": "en",
"t": str(int(time.time() * 1000)), "t": str(int(time.time() * 1000)),
} }
url = f"{MUSIXMATCH_TOKEN_URL}?{urlencode(params)}" url = f"{_MUSIXMATCH_TOKEN_URL}?{urlencode(params)}"
logger.debug("Musixmatch: fetching anonymous token") logger.debug("Musixmatch: fetching anonymous token")
try: try:
@@ -105,8 +102,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
+3 -3
View File
@@ -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
+19 -18
View File
@@ -14,16 +14,16 @@ from loguru import logger
from .base import BaseAuthenticator from .base import BaseAuthenticator
from ..cache import CacheEngine from ..cache import CacheEngine
from ..config import ( from ..config import HTTP_TIMEOUT, UA_BROWSER, credentials
HTTP_TIMEOUT,
SPOTIFY_SERVER_TIME_URL,
SPOTIFY_SECRET_URL,
SPOTIFY_SP_DC,
SPOTIFY_TOKEN_URL,
UA_BROWSER,
)
_SPOTIFY_BASE_HEADERS = { _SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token"
_SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
_SPOTIFY_SECRET_URL = (
"https://raw.githubusercontent.com/xyloflake/spot-secrets-go"
"/refs/heads/main/secrets/secrets.json"
)
SPOTIFY_BASE_HEADERS = {
"User-Agent": UA_BROWSER,
"Referer": "https://open.spotify.com/", "Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com", "Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer", "App-Platform": "WebPlayer",
@@ -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:
@@ -82,7 +82,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]: async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
try: try:
res = await client.get(SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT) res = await client.get(_SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT)
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
if not isinstance(data, dict) or "serverTime" not in data: if not isinstance(data, dict) or "serverTime" not in data:
@@ -100,7 +100,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
logger.debug("Spotify: using cached TOTP secret") logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret return self._cached_secret
try: try:
res = await client.get(SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT) res = await client.get(_SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT)
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
if not isinstance(data, list) or len(data) == 0: if not isinstance(data, list) or len(data) == 0:
@@ -133,15 +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,
"Accept": "*/*", "Accept": "*/*",
"Cookie": f"sp_dc={SPOTIFY_SP_DC}", "Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}",
**_SPOTIFY_BASE_HEADERS, **SPOTIFY_BASE_HEADERS,
} }
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
@@ -167,7 +168,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
try: try:
res = await client.get( res = await client.get(
SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT _SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT
) )
if res.status_code != 200: if res.status_code != 200:
logger.error(f"Spotify: token request returned {res.status_code}") logger.error(f"Spotify: token request returned {res.status_code}")
+26 -31
View File
@@ -57,43 +57,38 @@ MULTI_CANDIDATE_DELAY_S = 0.2 # delay between sequential lyric fetches
LEGACY_CONFIDENCE_SYNCED = 50.0 LEGACY_CONFIDENCE_SYNCED = 50.0
LEGACY_CONFIDENCE_UNSYNCED = 40.0 LEGACY_CONFIDENCE_UNSYNCED = 40.0
# Spotify related # User-Agents
SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token" UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/" UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
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"
# 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"
MUSIXMATCH_TRACK_MATCH_URL = (
"https://apic-desktop.musixmatch.com/ws/1.1/matcher.track.get"
)
MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes 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")
# User-Agents
UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0" class _Credentials:
UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)" """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()
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
+5 -2
View File
@@ -11,7 +11,10 @@ from loguru import logger
from .base import BaseEnricher from .base import BaseEnricher
from ..authenticators.musixmatch import MusixmatchAuthenticator from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..models import TrackMeta from ..models import TrackMeta
from ..config import MUSIXMATCH_TRACK_MATCH_URL
_MUSIXMATCH_TRACK_MATCH_URL = (
"https://apic-desktop.musixmatch.com/ws/1.1/matcher.track.get"
)
class MusixmatchSpotifyEnricher(BaseEnricher): class MusixmatchSpotifyEnricher(BaseEnricher):
@@ -36,7 +39,7 @@ class MusixmatchSpotifyEnricher(BaseEnricher):
try: try:
data = await self.auth.get_json( data = await self.auth.get_json(
MUSIXMATCH_TRACK_MATCH_URL, _MUSIXMATCH_TRACK_MATCH_URL,
{"track_spotify_id": track.trackid}, {"track_spotify_id": track.trackid},
) )
except Exception as e: except Exception as e:
+3 -2
View File
@@ -21,10 +21,11 @@ from ..config import (
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
LRCLIB_API_URL,
UA_LRX, UA_LRX,
) )
_LRCLIB_API_URL = "https://lrclib.net/api/get"
class LrclibFetcher(BaseFetcher): class LrclibFetcher(BaseFetcher):
@property @property
@@ -48,7 +49,7 @@ class LrclibFetcher(BaseFetcher):
"album_name": track.album, "album_name": track.album,
"duration": track.length / 1000.0 if track.length else 0, "duration": track.length / 1000.0 if track.length else 0,
} }
url = f"{LRCLIB_API_URL}?{urlencode(params)}" url = f"{_LRCLIB_API_URL}?{urlencode(params)}"
logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}") logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}")
try: try:
+3 -2
View File
@@ -24,10 +24,11 @@ from ..config import (
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
LRCLIB_SEARCH_URL,
UA_LRX, UA_LRX,
) )
_LRCLIB_SEARCH_URL = "https://lrclib.net/api/search"
class LrclibSearchFetcher(BaseFetcher): class LrclibSearchFetcher(BaseFetcher):
@property @property
@@ -83,7 +84,7 @@ class LrclibSearchFetcher(BaseFetcher):
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async def _query(params: dict[str, str]) -> tuple[list[dict], bool]: async def _query(params: dict[str, str]) -> tuple[list[dict], bool]:
url = f"{LRCLIB_SEARCH_URL}?{urlencode(params)}" url = f"{_LRCLIB_SEARCH_URL}?{urlencode(params)}"
logger.debug(f"LRCLIB-search: query {params}") logger.debug(f"LRCLIB-search: query {params}")
try: try:
resp = await client.get(url, headers={"User-Agent": UA_LRX}) resp = await client.get(url, headers={"User-Agent": UA_LRX})
+7 -7
View File
@@ -22,12 +22,12 @@ from .selection import SearchCandidate, select_best
from ..authenticators.musixmatch import MusixmatchAuthenticator from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..lrc import LRCData from ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta from ..models import CacheStatus, LyricResult, TrackMeta
from ..config import ( from ..config import TTL_NETWORK_ERROR, TTL_NOT_FOUND
MUSIXMATCH_MACRO_URL,
MUSIXMATCH_SEARCH_URL, _MUSIXMATCH_MACRO_URL = (
TTL_NETWORK_ERROR, "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get"
TTL_NOT_FOUND,
) )
_MUSIXMATCH_SEARCH_URL = "https://apic-desktop.musixmatch.com/ws/1.1/track.search"
# Macro-specific params (format/app_id injected by authenticator) # Macro-specific params (format/app_id injected by authenticator)
_MXM_MACRO_PARAMS = { _MXM_MACRO_PARAMS = {
@@ -97,7 +97,7 @@ async def _fetch_macro(
lyrics are found. Raises on HTTP/network errors. lyrics are found. Raises on HTTP/network errors.
""" """
logger.debug(f"Musixmatch: macro call with {list(params.keys())}") logger.debug(f"Musixmatch: macro call with {list(params.keys())}")
data = await auth.get_json(MUSIXMATCH_MACRO_URL, {**_MXM_MACRO_PARAMS, **params}) data = await auth.get_json(_MUSIXMATCH_MACRO_URL, {**_MXM_MACRO_PARAMS, **params})
if data is None: if data is None:
return None return None
@@ -220,7 +220,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album params["q_album"] = track.album
logger.debug(f"Musixmatch: searching for '{track.display_name()}'") logger.debug(f"Musixmatch: searching for '{track.display_name()}'")
data = await self.auth.get_json(MUSIXMATCH_SEARCH_URL, params) data = await self.auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
if data is None: if data is None:
return None, 0.0 return None, 0.0
+8 -7
View File
@@ -26,14 +26,15 @@ from ..config import (
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
MULTI_CANDIDATE_DELAY_S, MULTI_CANDIDATE_DELAY_S,
NETEASE_SEARCH_URL,
NETEASE_LYRIC_URL,
UA_BROWSER, UA_BROWSER,
) )
_HEADERS = { _NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc"
_NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric"
_NETEASE_BASE_HEADERS = {
"User-Agent": UA_BROWSER, "User-Agent": UA_BROWSER,
"Referer": "https://music.163.com/", "Referer": "https://music.163.com/",
"Origin": "https://music.163.com",
} }
@@ -57,8 +58,8 @@ class NeteaseFetcher(BaseFetcher):
try: try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.post( resp = await client.post(
NETEASE_SEARCH_URL, _NETEASE_SEARCH_URL,
headers=_HEADERS, headers=_NETEASE_BASE_HEADERS,
data={"s": query, "type": "1", "limit": str(limit), "offset": "0"}, data={"s": query, "type": "1", "limit": str(limit), "offset": "0"},
) )
resp.raise_for_status() resp.raise_for_status()
@@ -124,8 +125,8 @@ class NeteaseFetcher(BaseFetcher):
try: try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.post( resp = await client.post(
NETEASE_LYRIC_URL, _NETEASE_LYRIC_URL,
headers=_HEADERS, headers=_NETEASE_BASE_HEADERS,
data={ data={
"id": str(song_id), "id": str(song_id),
"cp": "false", "cp": "false",
+5 -2
View File
@@ -26,6 +26,9 @@ from ..config import (
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
MULTI_CANDIDATE_DELAY_S, MULTI_CANDIDATE_DELAY_S,
) )
_QQ_MUSIC_API_SEARCH_ENDPOINT = "/api/search"
_QQ_MUSIC_API_LYRIC_ENDPOINT = "/api/lyric"
from ..authenticators import QQMusicAuthenticator from ..authenticators import QQMusicAuthenticator
@@ -52,7 +55,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 +114,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()
+5 -17
View File
@@ -9,23 +9,12 @@ from typing import Optional
from loguru import logger from loguru import logger
from .base import BaseFetcher from .base import BaseFetcher
from ..authenticators.spotify import SpotifyAuthenticator from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import HTTP_TIMEOUT, TTL_NOT_FOUND, TTL_NETWORK_ERROR
HTTP_TIMEOUT,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
SPOTIFY_LYRICS_URL,
UA_BROWSER,
)
_SPOTIFY_BASE_HEADERS = { _SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
"Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer",
"Spotify-App-Version": "1.2.88.21.g8e037c8f",
}
class SpotifyFetcher(BaseFetcher): class SpotifyFetcher(BaseFetcher):
@@ -71,12 +60,11 @@ class SpotifyFetcher(BaseFetcher):
logger.error("Spotify: cannot fetch lyrics without a token") logger.error("Spotify: cannot fetch lyrics without a token")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
url = f"{SPOTIFY_LYRICS_URL}{track.trackid}?format=json&vocalRemoval=false&market=from_token" url = f"{_SPOTIFY_LYRICS_URL}{track.trackid}?format=json&vocalRemoval=false&market=from_token"
headers = { headers = {
"User-Agent": UA_BROWSER,
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
**_SPOTIFY_BASE_HEADERS, **SPOTIFY_BASE_HEADERS,
} }
try: try:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lrx-cli" name = "lrx-cli"
version = "0.5.4" version = "0.5.5"
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"
+10
View File
@@ -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)
+16
View File
@@ -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",
)
+42 -14
View File
@@ -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",
@@ -91,26 +96,49 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"method, expect_fail", "method, expect_fail",
[ [
("spotify", False),
("lrclib", False), ("lrclib", False),
("lrclib-search", False), ("lrclib-search", False),
("musixmatch", False),
("musixmatch-spotify", False),
("netease", False), ("netease", False),
("qqmusic", False), ("spotify", True), # requires auth
("qqmusic", True), # requires api
], ],
) )
def test_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.parametrize( @pytest.mark.network
"method, expect_fail", @requires_spotify
[("local", True)], def test_spotify_fetcher(lrc_manager: LrcManager):
) _fetch_and_assert(lrc_manager, "spotify")
def test_local_fetcher(
lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool
): @pytest.mark.network
_fetch_and_assert(lrc_manager, method, expect_fail) @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)
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]] [[package]]
name = "lrx-cli" name = "lrx-cli"
version = "0.5.4" version = "0.5.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cyclopts" }, { name = "cyclopts" },