refactor: move some impl-specific constants out from config.py

This commit is contained in:
2026-04-06 07:44:05 +02:00
parent 9b04160783
commit 2d70231502
12 changed files with 59 additions and 96 deletions
+5 -8
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_COOLDOWN_MS,
credentials,
)
_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:
+13 -14
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_TOKEN_URL,
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/", "Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com", "Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer", "App-Platform": "WebPlayer",
@@ -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:
@@ -140,10 +140,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
return None return None
headers = { headers = {
"User-Agent": UA_BROWSER,
"Accept": "*/*", "Accept": "*/*",
"Cookie": f"sp_dc={credentials.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:
@@ -169,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}")
+3 -31
View File
@@ -57,34 +57,10 @@ 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"
)
# 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"
# Musixmatch desktop API
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)
@@ -114,10 +90,6 @@ class _Credentials:
credentials = _Credentials() 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)"
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
# Logger # Logger
+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 -4
View File
@@ -25,9 +25,10 @@ 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,
) )
_QQ_MUSIC_API_SEARCH_ENDPOINT = "/api/search"
_QQ_MUSIC_API_LYRIC_ENDPOINT = "/api/lyric"
from ..authenticators import QQMusicAuthenticator from ..authenticators import QQMusicAuthenticator
@@ -54,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()}{QQ_MUSIC_API_SEARCH_ENDPOINT}", 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()
@@ -113,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()}{QQ_MUSIC_API_LYRIC_ENDPOINT}", 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"
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" },