mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 18:24:42 +08:00
235 lines
8.9 KiB
Python
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
|