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 ..cache import CacheEngine
from ..config import (
HTTP_TIMEOUT,
MUSIXMATCH_TOKEN_URL,
MUSIXMATCH_USERTOKEN,
MUSIXMATCH_COOLDOWN_MS,
)
from ..config import HTTP_TIMEOUT, MUSIXMATCH_COOLDOWN_MS, credentials
_MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
_MXM_BASE_PARAMS = {
@@ -61,7 +58,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
{"until_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:
"""Discard the current token from memory and DB."""
@@ -76,7 +73,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
"user_language": "en",
"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")
try:
@@ -105,8 +102,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
+3 -3
View File
@@ -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
+19 -18
View File
@@ -14,16 +14,16 @@ from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import (
HTTP_TIMEOUT,
SPOTIFY_SERVER_TIME_URL,
SPOTIFY_SECRET_URL,
SPOTIFY_SP_DC,
SPOTIFY_TOKEN_URL,
UA_BROWSER,
)
from ..config import HTTP_TIMEOUT, UA_BROWSER, credentials
_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/",
"Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer",
@@ -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:
@@ -82,7 +82,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
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()
data = res.json()
if not isinstance(data, dict) or "serverTime" not in data:
@@ -100,7 +100,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret
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()
data = res.json()
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:
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}",
**_SPOTIFY_BASE_HEADERS,
"Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}",
**SPOTIFY_BASE_HEADERS,
}
async with httpx.AsyncClient(headers=headers) as client:
@@ -167,7 +168,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
try:
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:
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_UNSYNCED = 40.0
# Spotify related
SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token"
SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
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", "")
# 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)"
# 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
# Player preference (used when multiple MPRIS players are active)
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"
UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
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()
os.makedirs(CACHE_DIR, exist_ok=True)
+5 -2
View File
@@ -11,7 +11,10 @@ from loguru import logger
from .base import BaseEnricher
from ..authenticators.musixmatch import MusixmatchAuthenticator
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):
@@ -36,7 +39,7 @@ class MusixmatchSpotifyEnricher(BaseEnricher):
try:
data = await self.auth.get_json(
MUSIXMATCH_TRACK_MATCH_URL,
_MUSIXMATCH_TRACK_MATCH_URL,
{"track_spotify_id": track.trackid},
)
except Exception as e:
+3 -2
View File
@@ -21,10 +21,11 @@ from ..config import (
TTL_UNSYNCED,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
LRCLIB_API_URL,
UA_LRX,
)
_LRCLIB_API_URL = "https://lrclib.net/api/get"
class LrclibFetcher(BaseFetcher):
@property
@@ -48,7 +49,7 @@ class LrclibFetcher(BaseFetcher):
"album_name": track.album,
"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()}")
try:
+3 -2
View File
@@ -24,10 +24,11 @@ from ..config import (
TTL_UNSYNCED,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
LRCLIB_SEARCH_URL,
UA_LRX,
)
_LRCLIB_SEARCH_URL = "https://lrclib.net/api/search"
class LrclibSearchFetcher(BaseFetcher):
@property
@@ -83,7 +84,7 @@ class LrclibSearchFetcher(BaseFetcher):
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
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}")
try:
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 ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta
from ..config import (
MUSIXMATCH_MACRO_URL,
MUSIXMATCH_SEARCH_URL,
TTL_NETWORK_ERROR,
TTL_NOT_FOUND,
from ..config import TTL_NETWORK_ERROR, TTL_NOT_FOUND
_MUSIXMATCH_MACRO_URL = (
"https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get"
)
_MUSIXMATCH_SEARCH_URL = "https://apic-desktop.musixmatch.com/ws/1.1/track.search"
# Macro-specific params (format/app_id injected by authenticator)
_MXM_MACRO_PARAMS = {
@@ -97,7 +97,7 @@ async def _fetch_macro(
lyrics are found. Raises on HTTP/network errors.
"""
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:
return None
@@ -220,7 +220,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album
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:
return None, 0.0
+8 -7
View File
@@ -26,14 +26,15 @@ from ..config import (
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
MULTI_CANDIDATE_DELAY_S,
NETEASE_SEARCH_URL,
NETEASE_LYRIC_URL,
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,
"Referer": "https://music.163.com/",
"Origin": "https://music.163.com",
}
@@ -57,8 +58,8 @@ class NeteaseFetcher(BaseFetcher):
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.post(
NETEASE_SEARCH_URL,
headers=_HEADERS,
_NETEASE_SEARCH_URL,
headers=_NETEASE_BASE_HEADERS,
data={"s": query, "type": "1", "limit": str(limit), "offset": "0"},
)
resp.raise_for_status()
@@ -124,8 +125,8 @@ class NeteaseFetcher(BaseFetcher):
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
resp = await client.post(
NETEASE_LYRIC_URL,
headers=_HEADERS,
_NETEASE_LYRIC_URL,
headers=_NETEASE_BASE_HEADERS,
data={
"id": str(song_id),
"cp": "false",
+5 -2
View File
@@ -26,6 +26,9 @@ from ..config import (
TTL_NETWORK_ERROR,
MULTI_CANDIDATE_DELAY_S,
)
_QQ_MUSIC_API_SEARCH_ENDPOINT = "/api/search"
_QQ_MUSIC_API_LYRIC_ENDPOINT = "/api/lyric"
from ..authenticators import QQMusicAuthenticator
@@ -52,7 +55,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 +114,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()
+5 -17
View File
@@ -9,23 +9,12 @@ from typing import Optional
from loguru import logger
from .base import BaseFetcher
from ..authenticators.spotify import SpotifyAuthenticator
from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
SPOTIFY_LYRICS_URL,
UA_BROWSER,
)
from ..config import HTTP_TIMEOUT, TTL_NOT_FOUND, TTL_NETWORK_ERROR
_SPOTIFY_BASE_HEADERS = {
"Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer",
"Spotify-App-Version": "1.2.88.21.g8e037c8f",
}
_SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
class SpotifyFetcher(BaseFetcher):
@@ -71,12 +60,11 @@ class SpotifyFetcher(BaseFetcher):
logger.error("Spotify: cannot fetch lyrics without a token")
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 = {
"User-Agent": UA_BROWSER,
"Accept": "application/json",
"Authorization": f"Bearer {token}",
**_SPOTIFY_BASE_HEADERS,
**SPOTIFY_BASE_HEADERS,
}
try:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lrx-cli"
version = "0.5.4"
version = "0.5.5"
description = "Fetch line-synced lyrics for your music player."
readme = "README.md"
requires-python = ">=3.13"
+10
View File
@@ -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)
+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.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",
@@ -91,26 +96,49 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
@pytest.mark.parametrize(
"method, expect_fail",
[
("spotify", False),
("lrclib", False),
("lrclib-search", False),
("musixmatch", False),
("musixmatch-spotify", False),
("netease", False),
("qqmusic", False),
("spotify", True), # requires auth
("qqmusic", True), # requires api
],
)
def test_remote_fetchers(
lrc_manager: LrcManager, method: FetcherMethodType, expect_fail: bool
def test_anonymous_remote_fetchers(
no_credentials,
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)
@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)
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "lrx-cli"
version = "0.5.4"
version = "0.5.5"
source = { editable = "." }
dependencies = [
{ name = "cyclopts" },