mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
feat(music): 完善歌词与专辑目录整理
This commit is contained in:
@@ -25,7 +25,7 @@ from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTrans
|
||||
from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData
|
||||
from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath
|
||||
from app.schemas.transfer import TransferJob as _SchemaTransferJob
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
|
||||
@@ -425,6 +425,16 @@ def _execute_manual_transfer(
|
||||
return _SchemaResponse(
|
||||
success=False, message=f"不支持的媒体类型:{type_name}"
|
||||
)
|
||||
|
||||
def _resolve_music_type(file_item: FileItem) -> Optional[str]:
|
||||
"""为未显式指定实体的旧客户端按源项类型补全音乐命名空间。"""
|
||||
if mtype != MediaType.MUSIC or transer_item.music_type:
|
||||
return transer_item.music_type
|
||||
return (
|
||||
MUSIC_ENTITY_ALBUM
|
||||
if file_item.type == "dir"
|
||||
else MUSIC_ENTITY_RECORDING
|
||||
)
|
||||
# 自定义格式
|
||||
epformat = None
|
||||
if (
|
||||
@@ -486,7 +496,7 @@ def _execute_manual_transfer(
|
||||
target_path=target_path,
|
||||
media_source=transer_item.media_source,
|
||||
media_id=transer_item.media_id,
|
||||
music_type=transer_item.music_type,
|
||||
music_type=_resolve_music_type(src_fileitem),
|
||||
mtype=mtype,
|
||||
season=transer_item.season,
|
||||
episode_group=transer_item.episode_group,
|
||||
@@ -570,7 +580,7 @@ def _execute_manual_transfer(
|
||||
target_path=target_path,
|
||||
media_source=transer_item.media_source,
|
||||
media_id=transer_item.media_id,
|
||||
music_type=transer_item.music_type,
|
||||
music_type=_resolve_music_type(src_fileitem),
|
||||
mtype=mtype,
|
||||
season=transer_item.season,
|
||||
episode_group=transer_item.episode_group,
|
||||
|
||||
@@ -4,10 +4,10 @@ from uuid import UUID
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import APIC
|
||||
from mutagen.id3 import APIC, SYLT, USLT
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
|
||||
from app.domain.context import MusicInfo
|
||||
from app.domain.context import MusicInfo, MusicLyrics
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
@@ -86,6 +86,83 @@ class AudioMetadataHelper:
|
||||
media_id=musicbrainz_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def read_lyrics(cls, path: Path) -> Optional[MusicLyrics]:
|
||||
"""读取常见音频容器中的逐行同步歌词、纯文本歌词和 Lyricsfile 标签。"""
|
||||
try:
|
||||
audio = MutagenFile(path, easy=False)
|
||||
except Exception as err:
|
||||
logger.warning(f"读取内嵌歌词失败:{path} - {err}")
|
||||
return None
|
||||
if not audio or not audio.tags:
|
||||
return None
|
||||
tags = audio.tags
|
||||
synced = None
|
||||
plain = None
|
||||
lyricsfile = None
|
||||
|
||||
getall = getattr(tags, "getall", None)
|
||||
if callable(getall):
|
||||
synced_frames = getall("SYLT")
|
||||
plain_frames = getall("USLT")
|
||||
synced = cls._sylt_to_lrc(synced_frames[0]) if synced_frames else None
|
||||
plain = str(plain_frames[0].text or "").strip() if plain_frames else None
|
||||
|
||||
normalized = {
|
||||
str(key).casefold(): value
|
||||
for key, value in getattr(tags, "items", lambda: [])()
|
||||
}
|
||||
synced = synced or cls._tag_text(
|
||||
normalized,
|
||||
"syncedlyrics",
|
||||
"synced lyrics",
|
||||
"lyrics_synced",
|
||||
)
|
||||
plain = plain or cls._tag_text(
|
||||
normalized,
|
||||
"lyrics",
|
||||
"unsyncedlyrics",
|
||||
"unsynced lyrics",
|
||||
"©lyr",
|
||||
"\xa9lyr",
|
||||
)
|
||||
lyricsfile = cls._tag_text(normalized, "lyricsfile", "lyricsfile.yaml")
|
||||
if not synced and not plain and not lyricsfile:
|
||||
return None
|
||||
return MusicLyrics(
|
||||
provider="embedded",
|
||||
plain_lyrics=plain,
|
||||
synced_lyrics=synced,
|
||||
lyricsfile=lyricsfile,
|
||||
match_score=100,
|
||||
provider_priority=100,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tag_text(tags: dict[str, Any], *keys: str) -> Optional[str]:
|
||||
"""从不同容器的单值或列表标签中提取首个非空文本。"""
|
||||
for key in keys:
|
||||
value = tags.get(key.casefold())
|
||||
if isinstance(value, (list, tuple)) and value:
|
||||
value = value[0]
|
||||
if isinstance(value, (USLT, SYLT)):
|
||||
value = getattr(value, "text", None)
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _sylt_to_lrc(frame: SYLT) -> Optional[str]:
|
||||
"""把 ID3 SYLT 的毫秒时间戳转换为通用 LRC 行。"""
|
||||
output = []
|
||||
for text, timestamp in frame.text or []:
|
||||
if not str(text or "").strip():
|
||||
continue
|
||||
minutes, remainder = divmod(max(int(timestamp), 0), 60000)
|
||||
output.append(f"[{minutes:02d}:{remainder / 1000:05.2f}]{str(text).strip()}")
|
||||
return "\n".join(output) or None
|
||||
|
||||
@staticmethod
|
||||
def read_filename(path: Path) -> MetaMusic:
|
||||
"""只从文件名和目录结构解析音乐元数据。"""
|
||||
|
||||
@@ -168,6 +168,7 @@ class ChainRuntimeConfig:
|
||||
data_cleanup_transfer_history_days: Any = 0
|
||||
data_cleanup_download_failure_days: Any = 0
|
||||
download_subtitle: bool = True
|
||||
lyrics_batch_timeout: int = 120
|
||||
music_metadata_to_simplified: bool = True
|
||||
recognize_plugin_first: bool = False
|
||||
ai_agent_enable: bool = False
|
||||
|
||||
@@ -141,6 +141,12 @@ class FileFilterMixin:
|
||||
return False
|
||||
return True if f".{fileitem.extension.lower()}" in self._audio_exts else False
|
||||
|
||||
@staticmethod
|
||||
def _is_music_lyrics_file(fileitem: FileItem) -> bool:
|
||||
"""判断文件是否为可随同名音乐音轨迁移的歌词旁挂文件。"""
|
||||
path = str(fileitem.path or fileitem.name or "").casefold()
|
||||
return path.endswith((".lrc", ".txt", ".lyricsfile.yaml"))
|
||||
|
||||
def _is_media_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
@@ -1068,6 +1074,11 @@ class FileKeyMixin:
|
||||
"""
|
||||
if self._is_subtitle_file(extra_fileitem):
|
||||
return self._get_subtitle_media_stem(extra_fileitem)
|
||||
if self._is_music_lyrics_file(extra_fileitem):
|
||||
file_name = extra_fileitem.name or Path(extra_fileitem.path).name
|
||||
lowered = file_name.casefold()
|
||||
suffix = ".lyricsfile.yaml" if lowered.endswith(".lyricsfile.yaml") else Path(file_name).suffix
|
||||
return file_name[:-len(suffix)].casefold() if suffix else lowered
|
||||
return self._get_file_stem(extra_fileitem)
|
||||
|
||||
def _get_related_main_file_key(
|
||||
@@ -1081,6 +1092,7 @@ class FileKeyMixin:
|
||||
if not (
|
||||
self._is_subtitle_file(extra_fileitem)
|
||||
or self._is_audio_file(extra_fileitem)
|
||||
or self._is_music_lyrics_file(extra_fileitem)
|
||||
):
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.domain.context import MusicInfo, MusicLyrics
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
|
||||
|
||||
class LrclibChain(ChainBase):
|
||||
"""LRCLIB 音乐歌词来源链。"""
|
||||
|
||||
def get_music_lyrics(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
) -> Optional[MusicLyrics]:
|
||||
"""按单曲元数据获取标准化歌词。"""
|
||||
result = self.run_module("music_lyrics", music=music)
|
||||
if isinstance(result, MusicLyrics):
|
||||
return result
|
||||
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
|
||||
|
||||
async def async_get_music_lyrics(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
) -> Optional[MusicLyrics]:
|
||||
"""异步按单曲元数据获取标准化歌词。"""
|
||||
result = await self.async_run_module("music_lyrics", music=music)
|
||||
if isinstance(result, MusicLyrics):
|
||||
return result
|
||||
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
|
||||
@@ -0,0 +1,128 @@
|
||||
import time
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.domain.context import MusicInfo, MusicLyrics
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
|
||||
|
||||
class LyricsChain(ChainBase):
|
||||
"""聚合本地、插件和宿主歌词候选,并按匹配度与内容质量择优。"""
|
||||
|
||||
def get_music_lyrics_candidates(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
local_candidates: Optional[list[MusicLyrics]] = None,
|
||||
) -> list[MusicLyrics]:
|
||||
"""获取所有标准化歌词候选,同时兼容旧版单结果插件接口。"""
|
||||
candidates = list(local_candidates or [])
|
||||
if self.deadline is not None and time.monotonic() >= self.deadline:
|
||||
self.budget_exceeded = True
|
||||
return self._deduplicate(candidates)
|
||||
legacy = self.run_module("music_lyrics", music=music)
|
||||
candidates.extend(self._normalize_results(legacy))
|
||||
results = self.run_module("music_lyrics_candidates", music=music)
|
||||
candidates.extend(self._normalize_results(results))
|
||||
|
||||
fallback = str(getattr(music, "lyrics", None) or "").strip()
|
||||
if fallback:
|
||||
candidates.append(MusicLyrics(
|
||||
provider="theaudiodb",
|
||||
plain_lyrics=fallback,
|
||||
match_score=85,
|
||||
provider_priority=10,
|
||||
))
|
||||
return self._deduplicate(candidates)
|
||||
|
||||
def get_music_lyrics(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
local_candidates: Optional[list[MusicLyrics]] = None,
|
||||
) -> Optional[MusicLyrics]:
|
||||
"""返回匹配可信且质量最高的歌词候选。"""
|
||||
candidates = self.get_music_lyrics_candidates(music, local_candidates)
|
||||
return self._select_best(candidates)
|
||||
|
||||
async def async_get_music_lyrics_candidates(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
local_candidates: Optional[list[MusicLyrics]] = None,
|
||||
) -> list[MusicLyrics]:
|
||||
"""异步聚合新旧模块候选,并复用同步路径的本地兜底规则。"""
|
||||
candidates = list(local_candidates or [])
|
||||
if self.deadline is not None and time.monotonic() >= self.deadline:
|
||||
self.budget_exceeded = True
|
||||
return self._deduplicate(candidates)
|
||||
legacy = await self.async_run_module("music_lyrics", music=music)
|
||||
candidates.extend(self._normalize_results(legacy))
|
||||
results = await self.async_run_module("music_lyrics_candidates", music=music)
|
||||
candidates.extend(self._normalize_results(results))
|
||||
fallback = str(getattr(music, "lyrics", None) or "").strip()
|
||||
if fallback:
|
||||
candidates.append(MusicLyrics(
|
||||
provider="theaudiodb",
|
||||
plain_lyrics=fallback,
|
||||
match_score=85,
|
||||
provider_priority=10,
|
||||
))
|
||||
return self._deduplicate(candidates)
|
||||
|
||||
async def async_get_music_lyrics(
|
||||
self,
|
||||
music: Union[MetaMusic, MusicInfo],
|
||||
local_candidates: Optional[list[MusicLyrics]] = None,
|
||||
) -> Optional[MusicLyrics]:
|
||||
"""异步返回质量最高的标准歌词候选。"""
|
||||
candidates = await self.async_get_music_lyrics_candidates(music, local_candidates)
|
||||
return self._select_best(candidates)
|
||||
|
||||
@staticmethod
|
||||
def _select_best(candidates: list[MusicLyrics]) -> Optional[MusicLyrics]:
|
||||
"""在可信候选中按内容质量、匹配度和来源优先级择优。"""
|
||||
if not candidates:
|
||||
return None
|
||||
return max(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
item.quality_rank,
|
||||
item.match_score,
|
||||
item.provider_priority,
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_results(cls, value: Any) -> list[MusicLyrics]:
|
||||
"""把模块的单结果、列表或字典结果统一转换为候选列表。"""
|
||||
values = value if isinstance(value, list) else [value]
|
||||
normalized = []
|
||||
for item in values:
|
||||
if isinstance(item, MusicLyrics):
|
||||
normalized.append(item)
|
||||
elif isinstance(item, dict):
|
||||
normalized.append(MusicLyrics.from_dict(item))
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _deduplicate(candidates: list[MusicLyrics]) -> list[MusicLyrics]:
|
||||
"""按来源身份和内容去重,重复项保留质量与匹配度更高的一条。"""
|
||||
selected: dict[tuple[str, str, str], MusicLyrics] = {}
|
||||
for candidate in candidates:
|
||||
if not candidate or not (candidate.content or candidate.instrumental):
|
||||
continue
|
||||
current = selected.get(candidate.identity_key)
|
||||
if current is None or (
|
||||
candidate.match_score,
|
||||
candidate.quality_rank,
|
||||
candidate.provider_priority,
|
||||
) > (
|
||||
current.match_score,
|
||||
current.quality_rank,
|
||||
current.provider_priority,
|
||||
):
|
||||
selected[candidate.identity_key] = candidate
|
||||
return list(selected.values())
|
||||
def __init__(self, deadline: Optional[float] = None) -> None:
|
||||
"""保存批次查询截止时间,防止单张专辑长期占用刮削任务。"""
|
||||
super().__init__()
|
||||
self.deadline = deadline
|
||||
self.budget_exceeded = False
|
||||
+95
-13
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -14,7 +15,7 @@ from app.application.configuration import (
|
||||
get_configured_system_config,
|
||||
)
|
||||
from app.chain import ChainBase
|
||||
from app.chain.lrclib import LrclibChain
|
||||
from app.chain.lyrics import LyricsChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.domain.context import (
|
||||
@@ -102,6 +103,11 @@ class ScrapingOption:
|
||||
"""是否覆盖模式"""
|
||||
return self.policy == ScrapingPolicy.OVERWRITE
|
||||
|
||||
@property
|
||||
def is_upgrade(self) -> bool:
|
||||
"""是否只在歌词等产物质量更高时替换。"""
|
||||
return self.policy == ScrapingPolicy.UPGRADE
|
||||
|
||||
class ScrapingConfig:
|
||||
"""媒体刮削配置"""
|
||||
|
||||
@@ -165,7 +171,9 @@ class ScrapingConfig:
|
||||
]
|
||||
for md in mds
|
||||
]
|
||||
return {item: ScrapingPolicy.MISSINGONLY for item in config_items}
|
||||
defaults = {item: ScrapingPolicy.MISSINGONLY for item in config_items}
|
||||
defaults["music_lyrics"] = ScrapingPolicy.UPGRADE
|
||||
return defaults
|
||||
|
||||
|
||||
class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
@@ -194,7 +202,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"landscape": ["thumb"],
|
||||
}
|
||||
|
||||
MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt")
|
||||
MUSIC_LYRICS_EXTENSIONS = (".lyricsfile.yaml", ".lrc", ".txt")
|
||||
_music_track_prefix_pattern = re.compile(
|
||||
r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+",
|
||||
flags=re.IGNORECASE,
|
||||
@@ -1045,7 +1053,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
with_cover = not poster_option.is_skip
|
||||
lyrics_chain = None
|
||||
if not lyrics_option.is_skip:
|
||||
lyrics_chain = LrclibChain()
|
||||
lyrics_chain = LyricsChain(
|
||||
deadline=time.monotonic() + max(self.runtime_config.lyrics_batch_timeout, 0)
|
||||
)
|
||||
cover_cache: dict[str, tuple[Optional[bytes], str]] = {}
|
||||
album_cache: dict[tuple[str, str], Optional[MusicAlbumInfo]] = {}
|
||||
|
||||
@@ -1054,6 +1064,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"saved": 0,
|
||||
"existing": 0,
|
||||
"missing": 0,
|
||||
"upgraded": 0,
|
||||
"protected": 0,
|
||||
"budget_exceeded": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
metadata_failure_label = (
|
||||
@@ -1109,11 +1122,15 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if not lyrics_option.is_skip:
|
||||
message += (
|
||||
f",歌词新增 {lyrics_counts['saved']} 首"
|
||||
f"、升级 {lyrics_counts['upgraded']} 首"
|
||||
f"、已存在 {lyrics_counts['existing']} 首"
|
||||
f"、防降级保护 {lyrics_counts['protected']} 首"
|
||||
f"、未匹配 {lyrics_counts['missing']} 首"
|
||||
)
|
||||
if lyrics_counts["failed"]:
|
||||
message += f"、失败 {lyrics_counts['failed']} 首"
|
||||
if lyrics_counts["budget_exceeded"]:
|
||||
message += f"、预算耗尽 {lyrics_counts['budget_exceeded']} 首"
|
||||
if failures:
|
||||
return False, f"{message};{';'.join(failures[:3])}"
|
||||
return True, message
|
||||
@@ -1240,7 +1257,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||||
lyrics_option: Optional[ScrapingOption] = None,
|
||||
lyrics_overwrite: bool = False,
|
||||
lyrics_chain: Optional[LrclibChain] = None,
|
||||
lyrics_chain: Optional[LyricsChain] = None,
|
||||
album_info: Optional[MusicAlbumInfo] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
) -> _MusicScrapeFileResult:
|
||||
@@ -1306,7 +1323,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover: Optional[tuple[Optional[bytes], str]],
|
||||
lyrics_option: Optional[ScrapingOption],
|
||||
lyrics_overwrite: bool,
|
||||
lyrics_chain: Optional[LrclibChain],
|
||||
lyrics_chain: Optional[LyricsChain],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
media_source: Optional[MediaSource],
|
||||
) -> _MusicScrapeFileResult:
|
||||
@@ -1495,14 +1512,14 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
scrape_info: Optional[MetaMusic | MusicInfo],
|
||||
lyrics_option: Optional[ScrapingOption],
|
||||
overwrite: bool,
|
||||
lyrics_chain: Optional[LrclibChain],
|
||||
lyrics_chain: Optional[LyricsChain],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
) -> str:
|
||||
"""按歌词策略查询单个音轨并保存同名旁挂歌词文件。"""
|
||||
if not lyrics_option or lyrics_option.is_skip or not lyrics_chain:
|
||||
return "disabled"
|
||||
existing = self._find_music_lyrics_sidecar(fileitem)
|
||||
if existing and not overwrite:
|
||||
if existing and not overwrite and not getattr(lyrics_option, "is_upgrade", False):
|
||||
return "existing"
|
||||
if not scrape_info:
|
||||
return "missing"
|
||||
@@ -1511,11 +1528,23 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if album_info:
|
||||
local_meta = AudioMetadataHelper.read(local_path)
|
||||
lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info
|
||||
lyrics = lyrics_chain.get_music_lyrics(lookup_info)
|
||||
embedded = AudioMetadataHelper.read_lyrics(local_path)
|
||||
lyrics = lyrics_chain.get_music_lyrics(
|
||||
lookup_info,
|
||||
local_candidates=[embedded] if embedded else None,
|
||||
)
|
||||
if lyrics_chain.budget_exceeded and not lyrics:
|
||||
return "budget_exceeded"
|
||||
if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension:
|
||||
return "missing"
|
||||
existing_quality = self._music_lyrics_sidecar_quality(existing)
|
||||
if existing_quality > lyrics.quality_rank:
|
||||
return "protected"
|
||||
if existing and existing_quality == lyrics.quality_rank and not overwrite:
|
||||
return "existing"
|
||||
status = "upgraded" if existing and lyrics.quality_rank > existing_quality else "saved"
|
||||
return (
|
||||
"saved"
|
||||
status
|
||||
if self._write_music_lyrics_sidecar(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
@@ -1532,14 +1561,34 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""查找音轨旁已存在的同步或纯文本歌词文件。"""
|
||||
audio_path = Path(fileitem.path)
|
||||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||||
target_path = self._music_lyrics_path(audio_path, extension)
|
||||
item = self.storagechain.get_file_item(
|
||||
storage=fileitem.storage,
|
||||
path=audio_path.with_suffix(extension),
|
||||
path=target_path,
|
||||
)
|
||||
if item:
|
||||
return item
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _music_lyrics_path(cls, audio_path: Path, extension: str) -> Path:
|
||||
"""构造普通歌词和双扩展名 Lyricsfile 的同名旁挂路径。"""
|
||||
return audio_path.with_suffix(extension)
|
||||
|
||||
@staticmethod
|
||||
def _music_lyrics_sidecar_quality(fileitem: Optional[_SchemaFileItem]) -> int:
|
||||
"""按旁挂扩展名估算质量,用于写入前执行防降级保护。"""
|
||||
if not fileitem:
|
||||
return 0
|
||||
path = str(fileitem.path or "").casefold()
|
||||
if path.endswith(".lyricsfile.yaml"):
|
||||
return 4
|
||||
if path.endswith(".lrc"):
|
||||
return 3
|
||||
if path.endswith(".txt"):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def _write_music_lyrics_sidecar(
|
||||
self,
|
||||
fileitem: _SchemaFileItem,
|
||||
@@ -1581,6 +1630,13 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
):
|
||||
return False
|
||||
|
||||
if lyrics.lyricsfile and not self._write_music_lyricsfile_sidecar(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
content=lyrics.lyricsfile,
|
||||
):
|
||||
return False
|
||||
|
||||
if overwrite:
|
||||
self._remove_alternate_music_lyrics(fileitem, keep_extension=extension)
|
||||
return True
|
||||
@@ -1591,6 +1647,31 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if temp_path and temp_path.exists() and temp_path != target_path:
|
||||
self._cleanup_temp_file(temp_path)
|
||||
|
||||
def _write_music_lyricsfile_sidecar(
|
||||
self,
|
||||
fileitem: _SchemaFileItem,
|
||||
local_path: Path,
|
||||
content: str,
|
||||
) -> bool:
|
||||
"""保留来源返回的标准 Lyricsfile,同时由主写入流程生成播放器兼容歌词。"""
|
||||
target_path = self._music_lyrics_path(Path(fileitem.path), ".lyricsfile.yaml")
|
||||
try:
|
||||
if fileitem.storage == "local":
|
||||
target_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
|
||||
return True
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
if not parent:
|
||||
return False
|
||||
temp_path = local_path.with_suffix(".lyricsfile.yaml")
|
||||
temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
|
||||
try:
|
||||
return bool(self.storagechain.upload_file(parent, temp_path, new_name=target_path.name))
|
||||
finally:
|
||||
self._cleanup_temp_file(temp_path)
|
||||
except OSError as err:
|
||||
logger.warning(f"保存 Lyricsfile 失败:{target_path} - {err}")
|
||||
return False
|
||||
|
||||
def _remove_alternate_music_lyrics(
|
||||
self,
|
||||
fileitem: _SchemaFileItem,
|
||||
@@ -1599,11 +1680,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。"""
|
||||
audio_path = Path(fileitem.path)
|
||||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||||
if extension == keep_extension:
|
||||
if extension in (keep_extension, ".lyricsfile.yaml"):
|
||||
continue
|
||||
target_path = self._music_lyrics_path(audio_path, extension)
|
||||
item = self.storagechain.get_file_item(
|
||||
storage=fileitem.storage,
|
||||
path=audio_path.with_suffix(extension),
|
||||
path=target_path,
|
||||
)
|
||||
if item and not self.storagechain.delete_file(item):
|
||||
logger.warning(f"删除旧歌词文件失败:{item.path}")
|
||||
|
||||
+36
-6
@@ -155,8 +155,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
self._subtitle_exts = self.runtime_config.subtitle_extensions
|
||||
# 音频文件后缀
|
||||
self._audio_exts = self.runtime_config.audio_extensions
|
||||
# 可处理的文件后缀(视频文件、字幕、音频文件)
|
||||
self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts
|
||||
# 可处理的文件后缀(视频文件、字幕、音频文件和音乐歌词)
|
||||
self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts + (
|
||||
".lrc", ".txt", ".yaml",
|
||||
)
|
||||
# 待整理任务队列
|
||||
self._queue = queue.Queue()
|
||||
# 文件整理线程
|
||||
@@ -1997,6 +1999,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
return False
|
||||
matched_template = True
|
||||
if batch_mtype == MediaType.MUSIC:
|
||||
if self._is_music_lyrics_file(item):
|
||||
return not self._is_blocked_by_exclude_words(item.path, exclude_words)
|
||||
if not self._is_media_file(item, batch_mtype):
|
||||
return False
|
||||
if not self._is_allow_filesize(item, min_filesize):
|
||||
@@ -2332,7 +2336,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
dir_key = self._get_file_parent_key(item)
|
||||
if not is_bluray_dir and self._is_media_file(item, batch_mtype):
|
||||
main_items_by_dir.setdefault(dir_key, []).append(item)
|
||||
elif self._is_subtitle_file(item) or self._is_audio_file(item):
|
||||
elif (
|
||||
self._is_subtitle_file(item)
|
||||
or self._is_audio_file(item)
|
||||
or self._is_music_lyrics_file(item)
|
||||
):
|
||||
extra_items_by_dir.setdefault(dir_key, []).append((item, is_bluray_dir))
|
||||
return main_items_by_dir, extra_items_by_dir
|
||||
|
||||
@@ -2358,7 +2366,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
if self._is_media_file(item, batch_mtype):
|
||||
main_fileitems.append(item)
|
||||
continue
|
||||
if not (self._is_subtitle_file(item) or self._is_audio_file(item)):
|
||||
if not (
|
||||
self._is_subtitle_file(item)
|
||||
or self._is_audio_file(item)
|
||||
or self._is_music_lyrics_file(item)
|
||||
):
|
||||
continue
|
||||
if not _is_allowed_transfer_item(item, False):
|
||||
continue
|
||||
@@ -2404,7 +2416,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
main_items = [(current_item, current_bluray_dir)]
|
||||
main_items_by_dir[current_dir_key] = [current_item]
|
||||
extra_items_by_dir[current_dir_key] = sibling_extra_items
|
||||
elif self._is_subtitle_file(current_item) or self._is_audio_file(current_item):
|
||||
elif (
|
||||
self._is_subtitle_file(current_item)
|
||||
or self._is_audio_file(current_item)
|
||||
or self._is_music_lyrics_file(current_item)
|
||||
):
|
||||
related_main_file_key = self._get_related_main_file_key(
|
||||
extra_fileitem=current_item,
|
||||
main_fileitems=sibling_main_items,
|
||||
@@ -2428,7 +2444,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
return list(items), inherited_map
|
||||
|
||||
if not main_items:
|
||||
return list(items), inherited_map
|
||||
remaining = [
|
||||
item
|
||||
for item in items
|
||||
if not (
|
||||
batch_mtype == MediaType.MUSIC
|
||||
and self._is_music_lyrics_file(item[0])
|
||||
)
|
||||
]
|
||||
return remaining, inherited_map
|
||||
|
||||
planned_items: List[Tuple[FileItem, bool]] = []
|
||||
seen_file_keys: set[Tuple[str, str]] = set()
|
||||
@@ -2516,6 +2540,12 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
inherited_map[self._get_file_key(extra_item)] = deepcopy(extra_meta)
|
||||
|
||||
for item, is_bluray_dir in items:
|
||||
if (
|
||||
batch_mtype == MediaType.MUSIC
|
||||
and self._is_music_lyrics_file(item)
|
||||
and self._get_file_key(item) not in inherited_map
|
||||
):
|
||||
continue
|
||||
_append_item(planned_items, seen_file_keys, item, is_bluray_dir)
|
||||
|
||||
return planned_items, inherited_map
|
||||
|
||||
+237
-1
@@ -3,6 +3,11 @@ from dataclasses import asdict, dataclass, field, fields
|
||||
from datetime import datetime
|
||||
from typing import Callable, List, Dict, Any, Tuple, Optional, Set, Union, Self
|
||||
|
||||
import yaml
|
||||
from yaml.constructor import ConstructorError
|
||||
from yaml.events import AliasEvent
|
||||
from yaml.nodes import MappingNode
|
||||
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.meta.metamusic import (
|
||||
@@ -30,6 +35,26 @@ ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]")
|
||||
_tmdb_image_url_builder: Callable[[str], Optional[str]] = lambda path: path
|
||||
|
||||
|
||||
class _LyricsfileSafeLoader(yaml.SafeLoader):
|
||||
"""在 SafeLoader 基础上拒绝 Lyricsfile 规范禁止的重复映射键。"""
|
||||
|
||||
def construct_mapping(self, node: MappingNode, deep: bool = False) -> dict:
|
||||
"""构造映射并在解析阶段拒绝重复键。"""
|
||||
if not isinstance(node, MappingNode):
|
||||
raise ConstructorError(None, None, "Lyricsfile 映射节点无效", node.start_mark)
|
||||
keys = set()
|
||||
for key_node, _value_node in node.value:
|
||||
key = self.construct_object(key_node, deep=deep)
|
||||
try:
|
||||
duplicated = key in keys
|
||||
except TypeError as err:
|
||||
raise ConstructorError(None, None, "Lyricsfile 映射键必须可哈希", key_node.start_mark) from err
|
||||
if duplicated:
|
||||
raise ConstructorError(None, None, f"Lyricsfile 存在重复键:{key}", key_node.start_mark)
|
||||
keys.add(key)
|
||||
return super().construct_mapping(node, deep=deep)
|
||||
|
||||
|
||||
def configure_tmdb_image_url_builder(
|
||||
builder: Callable[[str], Optional[str]],
|
||||
) -> None:
|
||||
@@ -99,13 +124,41 @@ def _music_init_values(model: type, data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@dataclass
|
||||
class MusicLyrics:
|
||||
"""标准化单曲歌词,区分同步歌词、纯文本歌词和纯音乐结果。"""
|
||||
"""标准化单曲歌词候选,保留来源匹配度和 Lyricsfile 原始内容。"""
|
||||
|
||||
provider: str
|
||||
provider_id: str | None = None
|
||||
instrumental: bool = False
|
||||
plain_lyrics: str | None = None
|
||||
synced_lyrics: str | None = None
|
||||
lyricsfile: str | None = None
|
||||
language: str | None = None
|
||||
match_score: int = 0
|
||||
provider_priority: int = 0
|
||||
|
||||
_lyricsfile_max_bytes = 1024 * 1024
|
||||
_lyricsfile_max_lines = 10000
|
||||
_lyricsfile_max_nodes = 50000
|
||||
_lyricsfile_max_depth = 20
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""补全 Lyricsfile 中可安全降级为 LRC 或纯文本的内容。"""
|
||||
if not self.lyricsfile:
|
||||
return
|
||||
parsed = self._parse_lyricsfile(self.lyricsfile)
|
||||
if not parsed:
|
||||
return
|
||||
metadata = parsed.get("metadata") if isinstance(parsed.get("metadata"), dict) else {}
|
||||
self.instrumental = self.instrumental or bool(
|
||||
metadata.get("instrumental", parsed.get("instrumental"))
|
||||
)
|
||||
self.language = self.language or self._optional_text(
|
||||
metadata.get("language", parsed.get("language"))
|
||||
)
|
||||
if not self.synced_lyrics:
|
||||
self.synced_lyrics = self._lyricsfile_to_lrc(parsed)
|
||||
if not self.plain_lyrics:
|
||||
self.plain_lyrics = self._lyricsfile_to_plain(parsed)
|
||||
|
||||
@property
|
||||
def content(self) -> str | None:
|
||||
@@ -121,6 +174,189 @@ class MusicLyrics:
|
||||
return ".txt"
|
||||
return None
|
||||
|
||||
@property
|
||||
def quality_rank(self) -> int:
|
||||
"""返回可比较的歌词质量等级,逐字同步高于逐行同步和纯文本。"""
|
||||
if self.lyricsfile and self._lyricsfile_has_words(self.lyricsfile):
|
||||
return 4
|
||||
if self.synced_lyrics:
|
||||
return 3
|
||||
if self.plain_lyrics:
|
||||
return 1
|
||||
if self.instrumental:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@property
|
||||
def identity_key(self) -> tuple[str, str, str]:
|
||||
"""构造候选去重键,兼容未提供来源 ID 的插件结果。"""
|
||||
return (
|
||||
self.provider.casefold(),
|
||||
self.provider_id or "",
|
||||
self.content or self.lyricsfile or "",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _parse_lyricsfile(cls, content: str) -> dict[str, Any] | None:
|
||||
"""安全解析受大小和行数约束的 Lyricsfile YAML,并拒绝锚点别名。"""
|
||||
if len(content.encode("utf-8")) > cls._lyricsfile_max_bytes:
|
||||
return None
|
||||
try:
|
||||
if any(isinstance(event, AliasEvent) for event in yaml.parse(content)):
|
||||
return None
|
||||
payload = yaml.load(content, Loader=_LyricsfileSafeLoader)
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("version") != "1.0" or not isinstance(payload.get("metadata"), dict):
|
||||
return None
|
||||
if not cls._lyricsfile_value_is_safe(payload):
|
||||
return None
|
||||
lines = payload.get("lines")
|
||||
if isinstance(lines, list) and len(lines) > cls._lyricsfile_max_lines:
|
||||
return None
|
||||
return payload if cls._lyricsfile_structure_is_valid(payload) else None
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_structure_is_valid(cls, payload: dict[str, Any]) -> bool:
|
||||
"""校验 Lyricsfile 1.0 的必需元数据、歌词形状和毫秒时间范围。"""
|
||||
metadata = payload.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
if not cls._optional_text(metadata.get("title")) or not cls._optional_text(metadata.get("artist")):
|
||||
return False
|
||||
lines = payload.get("lines")
|
||||
plain = payload.get("plain")
|
||||
if lines is not None and not isinstance(lines, list):
|
||||
return False
|
||||
if plain is not None and not isinstance(plain, str):
|
||||
return False
|
||||
if metadata.get("instrumental") is True:
|
||||
return not lines and not str(plain or "").strip()
|
||||
if not lines and not str(plain or "").strip():
|
||||
return False
|
||||
return all(cls._lyricsfile_line_is_valid(line) for line in lines or [])
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_line_is_valid(cls, line: Any) -> bool:
|
||||
"""校验单行与逐字时间戳,拒绝布尔值伪装整数和倒序区间。"""
|
||||
if not isinstance(line, dict) or not isinstance(line.get("text"), str):
|
||||
return False
|
||||
start = line.get("start_ms")
|
||||
end = line.get("end_ms")
|
||||
if isinstance(start, bool) or not isinstance(start, int) or start < 0:
|
||||
return False
|
||||
if end is not None and (
|
||||
isinstance(end, bool) or not isinstance(end, int) or end < start
|
||||
):
|
||||
return False
|
||||
words = line.get("words")
|
||||
if words is None:
|
||||
return True
|
||||
if not isinstance(words, list):
|
||||
return False
|
||||
for word in words:
|
||||
if not isinstance(word, dict) or not isinstance(word.get("text"), str):
|
||||
return False
|
||||
word_start = word.get("start_ms")
|
||||
word_end = word.get("end_ms")
|
||||
if isinstance(word_start, bool) or not isinstance(word_start, int) or word_start < 0:
|
||||
return False
|
||||
if word_end is not None and (
|
||||
isinstance(word_end, bool)
|
||||
or not isinstance(word_end, int)
|
||||
or word_end < word_start
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_value_is_safe(cls, value: Any, depth: int = 0, count: Optional[list[int]] = None) -> bool:
|
||||
"""限制 YAML 解析后的类型、深度和节点数,避免外部文档消耗过量资源。"""
|
||||
if depth > cls._lyricsfile_max_depth:
|
||||
return False
|
||||
counter = count if count is not None else [0]
|
||||
counter[0] += 1
|
||||
if counter[0] > cls._lyricsfile_max_nodes:
|
||||
return False
|
||||
if value is None or isinstance(value, (str, int, bool)):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return all(cls._lyricsfile_value_is_safe(item, depth + 1, counter) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(key, str)
|
||||
and cls._lyricsfile_value_is_safe(item, depth + 1, counter)
|
||||
for key, item in value.items()
|
||||
)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_has_words(cls, content: str) -> bool:
|
||||
"""判断 Lyricsfile 是否包含逐字时间轴。"""
|
||||
payload = cls._parse_lyricsfile(content)
|
||||
return bool(
|
||||
payload
|
||||
and any(
|
||||
isinstance(line, dict) and isinstance(line.get("words"), list)
|
||||
for line in payload.get("lines") or []
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
"""把可选标量规范化为非空文本。"""
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_to_plain(cls, payload: dict[str, Any]) -> str | None:
|
||||
"""从 Lyricsfile plain 或时间行生成播放器可读纯文本。"""
|
||||
plain = cls._optional_text(payload.get("plain"))
|
||||
if plain:
|
||||
return plain
|
||||
texts = []
|
||||
for line in payload.get("lines") or []:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
text = cls._optional_text(line.get("text"))
|
||||
if not text and isinstance(line.get("words"), list):
|
||||
text = "".join(
|
||||
str(word.get("text") or "")
|
||||
for word in line["words"]
|
||||
if isinstance(word, dict)
|
||||
).strip() or None
|
||||
if text:
|
||||
texts.append(text)
|
||||
return "\n".join(texts) or None
|
||||
|
||||
@classmethod
|
||||
def _lyricsfile_to_lrc(cls, payload: dict[str, Any]) -> str | None:
|
||||
"""把 Lyricsfile 行级毫秒时间轴转换为通用 LRC。"""
|
||||
output = []
|
||||
for line in payload.get("lines") or []:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
start = line.get("start_ms")
|
||||
try:
|
||||
start_ms = max(int(start), 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
text = cls._optional_text(line.get("text"))
|
||||
if not text and isinstance(line.get("words"), list):
|
||||
text = "".join(
|
||||
str(word.get("text") or "")
|
||||
for word in line["words"]
|
||||
if isinstance(word, dict)
|
||||
).strip() or None
|
||||
if not text:
|
||||
continue
|
||||
minutes, remainder = divmod(start_ms, 60000)
|
||||
seconds = remainder / 1000
|
||||
output.append(f"[{minutes:02d}:{seconds:05.2f}]{text}")
|
||||
return "\n".join(output) or None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Self:
|
||||
"""从模块或插件返回字典恢复标准歌词对象。"""
|
||||
|
||||
@@ -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"
|
||||
@@ -295,6 +295,16 @@ class ConfigModel(BaseModel):
|
||||
MUSIC_METADATA_TO_SIMPLIFIED: bool = True
|
||||
# TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖
|
||||
THEAUDIODB_API_KEY: str = "123"
|
||||
# LRCLIB 服务地址,可指向兼容官方 API 的自建实例
|
||||
LRCLIB_BASE_URL: str = "https://lrclib.net"
|
||||
# Musixmatch 官方 API Key;留空时不加载该歌词来源
|
||||
MUSIXMATCH_API_KEY: str = ""
|
||||
# Musixmatch 官方或授权代理 API 根地址
|
||||
MUSIXMATCH_BASE_URL: str = "https://api.musixmatch.com/ws/1.1"
|
||||
# 单次音乐刮削批次用于在线歌词查询的总预算(秒)
|
||||
LYRICS_BATCH_TIMEOUT: int = 120
|
||||
# 供应商要求的重试等待超过该值时进入冷却,不阻塞整个批次
|
||||
LYRICS_PROVIDER_RETRY_MAX_WAIT: int = 5
|
||||
|
||||
# ==================== TVDB配置 ====================
|
||||
# TVDB API Key
|
||||
|
||||
@@ -195,6 +195,7 @@ _METHOD_CONTRACTS = {
|
||||
"music_discover": ModuleMethodContract(family="music", input_contract="MusicDiscoverRequest", result_contract="list[MusicInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("media_source", "page", "count", "entity", "mode", "tags", "sort")),
|
||||
"music_fresh_releases": ModuleMethodContract(family="music", input_contract="MusicFreshReleasesRequest", result_contract="list[MusicInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("days", "sort", "past", "future", "offset", "count")),
|
||||
"music_lyrics": ModuleMethodContract(family="music", input_contract="MusicLyricsRequest", result_contract="MusicLyrics | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("music",)),
|
||||
"music_lyrics_candidates": ModuleMethodContract(family="music", input_contract="MusicLyricsRequest", result_contract="list[MusicLyrics]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("music",), plugin_short_circuit=False),
|
||||
"search_music": ModuleMethodContract(family="music", input_contract="MusicSearchRequest", result_contract="list[MusicInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("meta", "limit", "media_source")),
|
||||
"channel_manage": ModuleMethodContract(family="messaging", input_contract="ChannelManageRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "action")),
|
||||
"delete_message": ModuleMethodContract(family="messaging", input_contract="MessageDeleteRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "source", "message_id", "chat_id")),
|
||||
|
||||
@@ -68,6 +68,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
|
||||
"listenbrainz",
|
||||
"lrclib",
|
||||
"musicbrainz",
|
||||
"musixmatch",
|
||||
"navidrome",
|
||||
"plex",
|
||||
"postgresql",
|
||||
|
||||
@@ -668,6 +668,8 @@ class OtherModulesType(Enum):
|
||||
ListenBrainz = "ListenBrainz"
|
||||
# LRCLIB 歌词
|
||||
Lrclib = "LRCLIB"
|
||||
# Musixmatch 授权歌词
|
||||
Musixmatch = "Musixmatch"
|
||||
# AcoustID 音频指纹
|
||||
AcoustId = "AcoustID"
|
||||
|
||||
@@ -689,6 +691,7 @@ class ScrapingPolicy(NameValueEnum):
|
||||
MISSINGONLY = "仅缺失"
|
||||
SKIP = "跳过"
|
||||
OVERWRITE = "覆盖"
|
||||
UPGRADE = "质量升级"
|
||||
|
||||
|
||||
# 刮削目标类型
|
||||
|
||||
@@ -103,6 +103,7 @@ def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig:
|
||||
video_extensions=tuple(settings.RMT_MEDIAEXT),
|
||||
subtitle_extensions=tuple(settings.RMT_SUBEXT),
|
||||
audio_extensions=tuple(settings.RMT_AUDIOEXT),
|
||||
lyrics_batch_timeout=settings.LYRICS_BATCH_TIMEOUT,
|
||||
temporary_path=settings.TEMP_PATH,
|
||||
root_path=settings.ROOT_PATH,
|
||||
config_path=settings.CONFIG_PATH,
|
||||
|
||||
Reference in New Issue
Block a user