""" Author: Uyanide pywang0608@foxmail.com Date: 2026-03-25 10:43:21 Description: Spotify fetcher — obtains synced lyrics via Spotify's internal color-lyrics API. """ import httpx from typing import Optional from loguru import logger from .base import BaseFetcher 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 = "https://spclient.wg.spotify.com/color-lyrics/v2/track/" class SpotifyFetcher(BaseFetcher): 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 self.auth.is_configured() @staticmethod def _format_lrc_line(start_ms: int, words: str) -> str: minutes = start_ms // 60000 seconds = (start_ms // 1000) % 60 centiseconds = round((start_ms % 1000) / 10.0) return f"[{minutes:02d}:{seconds:02d}.{centiseconds:02.0f}]{words}" @staticmethod def _is_truly_synced(lines: list[dict]) -> bool: for line in lines: try: ms = int(line.get("startTimeMs", "0")) if ms > 0: return True except (ValueError, TypeError): continue return False async def fetch( self, track: TrackMeta, bypass_cache: bool = False ) -> Optional[LyricResult]: if not track.trackid: logger.debug("Spotify: skipped — no trackid in metadata") return None logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}") 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) url = f"{_SPOTIFY_LYRICS_URL}{track.trackid}?format=json&vocalRemoval=false&market=from_token" headers = { "Accept": "application/json", "Authorization": f"Bearer {token}", **SPOTIFY_BASE_HEADERS, } try: async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: res = await client.get(url, headers=headers) if res.status_code == 404: logger.debug(f"Spotify: 404 for trackid={track.trackid}") return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) if res.status_code != 200: logger.error(f"Spotify: lyrics API returned {res.status_code}") return LyricResult( status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR ) data = res.json() if not isinstance(data, dict) or "lyrics" not in data: logger.error("Spotify: unexpected lyrics response structure") return LyricResult( status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR ) lyrics_data = data["lyrics"] sync_type = lyrics_data.get("syncType", "") lines = lyrics_data.get("lines", []) if not isinstance(lines, list) or len(lines) == 0: logger.debug("Spotify: response contained no lyric lines") return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) is_synced = sync_type == "LINE_SYNCED" and self._is_truly_synced(lines) lrc_lines: list[str] = [] for line in lines: words = line.get("words", "") if not isinstance(words, str): continue try: ms = int(line.get("startTimeMs", "0")) except (ValueError, TypeError): ms = 0 if is_synced: lrc_lines.append(self._format_lrc_line(ms, words)) else: lrc_lines.append(f"[00:00.00]{words}") content = LRCData("\n".join(lrc_lines)) status = ( CacheStatus.SUCCESS_SYNCED if is_synced else CacheStatus.SUCCESS_UNSYNCED ) logger.info(f"Spotify: got {status.value} lyrics ({len(lrc_lines)} lines)") return LyricResult(status=status, lyrics=content, source=self.source_name) except Exception as e: logger.error(f"Spotify: lyrics fetch failed: {e}") return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)