feat: auth: add auth module
This commit is contained in:
@@ -16,6 +16,12 @@ from .lrclib_search import LrclibSearchFetcher
|
||||
from .musixmatch import MusixmatchFetcher, MusixmatchSpotifyFetcher
|
||||
from .netease import NeteaseFetcher
|
||||
from .qqmusic import QQMusicFetcher
|
||||
from ..authenticators import (
|
||||
BaseAuthenticator,
|
||||
SpotifyAuthenticator,
|
||||
MusixmatchAuthenticator,
|
||||
QQMusicAuthenticator,
|
||||
)
|
||||
from ..cache import CacheEngine
|
||||
from ..models import TrackMeta
|
||||
|
||||
@@ -43,18 +49,27 @@ _FETCHER_GROUPS: list[list[FetcherMethodType]] = [
|
||||
]
|
||||
|
||||
|
||||
def create_fetchers(cache: CacheEngine) -> dict[FetcherMethodType, BaseFetcher]:
|
||||
def create_fetchers(
|
||||
cache: CacheEngine,
|
||||
authenticators: dict[str, BaseAuthenticator],
|
||||
) -> dict[FetcherMethodType, BaseFetcher]:
|
||||
"""Instantiate all fetchers. Returns a dict keyed by source name."""
|
||||
spotify_auth = authenticators["spotify"]
|
||||
mxm_auth = authenticators["musixmatch"]
|
||||
qqmusic_auth = authenticators.get("qqmusic")
|
||||
assert isinstance(spotify_auth, SpotifyAuthenticator)
|
||||
assert isinstance(mxm_auth, MusixmatchAuthenticator)
|
||||
assert isinstance(qqmusic_auth, QQMusicAuthenticator)
|
||||
fetchers: dict[FetcherMethodType, BaseFetcher] = {
|
||||
"local": LocalFetcher(),
|
||||
"cache-search": CacheSearchFetcher(cache),
|
||||
"spotify": SpotifyFetcher(),
|
||||
"spotify": SpotifyFetcher(spotify_auth),
|
||||
"lrclib": LrclibFetcher(),
|
||||
"musixmatch-spotify": MusixmatchSpotifyFetcher(),
|
||||
"musixmatch-spotify": MusixmatchSpotifyFetcher(mxm_auth),
|
||||
"lrclib-search": LrclibSearchFetcher(),
|
||||
"netease": NeteaseFetcher(),
|
||||
"qqmusic": QQMusicFetcher(),
|
||||
"musixmatch": MusixmatchFetcher(),
|
||||
"qqmusic": QQMusicFetcher(qqmusic_auth),
|
||||
"musixmatch": MusixmatchFetcher(mxm_auth),
|
||||
}
|
||||
return fetchers
|
||||
|
||||
|
||||
+83
-105
@@ -6,42 +6,34 @@ Description: Musixmatch fetchers (desktop API, usertoken auth)
|
||||
|
||||
"""
|
||||
Uses the Musixmatch desktop API (apic-desktop.musixmatch.com).
|
||||
Requires MUSIXMATCH_USERTOKEN from https://curators.musixmatch.com/settings
|
||||
→ "Copy debug info" → find UserToken.
|
||||
Token and all HTTP calls are managed by MusixmatchAuthenticator.
|
||||
|
||||
Two fetchers:
|
||||
musixmatch-spotify — direct lookup by Spotify track ID (exact, no search)
|
||||
musixmatch — metadata search + multi-candidate fallback
|
||||
musixmatch — metadata search + best-candidate fallback
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from .base import BaseFetcher
|
||||
from .selection import SearchCandidate, select_best
|
||||
from ..authenticators.musixmatch import MusixmatchAuthenticator
|
||||
from ..lrc import LRCData
|
||||
from ..models import CacheStatus, LyricResult, TrackMeta
|
||||
from ..config import (
|
||||
HTTP_TIMEOUT,
|
||||
MUSIXMATCH_MACRO_URL,
|
||||
MUSIXMATCH_SEARCH_URL,
|
||||
MUSIXMATCH_USERTOKEN,
|
||||
TTL_NETWORK_ERROR,
|
||||
TTL_NOT_FOUND,
|
||||
)
|
||||
|
||||
_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="}
|
||||
|
||||
_MXM_MACRO_BASE_PARAMS: dict[str, str] = {
|
||||
"format": "json",
|
||||
# Macro-specific params (format/app_id injected by authenticator)
|
||||
_MXM_MACRO_PARAMS = {
|
||||
"namespace": "lyrics_richsynched",
|
||||
"subtitle_format": "mxm",
|
||||
"optional_calls": "track.richsync",
|
||||
"app_id": "web-desktop-app-v1.0",
|
||||
}
|
||||
|
||||
|
||||
@@ -96,23 +88,19 @@ def _parse_subtitle(body: str) -> Optional[str]:
|
||||
|
||||
|
||||
async def _fetch_macro(
|
||||
client: httpx.AsyncClient,
|
||||
params: dict[str, str],
|
||||
auth: MusixmatchAuthenticator,
|
||||
params: dict,
|
||||
) -> Optional[LRCData]:
|
||||
"""Call macro.subtitles.get via auth.get_json.
|
||||
|
||||
Returns LRCData (richsync preferred over subtitle), or None when no usable
|
||||
lyrics are found. Raises on HTTP/network errors.
|
||||
"""
|
||||
Call macro.subtitles.get with given params merged onto base params.
|
||||
Returns LRCData on success (richsync preferred over subtitle),
|
||||
None when the API returns no usable lyrics.
|
||||
Raises on HTTP/network errors.
|
||||
"""
|
||||
merged = {**_MXM_MACRO_BASE_PARAMS, **params}
|
||||
url = f"{MUSIXMATCH_MACRO_URL}?{urlencode(merged)}"
|
||||
logger.debug(f"Musixmatch: macro call with {list(params.keys())}")
|
||||
data = await auth.get_json(MUSIXMATCH_MACRO_URL, {**_MXM_MACRO_PARAMS, **params})
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
resp = await client.get(url, headers=_MXM_HEADERS)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
# Musixmatch returns body=[] (not {}) when the track is not found
|
||||
body = data.get("message", {}).get("body", {})
|
||||
if not isinstance(body, dict):
|
||||
@@ -162,26 +150,26 @@ async def _fetch_macro(
|
||||
class MusixmatchSpotifyFetcher(BaseFetcher):
|
||||
"""Direct lookup by Spotify track ID — no search, single request."""
|
||||
|
||||
def __init__(self, auth: MusixmatchAuthenticator) -> None:
|
||||
self.auth = auth
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "musixmatch-spotify"
|
||||
|
||||
def is_available(self, track: TrackMeta) -> bool:
|
||||
return bool(track.trackid) and bool(MUSIXMATCH_USERTOKEN)
|
||||
return bool(track.trackid) and not self.auth.is_cooldown()
|
||||
|
||||
async def fetch(
|
||||
self, track: TrackMeta, bypass_cache: bool = False
|
||||
) -> Optional[LyricResult]:
|
||||
logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
lrc = await _fetch_macro(
|
||||
client,
|
||||
{
|
||||
"track_spotify_id": track.trackid, # type: ignore[dict-item]
|
||||
"usertoken": MUSIXMATCH_USERTOKEN,
|
||||
},
|
||||
)
|
||||
lrc = await _fetch_macro(
|
||||
self.auth,
|
||||
{"track_spotify_id": track.trackid}, # type: ignore[dict-item]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Musixmatch-Spotify: fetch failed: {e}")
|
||||
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
|
||||
@@ -201,21 +189,26 @@ class MusixmatchSpotifyFetcher(BaseFetcher):
|
||||
|
||||
|
||||
class MusixmatchFetcher(BaseFetcher):
|
||||
"""Metadata search + multi-candidate fallback."""
|
||||
"""Metadata search + best-candidate lyric fetch."""
|
||||
|
||||
def __init__(self, auth: MusixmatchAuthenticator) -> None:
|
||||
self.auth = auth
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "musixmatch"
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> str:
|
||||
return "musixmatch"
|
||||
|
||||
def is_available(self, track: TrackMeta) -> bool:
|
||||
return bool(track.title) and bool(MUSIXMATCH_USERTOKEN)
|
||||
return bool(track.title) and not self.auth.is_cooldown()
|
||||
|
||||
async def _search(self, track: TrackMeta) -> tuple[Optional[int], float]:
|
||||
params: dict[str, str] = {
|
||||
"format": "json",
|
||||
"app_id": "web-desktop-app-v1.0",
|
||||
"""Search for track metadata. Raises on network/HTTP errors."""
|
||||
params: dict = {
|
||||
"q_track": track.title or "",
|
||||
"usertoken": MUSIXMATCH_USERTOKEN,
|
||||
"page_size": "10",
|
||||
"f_has_lyrics": "1",
|
||||
}
|
||||
@@ -224,79 +217,64 @@ class MusixmatchFetcher(BaseFetcher):
|
||||
if track.album:
|
||||
params["q_album"] = track.album
|
||||
|
||||
url = f"{MUSIXMATCH_SEARCH_URL}?{urlencode(params)}"
|
||||
logger.debug(f"Musixmatch: searching for '{track.display_name()}'")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(url, headers=_MXM_HEADERS)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
track_list = data.get("message", {}).get("body", {}).get("track_list", [])
|
||||
if not isinstance(track_list, list) or not track_list:
|
||||
logger.debug("Musixmatch: search returned 0 results")
|
||||
return None, 0.0
|
||||
|
||||
logger.debug(f"Musixmatch: search returned {len(track_list)} candidates")
|
||||
|
||||
candidates = [
|
||||
SearchCandidate(
|
||||
item=int(t["commontrack_id"]),
|
||||
duration_ms=(
|
||||
float(t["track_length"]) * 1000
|
||||
if t.get("track_length")
|
||||
else None
|
||||
),
|
||||
is_synced=bool(t.get("has_subtitles") or t.get("has_richsync")),
|
||||
title=t.get("track_name"),
|
||||
artist=t.get("artist_name"),
|
||||
album=t.get("album_name"),
|
||||
)
|
||||
for item in track_list
|
||||
if isinstance(item, dict)
|
||||
and isinstance(t := item.get("track", {}), dict)
|
||||
and isinstance(t.get("commontrack_id"), int)
|
||||
and not t.get("instrumental")
|
||||
]
|
||||
|
||||
best_id, confidence = select_best(
|
||||
candidates,
|
||||
track.length,
|
||||
title=track.title,
|
||||
artist=track.artist,
|
||||
album=track.album,
|
||||
)
|
||||
if best_id is not None:
|
||||
logger.debug(
|
||||
f"Musixmatch: best candidate id={best_id} ({confidence:.0f})"
|
||||
)
|
||||
else:
|
||||
logger.debug("Musixmatch: no suitable candidate found")
|
||||
return best_id, confidence
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Musixmatch: search failed: {e}")
|
||||
data = await self.auth.get_json(MUSIXMATCH_SEARCH_URL, params)
|
||||
if data is None:
|
||||
return None, 0.0
|
||||
|
||||
track_list = data.get("message", {}).get("body", {}).get("track_list", [])
|
||||
if not isinstance(track_list, list) or not track_list:
|
||||
logger.debug("Musixmatch: search returned 0 results")
|
||||
return None, 0.0
|
||||
|
||||
logger.debug(f"Musixmatch: search returned {len(track_list)} candidates")
|
||||
|
||||
candidates = [
|
||||
SearchCandidate(
|
||||
item=int(t["commontrack_id"]),
|
||||
duration_ms=(
|
||||
float(t["track_length"]) * 1000 if t.get("track_length") else None
|
||||
),
|
||||
is_synced=bool(t.get("has_subtitles") or t.get("has_richsync")),
|
||||
title=t.get("track_name"),
|
||||
artist=t.get("artist_name"),
|
||||
album=t.get("album_name"),
|
||||
)
|
||||
for item in track_list
|
||||
if isinstance(item, dict)
|
||||
and isinstance(t := item.get("track", {}), dict)
|
||||
and isinstance(t.get("commontrack_id"), int)
|
||||
and not t.get("instrumental")
|
||||
]
|
||||
|
||||
best_id, confidence = select_best(
|
||||
candidates,
|
||||
track.length,
|
||||
title=track.title,
|
||||
artist=track.artist,
|
||||
album=track.album,
|
||||
)
|
||||
if best_id is not None:
|
||||
logger.debug(f"Musixmatch: best candidate id={best_id} ({confidence:.0f})")
|
||||
else:
|
||||
logger.debug("Musixmatch: no suitable candidate found")
|
||||
return best_id, confidence
|
||||
|
||||
async def fetch(
|
||||
self, track: TrackMeta, bypass_cache: bool = False
|
||||
) -> Optional[LyricResult]:
|
||||
logger.info(f"Musixmatch: fetching lyrics for {track.display_name()}")
|
||||
commontrack_id, confidence = await self._search(track)
|
||||
if commontrack_id is None:
|
||||
logger.debug(f"Musixmatch: no match found for {track.display_name()}")
|
||||
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
lrc = await _fetch_macro(
|
||||
client,
|
||||
{
|
||||
"commontrack_id": str(commontrack_id),
|
||||
"usertoken": MUSIXMATCH_USERTOKEN,
|
||||
},
|
||||
)
|
||||
commontrack_id, confidence = await self._search(track)
|
||||
if commontrack_id is None:
|
||||
logger.debug(f"Musixmatch: no match found for {track.display_name()}")
|
||||
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
|
||||
|
||||
lrc = await _fetch_macro(
|
||||
self.auth,
|
||||
{"commontrack_id": str(commontrack_id)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Musixmatch: fetch failed: {e}")
|
||||
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
|
||||
|
||||
@@ -25,17 +25,20 @@ from ..config import (
|
||||
TTL_NOT_FOUND,
|
||||
TTL_NETWORK_ERROR,
|
||||
MULTI_CANDIDATE_DELAY_S,
|
||||
QQ_MUSIC_API_URL,
|
||||
)
|
||||
from ..authenticators import QQMusicAuthenticator
|
||||
|
||||
|
||||
class QQMusicFetcher(BaseFetcher):
|
||||
def __init__(self, auth: QQMusicAuthenticator) -> None:
|
||||
self.auth = auth
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "qqmusic"
|
||||
|
||||
def is_available(self, track: TrackMeta) -> bool:
|
||||
return bool(track.title) and bool(QQ_MUSIC_API_URL)
|
||||
return bool(track.title) and self.auth.is_configured()
|
||||
|
||||
async def _search(
|
||||
self, track: TrackMeta, limit: int = 10
|
||||
@@ -49,7 +52,7 @@ class QQMusicFetcher(BaseFetcher):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{QQ_MUSIC_API_URL}/api/search",
|
||||
f"{self.auth.authenticate()}/api/search",
|
||||
params={"keyword": query, "type": "song", "num": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -108,7 +111,7 @@ class QQMusicFetcher(BaseFetcher):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{QQ_MUSIC_API_URL}/api/lyric",
|
||||
f"{self.auth.authenticate()}/api/lyric",
|
||||
params={"mid": mid},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -144,8 +147,8 @@ class QQMusicFetcher(BaseFetcher):
|
||||
async def fetch(
|
||||
self, track: TrackMeta, bypass_cache: bool = False
|
||||
) -> Optional[LyricResult]:
|
||||
if not QQ_MUSIC_API_URL:
|
||||
logger.debug("QQMusic: skipped — QQ_MUSIC_API_URL not configured")
|
||||
if not self.auth.is_configured():
|
||||
logger.debug("QQMusic: skipped — Auth not configured")
|
||||
return None
|
||||
|
||||
query = f"{track.artist or ''} {track.title or ''}".strip()
|
||||
|
||||
+6
-217
@@ -4,41 +4,19 @@ Date: 2026-03-25 10:43:21
|
||||
Description: Spotify fetcher — obtains synced lyrics via Spotify's internal color-lyrics API.
|
||||
"""
|
||||
|
||||
"""
|
||||
Authentication flow:
|
||||
1. Fetch server time from Spotify
|
||||
2. Fetch TOTP secret
|
||||
3. Generate a TOTP code and exchange it (with SP_DC cookie) for an access token
|
||||
4. Request lyrics using the access token
|
||||
|
||||
The secret and token are cached on the instance to avoid redundant network
|
||||
calls within the same session.
|
||||
|
||||
Requires SPOTIFY_SP_DC environment variable to be set.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
import struct
|
||||
import hmac
|
||||
import hashlib
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
from .base import BaseFetcher
|
||||
from ..authenticators.spotify import SpotifyAuthenticator
|
||||
from ..models import TrackMeta, LyricResult, CacheStatus
|
||||
from ..lrc import LRCData
|
||||
from ..config import (
|
||||
HTTP_TIMEOUT,
|
||||
TTL_NOT_FOUND,
|
||||
TTL_NETWORK_ERROR,
|
||||
SPOTIFY_TOKEN_URL,
|
||||
SPOTIFY_LYRICS_URL,
|
||||
SPOTIFY_SERVER_TIME_URL,
|
||||
SPOTIFY_SECRET_URL,
|
||||
SPOTIFY_SP_DC,
|
||||
SPOTIFY_TOKEN_CACHE_FILE,
|
||||
UA_BROWSER,
|
||||
)
|
||||
|
||||
@@ -51,72 +29,18 @@ _SPOTIFY_BASE_HEADERS = {
|
||||
|
||||
|
||||
class SpotifyFetcher(BaseFetcher):
|
||||
def __init__(self) -> None:
|
||||
# Session-level caches to avoid refetching within the same run
|
||||
self._cached_secret: Optional[Tuple[str, int]] = None
|
||||
self._cached_token: Optional[str] = None
|
||||
self._token_expires_at: float = 0.0
|
||||
def __init__(self, auth: SpotifyAuthenticator) -> None:
|
||||
self.auth = auth
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "spotify"
|
||||
|
||||
def is_available(self, track: TrackMeta) -> bool:
|
||||
return bool(track.trackid) and bool(SPOTIFY_SP_DC)
|
||||
|
||||
@staticmethod
|
||||
def _generate_totp(server_time_s: int, secret: str) -> str:
|
||||
"""Generate a 6-digit TOTP code compatible with Spotify's auth.
|
||||
|
||||
Uses HMAC-SHA1 with a 30-second period, matching the Go reference.
|
||||
"""
|
||||
counter = server_time_s // 30
|
||||
counter_bytes = struct.pack(">Q", counter)
|
||||
|
||||
mac = hmac.new(secret.encode(), counter_bytes, hashlib.sha1).digest()
|
||||
|
||||
offset = mac[-1] & 0x0F
|
||||
binary_code = (
|
||||
(mac[offset] & 0x7F) << 24
|
||||
| (mac[offset + 1] & 0xFF) << 16
|
||||
| (mac[offset + 2] & 0xFF) << 8
|
||||
| (mac[offset + 3] & 0xFF)
|
||||
)
|
||||
|
||||
code = binary_code % (10**6)
|
||||
return str(code).zfill(6)
|
||||
|
||||
def _load_cached_token(self) -> Optional[str]:
|
||||
"""Try to load a valid token from the persistent cache file."""
|
||||
try:
|
||||
with open(SPOTIFY_TOKEN_CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
expires_ms = data.get("accessTokenExpirationTimestampMs", 0)
|
||||
if expires_ms <= int(time.time() * 1000):
|
||||
logger.debug("Spotify: persisted token expired")
|
||||
return None
|
||||
token = data.get("accessToken", "")
|
||||
if not token:
|
||||
return None
|
||||
self._cached_token = token
|
||||
self._token_expires_at = expires_ms / 1000.0
|
||||
logger.debug("Spotify: loaded token from cache file")
|
||||
return token
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
def _save_token(self, body: dict) -> None:
|
||||
"""Persist the token response to disk."""
|
||||
try:
|
||||
with open(SPOTIFY_TOKEN_CACHE_FILE, "w") as f:
|
||||
json.dump(body, f)
|
||||
logger.debug("Spotify: token saved to cache file")
|
||||
except Exception as e:
|
||||
logger.warning(f"Spotify: failed to write token cache: {e}")
|
||||
return bool(track.trackid) and self.auth.is_configured()
|
||||
|
||||
@staticmethod
|
||||
def _format_lrc_line(start_ms: int, words: str) -> str:
|
||||
"""Format a single lyric line as LRC ``[mm:ss.cc]text``."""
|
||||
minutes = start_ms // 60000
|
||||
seconds = (start_ms // 1000) % 60
|
||||
centiseconds = round((start_ms % 1000) / 10.0)
|
||||
@@ -124,7 +48,6 @@ class SpotifyFetcher(BaseFetcher):
|
||||
|
||||
@staticmethod
|
||||
def _is_truly_synced(lines: list[dict]) -> bool:
|
||||
"""Check if lyrics are actually synced (not all timestamps zero)."""
|
||||
for line in lines:
|
||||
try:
|
||||
ms = int(line.get("startTimeMs", "0"))
|
||||
@@ -134,140 +57,6 @@ class SpotifyFetcher(BaseFetcher):
|
||||
continue
|
||||
return False
|
||||
|
||||
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
|
||||
try:
|
||||
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:
|
||||
logger.error(f"Spotify: unexpected server-time response: {data}")
|
||||
return None
|
||||
server_time = data["serverTime"]
|
||||
logger.debug(f"Spotify: server time = {server_time}")
|
||||
return server_time
|
||||
except Exception as e:
|
||||
logger.error(f"Spotify: failed to fetch server time: {e}")
|
||||
return None
|
||||
|
||||
async def _get_secret(self, client: httpx.AsyncClient) -> Optional[Tuple[str, int]]:
|
||||
if self._cached_secret is not None:
|
||||
logger.debug("Spotify: using cached TOTP secret")
|
||||
return self._cached_secret
|
||||
|
||||
try:
|
||||
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:
|
||||
logger.error(
|
||||
f"Spotify: unexpected secrets response (type={type(data).__name__}, len={len(data) if isinstance(data, list) else '?'})"
|
||||
)
|
||||
return None
|
||||
|
||||
last = data[-1]
|
||||
if "secret" not in last or "version" not in last:
|
||||
logger.error(f"Spotify: malformed secret entry: {list(last.keys())}")
|
||||
return None
|
||||
|
||||
secret_raw = last["secret"]
|
||||
version = last["version"]
|
||||
|
||||
parts = []
|
||||
for i, char in enumerate(secret_raw):
|
||||
parts.append(str(ord(char) ^ ((i % 33) + 9)))
|
||||
secret = "".join(parts)
|
||||
|
||||
logger.debug(f"Spotify: decoded secret v{version} (len={len(secret)})")
|
||||
self._cached_secret = (secret, version)
|
||||
return self._cached_secret
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Spotify: failed to fetch secret: {e}")
|
||||
return None
|
||||
|
||||
async def _get_token(self) -> Optional[str]:
|
||||
if self._cached_token and time.time() < self._token_expires_at - 30:
|
||||
logger.debug("Spotify: using in-memory cached token")
|
||||
return self._cached_token
|
||||
|
||||
disk_token = self._load_cached_token()
|
||||
if disk_token and time.time() < self._token_expires_at - 30:
|
||||
return disk_token
|
||||
|
||||
if not SPOTIFY_SP_DC:
|
||||
logger.error(
|
||||
"Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate with Spotify"
|
||||
)
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"User-Agent": UA_BROWSER,
|
||||
"Accept": "*/*",
|
||||
"Cookie": f"sp_dc={SPOTIFY_SP_DC}",
|
||||
**_SPOTIFY_BASE_HEADERS,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
server_time = await self._get_server_time(client)
|
||||
if server_time is None:
|
||||
return None
|
||||
|
||||
secret_data = await self._get_secret(client)
|
||||
if secret_data is None:
|
||||
return None
|
||||
|
||||
secret, version = secret_data
|
||||
totp = self._generate_totp(server_time, secret)
|
||||
logger.debug(f"Spotify: generated TOTP v{version}: {totp}")
|
||||
|
||||
params = {
|
||||
"reason": "init",
|
||||
"productType": "web-player",
|
||||
"totp": totp,
|
||||
"totpVer": str(version),
|
||||
"totpServer": totp,
|
||||
}
|
||||
|
||||
try:
|
||||
res = await client.get(
|
||||
SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT
|
||||
)
|
||||
if res.status_code != 200:
|
||||
logger.error(f"Spotify: token request returned {res.status_code}")
|
||||
return None
|
||||
|
||||
body = res.json()
|
||||
|
||||
if not isinstance(body, dict) or "accessToken" not in body:
|
||||
logger.error(
|
||||
f"Spotify: unexpected token response keys: {list(body.keys()) if isinstance(body, dict) else type(body).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
token = body["accessToken"]
|
||||
is_anonymous = body.get("isAnonymous", False)
|
||||
if is_anonymous:
|
||||
logger.warning(
|
||||
"Spotify: received anonymous token — SP_DC may be invalid"
|
||||
)
|
||||
|
||||
expires_ms = body.get("accessTokenExpirationTimestampMs", 0)
|
||||
if expires_ms and expires_ms > int(time.time() * 1000):
|
||||
self._token_expires_at = expires_ms / 1000.0
|
||||
else:
|
||||
logger.warning("Spotify: token expiry missing or invalid")
|
||||
self._token_expires_at = time.time() + 3600
|
||||
|
||||
self._cached_token = token
|
||||
self._save_token(body)
|
||||
logger.debug("Spotify: obtained access token")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Spotify: token request failed: {e}")
|
||||
return None
|
||||
|
||||
async def fetch(
|
||||
self, track: TrackMeta, bypass_cache: bool = False
|
||||
) -> Optional[LyricResult]:
|
||||
@@ -277,7 +66,7 @@ class SpotifyFetcher(BaseFetcher):
|
||||
|
||||
logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}")
|
||||
|
||||
token = await self._get_token()
|
||||
token = await self.auth.authenticate()
|
||||
if not token:
|
||||
logger.error("Spotify: cannot fetch lyrics without a token")
|
||||
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
|
||||
|
||||
Reference in New Issue
Block a user