mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 14:06:50 +08:00
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
import time
|
|
from typing import Any, Optional, Tuple, Union
|
|
|
|
from app.adapters.network.http import RequestUtils
|
|
from app.domain.context import MusicInfo, MusicLyrics
|
|
from app.domain.meta.metamusic import MetaMusic
|
|
from app.modules import _ModuleBase
|
|
from app.runtime.log import logger
|
|
from app.runtime.settings import get_runtime_setting
|
|
from app.schemas.types import ModuleType, OtherModulesType
|
|
|
|
|
|
class MusixmatchModule(_ModuleBase):
|
|
"""使用用户授权的 Musixmatch 官方 API 获取同步或纯文本歌词。"""
|
|
|
|
_source = "musixmatch"
|
|
_cooldown_until = 0.0
|
|
|
|
def init_module(self) -> None:
|
|
"""初始化无持久状态的授权歌词模块。"""
|
|
|
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
|
"""仅在配置官方 API Key 后启用模块。"""
|
|
return "MUSIXMATCH_API_KEY", True
|
|
|
|
def stop(self) -> None:
|
|
"""停止模块;当前没有需要释放的资源。"""
|
|
|
|
def test(self) -> Tuple[bool, str]:
|
|
"""验证 API Key 和官方接口连通性。"""
|
|
if not str(get_runtime_setting('MUSIXMATCH_API_KEY') or "").strip():
|
|
return False, "Musixmatch API Key 未配置"
|
|
payload = self._request("matcher.lyrics.get", {"q_track": "test", "q_artist": "test"})
|
|
return (True, "") if payload is not None else (False, "Musixmatch API 连接或授权失败")
|
|
|
|
@staticmethod
|
|
def get_name() -> str:
|
|
"""返回模块展示名称。"""
|
|
return "Musixmatch"
|
|
|
|
@staticmethod
|
|
def get_type() -> ModuleType:
|
|
"""返回模块所属类型。"""
|
|
return ModuleType.Other
|
|
|
|
@staticmethod
|
|
def get_subtype() -> OtherModulesType:
|
|
"""返回 Musixmatch 模块子类型。"""
|
|
return OtherModulesType.Musixmatch
|
|
|
|
@staticmethod
|
|
def get_priority() -> int:
|
|
"""授权同步歌词优先于免费来源参与候选评分。"""
|
|
return 4
|
|
|
|
def music_lyrics_candidates(
|
|
self,
|
|
music: Union[MetaMusic, MusicInfo],
|
|
) -> list[MusicLyrics]:
|
|
"""按标题、艺术家和时长调用官方 matcher 接口。"""
|
|
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()
|
|
if not title or not artist:
|
|
return []
|
|
params: dict[str, Any] = {"q_track": title, "q_artist": artist}
|
|
duration = self._optional_int(getattr(music, "duration", None))
|
|
if duration:
|
|
params.update({
|
|
"f_subtitle_length": duration,
|
|
"f_subtitle_length_max_deviation": 2,
|
|
})
|
|
subtitle = self._response_item(self._request("matcher.subtitle.get", params), "subtitle")
|
|
if subtitle and not subtitle.get("restricted"):
|
|
body = str(subtitle.get("subtitle_body") or "").strip()
|
|
if body:
|
|
return [MusicLyrics(
|
|
provider=self._source,
|
|
provider_id=self._optional_text(subtitle.get("subtitle_id")),
|
|
synced_lyrics=body,
|
|
language=self._optional_text(subtitle.get("subtitle_language")),
|
|
match_score=95,
|
|
provider_priority=30,
|
|
)]
|
|
lyrics = self._response_item(
|
|
self._request("matcher.lyrics.get", {"q_track": title, "q_artist": artist}),
|
|
"lyrics",
|
|
)
|
|
if not lyrics or lyrics.get("restricted"):
|
|
return []
|
|
body = str(lyrics.get("lyrics_body") or "").strip()
|
|
instrumental = bool(lyrics.get("instrumental"))
|
|
if not body and not instrumental:
|
|
return []
|
|
return [MusicLyrics(
|
|
provider=self._source,
|
|
provider_id=self._optional_text(lyrics.get("lyrics_id")),
|
|
instrumental=instrumental,
|
|
plain_lyrics=body or None,
|
|
language=self._optional_text(lyrics.get("lyrics_language")),
|
|
match_score=92,
|
|
provider_priority=30,
|
|
)]
|
|
|
|
def _request(self, method: str, params: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
"""请求官方 API,并对限流或服务过载设置进程内冷却。"""
|
|
api_key = str(get_runtime_setting('MUSIXMATCH_API_KEY') or "").strip()
|
|
if not api_key or time.monotonic() < self._cooldown_until:
|
|
return None
|
|
response = RequestUtils(
|
|
ua=get_runtime_setting('USER_AGENT'),
|
|
proxies=get_runtime_setting('PROXY'),
|
|
timeout=20,
|
|
).get_res(
|
|
f"{str(get_runtime_setting('MUSIXMATCH_BASE_URL')).rstrip('/')}/{method}",
|
|
params={**params, "apikey": api_key},
|
|
)
|
|
if response is None:
|
|
return None
|
|
try:
|
|
if response.status_code in (429, 503):
|
|
retry_after = self._optional_int(response.headers.get("Retry-After")) or 60
|
|
type(self)._cooldown_until = time.monotonic() + retry_after
|
|
logger.warning(f"Musixmatch 进入冷却 {retry_after} 秒")
|
|
return None
|
|
if response.status_code != 200:
|
|
logger.warning(f"Musixmatch 请求失败:HTTP {response.status_code}")
|
|
return None
|
|
payload = response.json()
|
|
status = self._optional_int(
|
|
((payload.get("message") or {}).get("header") or {}).get("status_code")
|
|
) if isinstance(payload, dict) else None
|
|
if status != 200:
|
|
if status in (401, 402, 429):
|
|
logger.warning(f"Musixmatch API 拒绝请求:状态码 {status}")
|
|
return None
|
|
return payload
|
|
except (TypeError, ValueError) as err:
|
|
logger.warning(f"Musixmatch 响应解析失败:{err}")
|
|
return None
|
|
finally:
|
|
response.close()
|
|
|
|
@staticmethod
|
|
def _response_item(payload: Any, name: str) -> Optional[dict[str, Any]]:
|
|
"""从官方 message/body 包装中提取歌词或字幕对象。"""
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
item = (((payload.get("message") or {}).get("body") or {}).get(name))
|
|
return item if isinstance(item, dict) else None
|
|
|
|
@staticmethod
|
|
def _optional_int(value: Any) -> Optional[int]:
|
|
"""安全转换整数响应字段。"""
|
|
try:
|
|
return int(float(value)) if value not in (None, "") else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _optional_text(value: Any) -> Optional[str]:
|
|
"""安全转换非空文本字段。"""
|
|
text = str(value or "").strip()
|
|
return text or None
|