adding shazam, and LRClib support
This commit is contained in:
115
core/lyrics.py
Normal file
115
core/lyrics.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Lyrics lookup via LRCLIB (https://lrclib.net) — free, no API key.
|
||||
|
||||
Rate limit: LRCLIB allows ~90 req/min with a proper User-Agent,
|
||||
~45 without. We throttle to 0.4s between requests (150/min theoretical,
|
||||
well within limits) and always send a UA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
DEFAULT_USER_AGENT = "audio-transcriber/1.0"
|
||||
_API_BASE = "https://lrclib.net/api"
|
||||
|
||||
_throttle_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
_MIN_INTERVAL = 0.4 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class LyricsMatch:
|
||||
title: str
|
||||
artist: str
|
||||
lyrics: str
|
||||
duration: Optional[float] = None
|
||||
|
||||
|
||||
def _throttle() -> None:
|
||||
global _last_request_at
|
||||
with _throttle_lock:
|
||||
now = time.monotonic()
|
||||
wait = _last_request_at + _MIN_INTERVAL - now
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
_last_request_at = time.monotonic()
|
||||
|
||||
|
||||
def _get(url: str, params: dict, user_agent: str, timeout: float = 10.0) -> Optional[dict]:
|
||||
_throttle()
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params,
|
||||
headers={"User-Agent": user_agent},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def _make_match(item: dict) -> Optional[LyricsMatch]:
|
||||
lyrics = item.get("plainLyrics") or item.get("syncedLyrics")
|
||||
if not lyrics:
|
||||
return None
|
||||
return LyricsMatch(
|
||||
title=item.get("trackName", ""),
|
||||
artist=item.get("artistName", ""),
|
||||
lyrics=lyrics.strip(),
|
||||
duration=item.get("duration"),
|
||||
)
|
||||
|
||||
|
||||
def search_by_identity(
|
||||
title: Optional[str],
|
||||
artist: Optional[str],
|
||||
album: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: float = 10.0,
|
||||
) -> Optional[LyricsMatch]:
|
||||
"""Search LRCLIB by track metadata. Works even with messy tags."""
|
||||
if not title and not artist:
|
||||
return None
|
||||
|
||||
query = " ".join(filter(None, [artist or "", title or ""])).strip()
|
||||
if not query:
|
||||
return None
|
||||
|
||||
data = _get(f"{_API_BASE}/search", {"q": query}, user_agent, timeout)
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
|
||||
for item in data[:5]:
|
||||
match = _make_match(item)
|
||||
if match:
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
def search_by_text(
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: float = 10.0,
|
||||
) -> list[LyricsMatch]:
|
||||
"""Search LRCLIB by plain text (e.g. first words of recognized speech)."""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
data = _get(f"{_API_BASE}/search", {"q": query.strip()}, user_agent, timeout)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
matches: list[LyricsMatch] = []
|
||||
for item in data[:limit]:
|
||||
match = _make_match(item)
|
||||
if match:
|
||||
matches.append(match)
|
||||
return matches
|
||||
Reference in New Issue
Block a user