mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(music): 完善歌词与专辑目录整理
This commit is contained in:
@@ -208,6 +208,13 @@ class TransHandler:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __is_music_lyrics_file(_fileitem: FileItem) -> bool:
|
||||
"""判断是否为音乐音轨的歌词附件。"""
|
||||
path = str(_fileitem.path or _fileitem.name or "").casefold()
|
||||
return mediainfo.type == MediaType.MUSIC and path.endswith(
|
||||
(".lrc", ".txt", ".lyricsfile.yaml")
|
||||
)
|
||||
|
||||
def __is_extra_file(_fileitem: FileItem) -> bool:
|
||||
"""
|
||||
判断是否为附加文件
|
||||
@@ -219,6 +226,8 @@ class TransHandler:
|
||||
extension = f".{_fileitem.extension.lower()}"
|
||||
if extension in settings.RMT_SUBEXT:
|
||||
return True
|
||||
if __is_music_lyrics_file(_fileitem):
|
||||
return True
|
||||
if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT:
|
||||
return True
|
||||
return False
|
||||
@@ -384,6 +393,11 @@ class TransHandler:
|
||||
|
||||
# 目的文件名
|
||||
if need_rename:
|
||||
file_extension = (
|
||||
".lyricsfile.yaml"
|
||||
if str(fileitem.path or "").casefold().endswith(".lyricsfile.yaml")
|
||||
else f".{fileitem.extension}"
|
||||
)
|
||||
new_file = self.get_rename_path(
|
||||
path=target_path,
|
||||
template_string=rename_format,
|
||||
@@ -391,7 +405,7 @@ class TransHandler:
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=f".{fileitem.extension}",
|
||||
file_ext=file_extension,
|
||||
),
|
||||
source_path=fileitem.path,
|
||||
source_item=fileitem,
|
||||
|
||||
@@ -3,30 +3,32 @@ 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.adapters.network.http import RequestUtils
|
||||
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.runtime.cache import cached
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
from app.adapters.network.http import RequestUtils
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
|
||||
|
||||
class LrclibModule(_ModuleBase):
|
||||
"""通过 LRCLIB 获取与单个音轨匹配的同步歌词或纯文本歌词。"""
|
||||
|
||||
_base_url = "https://lrclib.net"
|
||||
_source = "lrclib"
|
||||
_request_interval = 0.3
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
_cooldown_until = 0.0
|
||||
_match_pattern = re.compile(r"[^\w]+", flags=re.UNICODE)
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化无状态的 LRCLIB 歌词模块。"""
|
||||
"""配置变化后清理跨实例请求缓存和供应商冷却状态。"""
|
||||
self._request_json.cache_clear()
|
||||
type(self)._cooldown_until = 0.0
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""LRCLIB 无需密钥,是否请求由音乐歌词刮削策略控制。"""
|
||||
@@ -79,6 +81,7 @@ class LrclibModule(_ModuleBase):
|
||||
if duration:
|
||||
exact_params["duration"] = duration
|
||||
payload = self._request_json("/api/get", params=exact_params)
|
||||
match_score = 100
|
||||
if not payload:
|
||||
results = self._request_json(
|
||||
"/api/search",
|
||||
@@ -95,7 +98,16 @@ class LrclibModule(_ModuleBase):
|
||||
album=album,
|
||||
duration=duration,
|
||||
)
|
||||
return self._to_lyrics(payload)
|
||||
match_score = 90
|
||||
return self._to_lyrics(payload, match_score=match_score)
|
||||
|
||||
def music_lyrics_candidates(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
) -> list[MusicLyrics]:
|
||||
"""向通用歌词链返回候选列表,保留旧单结果接口兼容插件生态。"""
|
||||
lyrics = self.music_lyrics(music)
|
||||
return [lyrics] if lyrics else []
|
||||
|
||||
@classmethod
|
||||
def _select_result(
|
||||
@@ -152,14 +164,15 @@ class LrclibModule(_ModuleBase):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _to_lyrics(cls, payload: Any) -> Optional[MusicLyrics]:
|
||||
def _to_lyrics(cls, payload: Any, match_score: int = 90) -> 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
|
||||
lyricsfile = str(payload.get("lyricsfile") or "").strip() or None
|
||||
instrumental = bool(payload.get("instrumental"))
|
||||
if not instrumental and not plain_lyrics and not synced_lyrics:
|
||||
if not instrumental and not plain_lyrics and not synced_lyrics and not lyricsfile:
|
||||
return None
|
||||
return MusicLyrics(
|
||||
provider=cls._source,
|
||||
@@ -167,6 +180,9 @@ class LrclibModule(_ModuleBase):
|
||||
instrumental=instrumental,
|
||||
plain_lyrics=plain_lyrics,
|
||||
synced_lyrics=synced_lyrics,
|
||||
lyricsfile=lyricsfile,
|
||||
match_score=match_score,
|
||||
provider_priority=20,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -174,9 +190,12 @@ class LrclibModule(_ModuleBase):
|
||||
cls,
|
||||
path: str,
|
||||
params: Optional[dict[str, Any]],
|
||||
base_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""串行执行一次 LRCLIB 请求,确保批量专辑刮削遵守最小请求间隔。"""
|
||||
with cls._request_lock:
|
||||
if time.monotonic() < cls._cooldown_until:
|
||||
return None
|
||||
delay = cls._request_interval - (time.monotonic() - cls._last_request_at)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
@@ -187,7 +206,10 @@ class LrclibModule(_ModuleBase):
|
||||
},
|
||||
proxies=settings.PROXY,
|
||||
timeout=20,
|
||||
).get_res(f"{cls._base_url}{path}", params=params)
|
||||
).get_res(
|
||||
f"{(base_url or str(settings.LRCLIB_BASE_URL)).rstrip('/')}{path}",
|
||||
params=params,
|
||||
)
|
||||
cls._last_request_at = time.monotonic()
|
||||
return response
|
||||
|
||||
@@ -197,9 +219,10 @@ class LrclibModule(_ModuleBase):
|
||||
cls,
|
||||
path: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""请求 LRCLIB JSON 接口,缓存命中与未命中结果并按 Retry-After 重试一次。"""
|
||||
response = cls._request_once(path, params)
|
||||
response = cls._request_once(path, params, base_url)
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
@@ -208,8 +231,14 @@ class LrclibModule(_ModuleBase):
|
||||
if response.status_code in (429, 503):
|
||||
retry_after = cls._retry_after_seconds(response.headers.get("Retry-After"))
|
||||
response.close()
|
||||
max_wait = max(int(settings.LYRICS_PROVIDER_RETRY_MAX_WAIT), 0)
|
||||
if retry_after > max_wait:
|
||||
cls._cooldown_until = time.monotonic() + retry_after
|
||||
logger.warning(f"LRCLIB 进入冷却 {retry_after:g} 秒,跳过当前批次后续请求")
|
||||
response = None
|
||||
return None
|
||||
time.sleep(retry_after)
|
||||
response = cls._request_once(path, params)
|
||||
response = cls._request_once(path, params, base_url)
|
||||
if response is None:
|
||||
return None
|
||||
if response.status_code == 404:
|
||||
|
||||
@@ -12,4 +12,4 @@ priority = 5
|
||||
|
||||
[activation]
|
||||
policy = "bootstrap"
|
||||
watch = []
|
||||
watch = ["LRCLIB_BASE_URL"]
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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 RuntimeSettingsCompat
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
|
||||
|
||||
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(settings.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(settings.MUSIXMATCH_API_KEY or "").strip()
|
||||
if not api_key or time.monotonic() < self._cooldown_until:
|
||||
return None
|
||||
response = RequestUtils(
|
||||
ua=settings.USER_AGENT,
|
||||
proxies=settings.PROXY,
|
||||
timeout=20,
|
||||
).get_res(
|
||||
f"{str(settings.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
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version = 1
|
||||
id = "MusixmatchModule"
|
||||
kind = "host_module"
|
||||
entrypoint = "app.modules.musixmatch:MusixmatchModule"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "Musixmatch"
|
||||
type = "other"
|
||||
subtype = "Musixmatch"
|
||||
priority = 4
|
||||
|
||||
[activation]
|
||||
policy = "when_configured"
|
||||
watch = ["MUSIXMATCH_API_KEY", "MUSIXMATCH_BASE_URL"]
|
||||
|
||||
[activation.selector]
|
||||
kind = "setting_truthy"
|
||||
key = "MUSIXMATCH_API_KEY"
|
||||
Reference in New Issue
Block a user