feat(music): 完善歌词与专辑目录整理

This commit is contained in:
jxxghp
2026-08-25 21:02:26 +08:00
parent f28e8b1538
commit 574c50586e
31 changed files with 1228 additions and 105 deletions
+15 -1
View File
@@ -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,
+43 -14
View File
@@ -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:
+1 -1
View File
@@ -12,4 +12,4 @@ priority = 5
[activation]
policy = "bootstrap"
watch = []
watch = ["LRCLIB_BASE_URL"]
+166
View File
@@ -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
+19
View File
@@ -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"