Files
MoviePilot/app/modules/lrclib/__init__.py

235 lines
8.9 KiB
Python

import re
import threading
import time
from typing import Any, Optional, Tuple, Union
from app.core.cache import cached
from app.core.config import settings
from app.core.context import MusicInfo, MusicLyrics
from app.core.meta import MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
from app.utils.http import RequestUtils
class LrclibModule(_ModuleBase):
"""通过 LRCLIB 获取与单个音轨匹配的同步歌词或纯文本歌词。"""
_base_url = "https://lrclib.net"
_source = "lrclib"
_request_interval = 0.3
_request_lock = threading.Lock()
_last_request_at = 0.0
_match_pattern = re.compile(r"[^\w]+", flags=re.UNICODE)
def init_module(self) -> None:
"""初始化无状态的 LRCLIB 歌词模块。"""
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""LRCLIB 无需密钥,是否请求由音乐歌词刮削策略控制。"""
return None
def stop(self) -> None:
"""停止模块;当前实现没有需要释放的持久资源。"""
def test(self) -> Tuple[bool, str]:
"""测试 LRCLIB 搜索接口连通性。"""
result = self._request_json("/api/search", params={"track_name": "test"})
return (True, "") if result is not None else (False, "LRCLIB 网络连接失败")
@staticmethod
def get_name() -> str:
"""返回模块展示名称。"""
return "LRCLIB"
@staticmethod
def get_type() -> ModuleType:
"""返回模块所属的其它能力类型。"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""返回 LRCLIB 模块子类型。"""
return OtherModulesType.Lrclib
@staticmethod
def get_priority() -> int:
"""返回歌词模块执行优先级。"""
return 5
def music_lyrics(self, music: Union[MetaMusic, MusicInfo]) -> Optional[MusicLyrics]:
"""按标题、艺术家、专辑和时长查询单曲歌词,并对搜索回退结果严格匹配。"""
title = str(getattr(music, "title", None) or "").strip()
artists = list(getattr(music, "artists", None) or [])
artist = str((artists[0] if artists else getattr(music, "album_artist", None)) or "").strip()
album = str(getattr(music, "album", None) or "").strip()
duration = self._optional_int(getattr(music, "duration", None))
if not title or not artist:
return None
exact_params: dict[str, Any] = {
"track_name": title,
"artist_name": artist,
}
if album:
exact_params["album_name"] = album
if duration:
exact_params["duration"] = duration
payload = self._request_json("/api/get", params=exact_params)
if not payload:
results = self._request_json(
"/api/search",
params={
"track_name": title,
"artist_name": artist,
**({"album_name": album} if album else {}),
},
)
payload = self._select_result(
results if isinstance(results, list) else [],
title=title,
artist=artist,
album=album,
duration=duration,
)
return self._to_lyrics(payload)
@classmethod
def _select_result(
cls,
results: list[dict[str, Any]],
title: str,
artist: str,
album: str,
duration: Optional[int],
) -> Optional[dict[str, Any]]:
"""从模糊搜索结果中选择标题和艺术家一致且时长可信的歌词。"""
expected_title = cls._normalize_text(title)
expected_artist = cls._normalize_text(artist)
expected_album = cls._normalize_text(album)
ranked: list[tuple[int, dict[str, Any]]] = []
for item in results:
if cls._normalize_text(item.get("trackName")) != expected_title:
continue
candidate_artist = cls._normalize_text(item.get("artistName"))
if not cls._compatible_text(expected_artist, candidate_artist):
continue
candidate_duration = cls._optional_int(item.get("duration"))
if duration and candidate_duration and abs(duration - candidate_duration) > 2:
continue
score = 4
if candidate_artist == expected_artist:
score += 3
if expected_album and cls._normalize_text(item.get("albumName")) == expected_album:
score += 2
if duration and candidate_duration and abs(duration - candidate_duration) <= 2:
score += 3
ranked.append((score, item))
if not ranked:
return None
ranked.sort(key=lambda pair: pair[0], reverse=True)
return ranked[0][1]
@classmethod
def _normalize_text(cls, value: Any) -> str:
"""移除大小写、标点和空白差异,生成歌词匹配文本。"""
return cls._match_pattern.sub("", str(value or "").casefold())
@staticmethod
def _compatible_text(expected: str, candidate: str) -> bool:
"""允许合作艺人字符串互相包含,同时拒绝完全无关的艺术家。"""
return bool(expected and candidate and (expected in candidate or candidate in expected))
@staticmethod
def _optional_int(value: Any) -> Optional[int]:
"""把歌词源返回的时长安全转换为整数秒。"""
try:
return round(float(value)) if value not in (None, "") else None
except (TypeError, ValueError):
return None
@classmethod
def _to_lyrics(cls, payload: Any) -> Optional[MusicLyrics]:
"""把 LRCLIB 响应转换为标准歌词对象。"""
if not isinstance(payload, dict) or payload.get("id") is None:
return None
plain_lyrics = str(payload.get("plainLyrics") or "").strip() or None
synced_lyrics = str(payload.get("syncedLyrics") or "").strip() or None
instrumental = bool(payload.get("instrumental"))
if not instrumental and not plain_lyrics and not synced_lyrics:
return None
return MusicLyrics(
provider=cls._source,
provider_id=str(payload["id"]),
instrumental=instrumental,
plain_lyrics=plain_lyrics,
synced_lyrics=synced_lyrics,
)
@classmethod
def _request_once(
cls,
path: str,
params: Optional[dict[str, Any]],
) -> Any:
"""串行执行一次 LRCLIB 请求,确保批量专辑刮削遵守最小请求间隔。"""
with cls._request_lock:
delay = cls._request_interval - (time.monotonic() - cls._last_request_at)
if delay > 0:
time.sleep(delay)
response = RequestUtils(
headers={
"User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)",
"Accept": "application/json",
},
proxies=settings.PROXY,
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
cls._last_request_at = time.monotonic()
return response
@classmethod
@cached(maxsize=1024, ttl=7 * 24 * 60 * 60, skip_none=True)
def _request_json(
cls,
path: str,
params: Optional[dict[str, Any]] = None,
) -> Any:
"""请求 LRCLIB JSON 接口,缓存命中与未命中结果并按 Retry-After 重试一次。"""
response = cls._request_once(path, params)
if response is None:
return None
try:
if response.status_code == 404:
return {}
if response.status_code in (429, 503):
retry_after = cls._retry_after_seconds(response.headers.get("Retry-After"))
response.close()
time.sleep(retry_after)
response = cls._request_once(path, params)
if response is None:
return None
if response.status_code == 404:
return {}
if response.status_code != 200:
logger.warning(
f"LRCLIB 请求失败:{response.status_code} {response.text[:200]}"
)
return None
return response.json()
except (TypeError, ValueError) as err:
logger.warning(f"LRCLIB 响应解析失败:{err}")
return None
finally:
if response is not None:
response.close()
@staticmethod
def _retry_after_seconds(value: Any) -> float:
"""解析 LRCLIB 限流等待秒数,异常值回退到一秒。"""
try:
return max(float(value), 0.0)
except (TypeError, ValueError):
return 1.0