Files
MoviePilot/app/modules/amll/module.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

288 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AMLL TTML 歌词搜索、录音匹配和下载模块。
"""
import json
import math
import threading
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Optional, cast
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.modules.amll.lyrics import parse_lyrics
from app.modules.amll.matching import (
lyric_id,
match_score,
normalize_isrc,
search_identity,
text_values,
)
from app.runtime.cache import Cache
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.types import ModuleType, OtherModulesType
class AmllModule(_ModuleBase):
"""
聚合 AMLL 原生 API 的可信 TTML 候选,由宿主刮削链统一保存。
"""
# 文本搜索仅检查首页,最多读取 20 条身份候选
_page_size = 20
# 一次搜索最多下载三个不同候选的完整歌词
_download_limit = 3
# 服务端 Retry-After 最多延长冷却 300 秒
_maximum_cooldown = 300.0
_cache_region = "app.modules.amll"
def __init__(self) -> None:
"""
建立实例级请求互斥和冷却状态,不在模块构造时请求网络。
"""
super().__init__()
self._request_lock = threading.Lock()
self._cooldown_until = 0.0
def init_module(self) -> None:
"""
来源配置变化时清理缓存与冷却,后续查询使用新的服务地址。
"""
with self._request_lock:
Cache(maxsize=1024, ttl=3600).clear(region=self._cache_region)
self._cooldown_until = 0.0
def init_setting(self) -> None:
"""
公共歌词服务无需密钥,请求由音乐歌词刮削策略控制。
"""
def stop(self) -> None:
"""
当前模块不持有后台任务或持久网络客户端。
"""
def test(self) -> tuple[bool, str]:
"""
用有界搜索请求检查原生歌词接口连通性。
"""
response = self._search(self._base_url(), "test", "", "")
return (True, "") if response is not None else (False, "AMLL TTML API 网络连接失败")
@staticmethod
def get_name() -> str:
"""
返回歌词来源展示名称。
"""
return "AMLL TTML"
@staticmethod
def get_type() -> ModuleType:
"""
返回歌词模块所属的杂项能力分类。
"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""
返回 AMLL 歌词来源的稳定模块标识。
"""
return OtherModulesType.Amll
@staticmethod
def get_priority() -> int:
"""
在候选聚合阶段按常规歌词来源顺序执行。
"""
return 6
@staticmethod
def _base_url() -> str:
"""
解析当前配置地址,空地址表示本次不调用该来源。
"""
return str(get_runtime_setting("AMLL_BASE_URL") or "").strip().rstrip("/")
def music_lyrics_candidates(self, music: MetaMusic | MusicInfo) -> list[MusicLyrics]:
"""
优先按 ISRC 查询,未获得有效歌词时回退严格的文本身份匹配。
文本搜索仅检查首页 20 条,去重后最多下载三个候选并再次核对身份。
请求在实例内串行执行,限流或服务繁忙时遵守有上限的冷却期。
:param music: 待查单曲的标题、艺术家、专辑和可选 ISRC
:return: 最多三个已验证歌词候选;无可用来源配置或无匹配时为空列表
"""
base_url = self._base_url()
if not base_url:
return []
isrc = normalize_isrc(music.isrc)
if isrc:
payload = self._lookup_isrc(base_url, isrc)
if isinstance(payload, dict) and isrc in {
normalize_isrc(value) for value in text_values(payload, "isrcs")
}:
lyrics = self._to_lyrics(music, payload, score=100)
if lyrics:
return [lyrics]
return self._search_candidates(music, base_url)
def _search_candidates(self, music: MetaMusic | MusicInfo, base_url: str) -> list[MusicLyrics]:
"""
搜索仅提供候选身份,详情下载后再次核对身份与请求 ID。
"""
title, artists, album = search_identity(music)
if not title or not artists:
return []
response = self._search(base_url, title, artists[0], album)
items = response.get("items") if isinstance(response, dict) else None
if not isinstance(items, list):
return []
candidates: list[MusicLyrics] = []
requested: set[str] = set()
for item in items[:self._page_size]:
if not isinstance(item, dict) or not match_score(music, item):
continue
identifier = lyric_id(item)
if not identifier or identifier in requested:
continue
requested.add(identifier)
payload = self._get(base_url, identifier)
if isinstance(payload, dict) and lyric_id(payload) == identifier:
score = match_score(music, payload)
lyrics = self._to_lyrics(music, payload, score=score) if score else None
if lyrics:
candidates.append(lyrics)
if len(requested) >= self._download_limit:
break
return candidates
@staticmethod
def _to_lyrics(
music: MetaMusic | MusicInfo, payload: dict[str, Any], score: int,
) -> Optional[MusicLyrics]:
"""
将确认身份后的 TTML 正文交给来源解析器,保留逐词时间轴。
"""
content = payload.get("lyrics")
identifier = lyric_id(payload)
if payload.get("format") != "ttml" or not identifier or not isinstance(content, str):
return None
title, artists, _album = search_identity(music)
titles = text_values(payload, "musicNames")
source_artists = text_values(payload, "artistNames")
return parse_lyrics(
content,
title=title or next(iter(titles), ""),
artist=" / ".join(artists or source_artists),
provider_id=identifier,
match_score=score,
)
def _search(
self, base_url: str, music_name: str, artist_name: str, album_name: str,
) -> Optional[dict[str, Any]]:
"""
短期缓存动态搜索,控制单页候选数量并避免宽泛正文查询。
"""
params = {"musicName": music_name, "page": 1, "pageSize": self._page_size}
if artist_name:
params["artistName"] = artist_name
if album_name:
params["albumName"] = album_name
return self._cached_json("/v1/lyrics/search", params, base_url, ttl=3600)
def _get(self, base_url: str, identifier: str) -> Optional[dict[str, Any]]:
"""
特定歌词 ID 内容固定,成功下载后使用独立的较长缓存。
"""
return self._cached_json("/v1/lyrics/get", {"id": identifier}, base_url, ttl=7 * 24 * 3600)
def _lookup_isrc(self, base_url: str, isrc: str) -> Optional[dict[str, Any]]:
"""
ISRC 对应的最新版歌词会变化,因此使用短期缓存。
"""
return self._cached_json("/v1/lyrics/get", {"isrc": isrc}, base_url, ttl=3600)
def _cached_json(
self, path: str, params: dict[str, Any], base_url: str, ttl: int,
) -> Optional[dict[str, Any]]:
"""
按来源地址隔离缓存,未命中短期保存,网络失败保持可立即重试。
"""
cache = Cache(maxsize=1024, ttl=3600)
key = json.dumps([base_url, path, params], ensure_ascii=False, sort_keys=True)
cached_value = cache.get(key, region=self._cache_region)
if isinstance(cached_value, dict):
return cast(dict[str, Any], cached_value)
result = self._request_json(path, params, base_url)
if result is not None:
has_result = bool(result.get("items")) if path.endswith("/search") else bool(result)
cache.set(key, result, ttl=ttl if has_result else 300, region=self._cache_region)
return result
def _request_json(
self, path: str, params: dict[str, Any], base_url: str,
) -> Optional[dict[str, Any]]:
"""
串行发起有限超时请求,限流期间跳过该来源并确保释放响应。
"""
with self._request_lock:
if time.monotonic() < self._cooldown_until:
return None
try:
response = RequestUtils(
headers={"Accept": "application/json", "User-Agent": get_runtime_setting("USER_AGENT")},
proxies=get_runtime_setting("PROXY"),
timeout=10,
).get_res(f"{base_url}{path}", params=params)
except (OSError, ValueError) as error:
logger.warning(f"AMLL TTML API 网络请求失败:{error}")
return None
if response is None:
return None
try:
if response.status_code == 404:
return {}
if response.status_code in (429, 503):
delay = self._retry_after_seconds(response.headers.get("Retry-After"))
self._cooldown_until = time.monotonic() + delay
logger.warning(f"AMLL TTML API 进入冷却 {delay:g} 秒")
return None
if response.status_code != 200:
logger.warning(f"AMLL TTML API 请求失败:HTTP {response.status_code}")
return None
payload = response.json()
if not isinstance(payload, dict) or payload.get("status") != 200:
return None
data = payload.get("data")
return cast(dict[str, Any], data) if isinstance(data, dict) else None
except (TypeError, ValueError) as error:
logger.warning(f"AMLL TTML API 响应解析失败:{error}")
return None
finally:
response.close()
@classmethod
def _retry_after_seconds(cls, value: str | None) -> float:
"""
解析秒数或 HTTP 日期,限制异常远期值并避免无限等待。
"""
try:
delay = float(value or "1")
except ValueError:
try:
target = parsedate_to_datetime(value or "")
delay = (target - datetime.now(timezone.utc)).total_seconds()
except (TypeError, ValueError, OverflowError):
delay = 1.0
if not math.isfinite(delay):
return 1.0
return min(max(delay, 1.0), cls._maximum_cooldown)