import re import threading import time from typing import Any, Optional, Tuple, Union from app.runtime.cache import cached from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() from app.domain.context import MusicInfo, MusicLyrics from app.domain.meta.metamusic import MetaMusic from app.runtime.log import logger from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType from app.adapters.network.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