finish renaming
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Author: Uyanide pywang0608@foxmail.com
|
||||
Date: 2026-03-31 01:54:02
|
||||
Description: QQ Music fetcher via self-hosted API proxy
|
||||
"""
|
||||
|
||||
"""
|
||||
Requires a running qq-music-api instance.
|
||||
The base URL is read from the QQ_MUSIC_API_URL environment variable.
|
||||
|
||||
Search → pick best match by duration → fetch LRC lyrics.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from .base import BaseFetcher
|
||||
from ..models import TrackMeta, LyricResult, CacheStatus
|
||||
from ..lrc import detect_sync_status, normalize_tags
|
||||
from ..config import (
|
||||
HTTP_TIMEOUT,
|
||||
TTL_NOT_FOUND,
|
||||
TTL_NETWORK_ERROR,
|
||||
DURATION_TOLERANCE_MS,
|
||||
QQ_MUSIC_API_URL,
|
||||
)
|
||||
|
||||
|
||||
class QQMusicFetcher(BaseFetcher):
|
||||
@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)
|
||||
|
||||
def _search(self, track: TrackMeta, limit: int = 10) -> Optional[str]:
|
||||
"""Search QQ Music and return the best-matching song MID."""
|
||||
query = f"{track.artist or ''} {track.title or ''}".strip()
|
||||
if not query:
|
||||
return None
|
||||
|
||||
logger.debug(f"QQMusic: searching for '{query}' (limit={limit})")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(
|
||||
f"{QQ_MUSIC_API_URL}/api/search",
|
||||
params={"keyword": query, "type": "song", "num": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"QQMusic: search API error: {data}")
|
||||
return None
|
||||
|
||||
songs = data.get("data", {}).get("list", [])
|
||||
if not songs:
|
||||
logger.debug("QQMusic: search returned 0 results")
|
||||
return None
|
||||
|
||||
logger.debug(f"QQMusic: search returned {len(songs)} candidates")
|
||||
|
||||
# Duration-based best-match selection
|
||||
if track.length is not None:
|
||||
track_ms = track.length
|
||||
best_mid: Optional[str] = None
|
||||
best_diff = float("inf")
|
||||
|
||||
for song in songs:
|
||||
if not isinstance(song, dict):
|
||||
continue
|
||||
mid = song.get("mid")
|
||||
name = song.get("name", "?")
|
||||
# interval is in seconds
|
||||
interval = song.get("interval")
|
||||
if not isinstance(interval, int):
|
||||
logger.debug(
|
||||
f" candidate {mid} '{name}': no duration, skipped"
|
||||
)
|
||||
continue
|
||||
duration_ms = interval * 1000
|
||||
diff = abs(duration_ms - track_ms)
|
||||
logger.debug(
|
||||
f" candidate {mid} '{name}': "
|
||||
f"duration={duration_ms}ms, diff={diff}ms"
|
||||
)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best_mid = mid
|
||||
|
||||
if best_mid is not None and best_diff <= DURATION_TOLERANCE_MS:
|
||||
logger.debug(
|
||||
f"QQMusic: selected mid={best_mid} (diff={best_diff}ms)"
|
||||
)
|
||||
return best_mid
|
||||
|
||||
logger.debug(
|
||||
f"QQMusic: no candidate within {DURATION_TOLERANCE_MS}ms "
|
||||
f"(best diff={best_diff}ms)"
|
||||
)
|
||||
return None
|
||||
|
||||
# No duration info — take the first result
|
||||
first = songs[0]
|
||||
if not isinstance(first, dict) or "mid" not in first:
|
||||
logger.error("QQMusic: first search result has no 'mid'")
|
||||
return None
|
||||
logger.debug(
|
||||
f"QQMusic: no duration available, using first result "
|
||||
f"mid={first['mid']} '{first.get('name', '?')}'"
|
||||
)
|
||||
return first["mid"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"QQMusic: search failed: {e}")
|
||||
return None
|
||||
|
||||
def _get_lyric(self, mid: str) -> Optional[LyricResult]:
|
||||
"""Fetch lyrics for a given QQ Music song MID."""
|
||||
logger.debug(f"QQMusic: fetching lyrics for mid={mid}")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
||||
resp = client.get(
|
||||
f"{QQ_MUSIC_API_URL}/api/lyric",
|
||||
params={"mid": mid},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"QQMusic: lyric API error: {data}")
|
||||
return LyricResult(
|
||||
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
|
||||
)
|
||||
|
||||
lrc = data.get("data", {}).get("lyric", "")
|
||||
if not isinstance(lrc, str) or not lrc.strip():
|
||||
logger.debug(f"QQMusic: empty lyrics for mid={mid}")
|
||||
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
|
||||
|
||||
lrc = normalize_tags(lrc)
|
||||
status = detect_sync_status(lrc)
|
||||
logger.info(
|
||||
f"QQMusic: got {status.value} lyrics for mid={mid} "
|
||||
f"({len(lrc.splitlines())} lines)"
|
||||
)
|
||||
return LyricResult(
|
||||
status=status, lyrics=lrc.strip(), source=self.source_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"QQMusic: lyric fetch failed for mid={mid}: {e}")
|
||||
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
|
||||
|
||||
def fetch(
|
||||
self, track: TrackMeta, bypass_cache: bool = False
|
||||
) -> Optional[LyricResult]:
|
||||
"""Search for the track and fetch its lyrics."""
|
||||
if not QQ_MUSIC_API_URL:
|
||||
logger.debug("QQMusic: skipped — QQ_MUSIC_API_URL not configured")
|
||||
return None
|
||||
|
||||
query = f"{track.artist or ''} {track.title or ''}".strip()
|
||||
if not query:
|
||||
logger.debug("QQMusic: skipped — insufficient metadata")
|
||||
return None
|
||||
|
||||
logger.info(f"QQMusic: fetching lyrics for {track.display_name()}")
|
||||
mid = self._search(track)
|
||||
if not mid:
|
||||
logger.debug(f"QQMusic: no match found for {track.display_name()}")
|
||||
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
|
||||
|
||||
return self._get_lyric(mid)
|
||||
Reference in New Issue
Block a user