diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index d154bf6ab..1611e8b57 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -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, diff --git a/app/application/audio.py b/app/application/audio.py index 49cb0384f..59aa7874e 100644 --- a/app/application/audio.py +++ b/app/application/audio.py @@ -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: """只从文件名和目录结构解析音乐元数据。""" diff --git a/app/application/configuration.py b/app/application/configuration.py index 7db7c5861..b1d9a46b0 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -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 diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index e18c0b1f4..7b2afb5e9 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -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 diff --git a/app/chain/lrclib.py b/app/chain/lrclib.py deleted file mode 100644 index 60bd8c24b..000000000 --- a/app/chain/lrclib.py +++ /dev/null @@ -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 diff --git a/app/chain/lyrics.py b/app/chain/lyrics.py new file mode 100644 index 000000000..f5a129a40 --- /dev/null +++ b/app/chain/lyrics.py @@ -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 diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 6ad0abff5..24242b2b4 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -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}") diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 6529ce0ce..f234569ff 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -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 diff --git a/app/domain/context.py b/app/domain/context.py index d78d87b5d..cf10576db 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -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: """从模块或插件返回字典恢复标准歌词对象。""" diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 2f7f5ff8d..f58a2d5f8 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -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, diff --git a/app/modules/lrclib/__init__.py b/app/modules/lrclib/__init__.py index 44dc6c7f7..2eb1ded58 100644 --- a/app/modules/lrclib/__init__.py +++ b/app/modules/lrclib/__init__.py @@ -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: diff --git a/app/modules/lrclib/capability.toml b/app/modules/lrclib/capability.toml index 23ad94652..ff0a843e7 100644 --- a/app/modules/lrclib/capability.toml +++ b/app/modules/lrclib/capability.toml @@ -12,4 +12,4 @@ priority = 5 [activation] policy = "bootstrap" -watch = [] +watch = ["LRCLIB_BASE_URL"] diff --git a/app/modules/musixmatch/__init__.py b/app/modules/musixmatch/__init__.py new file mode 100644 index 000000000..ca5d2bca5 --- /dev/null +++ b/app/modules/musixmatch/__init__.py @@ -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 diff --git a/app/modules/musixmatch/capability.toml b/app/modules/musixmatch/capability.toml new file mode 100644 index 000000000..c1b7929e7 --- /dev/null +++ b/app/modules/musixmatch/capability.toml @@ -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" diff --git a/app/runtime/config.py b/app/runtime/config.py index 1f53a18af..9ba21cb7f 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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 diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index bbb24a571..8b759abab 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -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")), diff --git a/app/runtime/extensions/module/quality.py b/app/runtime/extensions/module/quality.py index 324685ecc..c7d670c2d 100644 --- a/app/runtime/extensions/module/quality.py +++ b/app/runtime/extensions/module/quality.py @@ -68,6 +68,7 @@ BASELINE_ASSESSED_MODULES = frozenset( "listenbrainz", "lrclib", "musicbrainz", + "musixmatch", "navidrome", "plex", "postgresql", diff --git a/app/schemas/types.py b/app/schemas/types.py index 585366b7b..96ba5497b 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -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 = "质量升级" # 刮削目标类型 diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 1e9335cb6..f4dd1adae 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -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, diff --git a/docs/cli.md b/docs/cli.md index 293c5d88d..9e4da50a4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -450,6 +450,10 @@ moviepilot config set PORT 3001 moviepilot config set NGINX_PORT 3000 moviepilot config set API_TOKEN your-token-here moviepilot config set ACOUSTID_API_KEY your-acoustid-client-key +moviepilot config set LRCLIB_BASE_URL https://lrclib.net +moviepilot config set LYRICS_BATCH_TIMEOUT 120 +moviepilot config set LYRICS_PROVIDER_RETRY_MAX_WAIT 5 +moviepilot config set MUSIXMATCH_API_KEY your-authorized-api-key moviepilot config set MUSIC_METADATA_TO_SIMPLIFIED true ``` @@ -469,6 +473,8 @@ moviepilot config describe API_TOKEN --show-secrets - `config list` 显示当前配置值 - `config keys` 显示配置项名称、类型和默认值 - `ACOUSTID_API_KEY` 内置可用默认值,也可在前端“高级设置 - 媒体”或配置命令中覆盖;本地安装需要系统可执行路径中存在 Chromaprint `fpcalc`,官方 Docker 镜像已内置 +- `LRCLIB_BASE_URL` 默认使用官方实例,也可指向兼容 LRCLIB API 的自建实例;`LYRICS_BATCH_TIMEOUT` 限制单次专辑刮削的在线歌词总预算,`LYRICS_PROVIDER_RETRY_MAX_WAIT` 决定长 `Retry-After` 进入来源冷却而非阻塞批次 +- `THEAUDIODB_API_KEY` 用于音乐元数据及纯文本歌词兜底,默认 `123` 为官方公开 V1 Key;`MUSIXMATCH_API_KEY` 留空时不加载 Musixmatch,配置后只调用官方或 `MUSIXMATCH_BASE_URL` 指定的授权代理,使用者必须遵守对应账户的歌词存储和展示授权 - `MUSIC_METADATA_TO_SIMPLIFIED` 默认开启;开启后会将识别结果中的曲名、艺术家、专辑和分类等标准音乐元数据转换为简体中文,不转换歌词与来源原始响应 - `config describe` 显示单个配置项的类型、默认值和当前值 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index bbce32ebe..783775bbb 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -157,7 +157,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 | | POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | -| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | #### 站点 @@ -217,7 +217,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page`、`count` | | GET | `/api/v1/recommend/music_douban` | 浏览豆瓣音乐新碟榜,参数:`page`、`count` | -专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐刮削遵循 `music` 的标签、封面和歌词策略,歌词通过带有界 TTL/LRU 缓存的 LRCLIB 模块保存为同名 `.lrc` 或 `.txt` 旁挂文件。 +专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐整理会迁移与音轨同目录、同主干名的 `.lrc`、`.txt` 和 `.lyricsfile.yaml` 旁挂歌词。音乐刮削默认使用“质量升级”策略:先读取已有旁挂和 MP3/FLAC/Ogg/MP4 内嵌歌词,再聚合插件、LRCLIB、可选 Musixmatch 和 TheAudioDB 纯文本候选;逐字 Lyricsfile、逐行同步 LRC、纯文本依次降级,任何覆盖入口都不会用低质量结果替换高质量歌词。LRCLIB 的 Lyricsfile 会保留为 `.lyricsfile.yaml`,同时生成播放器兼容的 `.lrc`。 音乐订阅可使用 `audio_quality=hires|lossless|lossy`(支持正则组合)、`audio_format`、`min_bitrate`、`min_bit_depth`、`min_sample_rate` 过滤资源。`best_version=1` 开启音质洗版,系统按格式、无损属性、位深、采样率和码率换算 0-100 优先级,只下载高于 `current_priority` 的候选;DSD 或 24-bit/192 kHz 无损资源达到终态 100。内置规则 `HIRES`、`LOSSLESS`、`FLAC`、`ALAC`、`APE`、`WAV`、`DSD`、`MP3`、`AAC`、`OPUS`、`BITRATE320`、`BITRATE256`、`BITRATE192` 可用于自定义过滤规则组。 @@ -313,7 +313,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 媒体相关 MCP 工具以 `media_source` + 来源原生 `media_id` 传递精确身份;内置来源使用 `MediaSource` 常量,插件来源使用注册的稳定扩展标识。`query_media_detail`、`search_torrents`、`query_library_exists` 必须提供完整字段对;`add_subscribe`、`transfer_file`、`scrape_metadata` 在显式指定身份时也必须成对提供。`search_media` 和 `recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口。 -Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。 +Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、升级、已存在、防降级保护、未匹配、预算耗尽和失败数量。 `get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`;需要查看种子标签时,传入 `include_labels=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index f2bb69efa..54b7f7327 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -209,7 +209,7 @@ Music acquisition rules: - Subscribe/download one recording as one track. Subscribe/download one album as a complete multi-track pack. - Album torrent validation compares supported audio files with `total_tracks`; incomplete resources do not complete the subscription. - Artist IDs are never subscription, torrent, download, transfer, or library-existence targets. -- `/api/v1/media/scrape/{storage}` writes configured music tags/covers and can fetch LRCLIB lyrics as `.lrc`/`.txt` sidecars. External metadata, cover, exploration, statistics, and lyrics requests use bounded TTL/LRU caches in their owning modules/helpers. +- `/api/v1/media/scrape/{storage}` writes configured music tags/covers and resolves lyrics from existing sidecars, embedded tags, plugins, LRCLIB, optional authorized Musixmatch, and TheAudioDB plain-text fallback. The default upgrade policy keeps `.lyricsfile.yaml` plus compatible `.lrc` output and never replaces higher-quality synchronized lyrics with plain text. Album lyrics requests have a batch deadline and provider cooldowns; external metadata, cover, exploration, statistics, and lyrics requests use bounded caches in their owning modules/helpers. ### Search / Torrents / Subtitles (11 endpoints) @@ -356,7 +356,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON | | POST | `/api/v1/transfer/manual/target-path` | Match the manual transfer target from source path and directory configuration. Body: ManualTransferItem JSON; this endpoint does not recognize media | | POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON | -| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying | +| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; music directories default to `music_type=album` and files to `music_type=recording` when omitted; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying | | GET | `/api/v1/transfer/now` | Run immediate transfer | ### Dashboard (19 endpoints) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 15b8aa8b1..78dcb7b57 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6676, - "edge_sha256": "4244dafa5ec5179e2cfbf005dca97f9dde678f9c45e780288bbbbaca36ff4de5", + "edge_count": 6689, + "edge_sha256": "764be9020b4657c344453dc801ba270c925a2b26c94c0fb43c5a9f89953a4e07", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -3199,11 +3199,11 @@ "app.chain.listenbrainz -> app.domain.context", "app.chain.listenbrainz -> app.schemas", "app.chain.listenbrainz -> app.schemas.types", - "app.chain.lrclib -> app.chain", - "app.chain.lrclib -> app.domain", - "app.chain.lrclib -> app.domain.context", - "app.chain.lrclib -> app.domain.meta", - "app.chain.lrclib -> app.domain.meta.metamusic", + "app.chain.lyrics -> app.chain", + "app.chain.lyrics -> app.domain", + "app.chain.lyrics -> app.domain.context", + "app.chain.lyrics -> app.domain.meta", + "app.chain.lyrics -> app.domain.meta.metamusic", "app.chain.media -> app.application", "app.chain.media -> app.application.audio", "app.chain.media -> app.application.configuration", @@ -3309,7 +3309,7 @@ "app.chain.scraping -> app.application.audio", "app.chain.scraping -> app.application.configuration", "app.chain.scraping -> app.chain", - "app.chain.scraping -> app.chain.lrclib", + "app.chain.scraping -> app.chain.lyrics", "app.chain.scraping -> app.chain.media", "app.chain.scraping -> app.chain.storage", "app.chain.scraping -> app.domain", @@ -4879,6 +4879,19 @@ "app.modules.musicbrainz.music_cache -> app.runtime.settings", "app.modules.musicbrainz.music_cache -> app.schemas", "app.modules.musicbrainz.music_cache -> app.schemas.types", + "app.modules.musixmatch -> app.adapters", + "app.modules.musixmatch -> app.adapters.network", + "app.modules.musixmatch -> app.adapters.network.http", + "app.modules.musixmatch -> app.domain", + "app.modules.musixmatch -> app.domain.context", + "app.modules.musixmatch -> app.domain.meta", + "app.modules.musixmatch -> app.domain.meta.metamusic", + "app.modules.musixmatch -> app.modules", + "app.modules.musixmatch -> app.runtime", + "app.modules.musixmatch -> app.runtime.log", + "app.modules.musixmatch -> app.runtime.settings", + "app.modules.musixmatch -> app.schemas", + "app.modules.musixmatch -> app.schemas.types", "app.modules.navidrome -> app.application", "app.modules.navidrome -> app.application.mediaserver", "app.modules.navidrome -> app.domain", @@ -6693,7 +6706,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 822, + "module_count": 823, "modules": [ "app", "app.adapters", @@ -7050,7 +7063,7 @@ "app.chain.download", "app.chain.interaction", "app.chain.listenbrainz", - "app.chain.lrclib", + "app.chain.lyrics", "app.chain.media", "app.chain.mediaserver", "app.chain.message", @@ -7252,6 +7265,7 @@ "app.modules.lrclib", "app.modules.musicbrainz", "app.modules.musicbrainz.music_cache", + "app.modules.musixmatch", "app.modules.navidrome", "app.modules.navidrome.navidrome", "app.modules.plex", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 1e7ee9695..dce3bf943 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -5299,6 +5299,24 @@ "timeout_policy": "caller_budget", "version": 1 }, + "music_lyrics_candidates": { + "aggregation": "ordered_list_merge", + "error_policy": "isolate_provider", + "execution": "sync_or_async", + "family": "music", + "input_contract": "MusicLyricsRequest", + "plugin_short_circuit": false, + "public_to_plugins": true, + "required_parameters": [ + "music" + ], + "result_contract": "list[MusicLyrics]", + "result_shape": "list", + "supports_async": true, + "supports_sync": true, + "timeout_policy": "caller_budget", + "version": 1 + }, "obtain_images": { "aggregation": "pipeline_relay", "error_policy": "isolate_provider", @@ -6423,10 +6441,10 @@ } }, "run_module": { - "call_count": 261, + "call_count": 263, "dynamic_call_count": 0, "dynamic_calls": [], - "method_count": 211, + "method_count": 212, "methods": { "anilist_credits": [ { @@ -7616,12 +7634,24 @@ ], "music_lyrics": [ { - "caller": "app.chain.lrclib", + "caller": "app.chain.lyrics", "count": 1, "mode": "async" }, { - "caller": "app.chain.lrclib", + "caller": "app.chain.lyrics", + "count": 1, + "mode": "sync" + } + ], + "music_lyrics_candidates": [ + { + "caller": "app.chain.lyrics", + "count": 1, + "mode": "async" + }, + { + "caller": "app.chain.lyrics", "count": 1, "mode": "sync" } diff --git a/tests/test_chain_layering.py b/tests/test_chain_layering.py index a3b3ef027..ace6d9d52 100644 --- a/tests/test_chain_layering.py +++ b/tests/test_chain_layering.py @@ -14,7 +14,6 @@ MUSIC_SOURCE_CHAIN_FILES = ( "acoustid.py", "douban.py", "listenbrainz.py", - "lrclib.py", "musicbrainz.py", "theaudiodb.py", ) diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index 367a939f1..807ae8428 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -506,7 +506,7 @@ from app.runtime.extensions.host_module_adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 39 +assert len(specs) == 40 adapter = HostModuleAdapter() lifecycle_events = [] @@ -553,7 +553,7 @@ from app.schemas.types import EventType registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 39 +assert len(specs) == 40 spec_by_id = {spec.id: spec for spec in specs} events = {spec.id: [] for spec in specs} @@ -720,7 +720,7 @@ from app.runtime.extensions.host_module_adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 39 +assert len(specs) == 40 configured_specs = tuple( spec for spec in specs if spec.activation is ActivationPolicy.WHEN_CONFIGURED @@ -816,12 +816,12 @@ from app.application.module import configure_module_runtime configure_module_runtime(lambda: ModuleManager()) manager = ModuleManager() -assert len(manager.list_specs()) == 39 +assert len(manager.list_specs()) == 40 assert manager.get_specs() == manager.list_specs() from app.api.endpoints.system import modulelist response = modulelist(None) -assert len(response.data["modules"]) == 39 +assert len(response.data["modules"]) == 40 heavy_prefixes = ( "lark_oapi", @@ -930,7 +930,7 @@ from app.runtime.extensions.module_manager import ModuleManager manager = ModuleManager() modules = manager.get_modules() -assert len(modules) == len(manager.list_specs()) == 39 +assert len(modules) == len(manager.list_specs()) == 40 for spec in manager.list_specs(): implementation = modules[spec.id] assert implementation.get_name() == spec.metadata["name"] diff --git a/tests/test_music_lyrics.py b/tests/test_music_lyrics.py new file mode 100644 index 000000000..96e66fa29 --- /dev/null +++ b/tests/test_music_lyrics.py @@ -0,0 +1,159 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from mutagen.id3 import SYLT, USLT + +from app.application.audio import AudioMetadataHelper +from app.chain.lyrics import LyricsChain +from app.chain.scraping import ScrapingChain +from app.domain.context import MusicInfo, MusicLyrics +from app.domain.meta.metamusic import MetaMusic +from app.schemas.workflow import FileItem + + +def test_lyricsfile_derives_lrc_plain_language_and_word_quality() -> None: + """Lyricsfile 应保留原文,并派生播放器兼容内容和逐字同步质量。""" + lyrics = MusicLyrics( + provider="lrclib", + lyricsfile=""" +version: '1.0' +metadata: + title: 晴天 + artist: 周杰伦 + language: zh +lines: + - start_ms: 1250 + text: 晴天 + words: + - {start_ms: 1250, text: 晴} + - {start_ms: 1500, text: 天} +""", + ) + + assert lyrics.language == "zh" + assert lyrics.synced_lyrics == "[00:01.25]晴天" + assert lyrics.plain_lyrics == "晴天" + assert lyrics.quality_rank == 4 + + +def test_lyricsfile_rejects_yaml_aliases() -> None: + """外部 Lyricsfile 不得通过 YAML 锚点别名制造共享或膨胀结构。""" + lyrics = MusicLyrics( + provider="lrclib", + lyricsfile=""" +version: '1.0' +metadata: {title: Track, artist: Artist} +base: &line {start: 1000, text: unsafe} +lines: [*line] +""", + ) + + assert lyrics.content is None + assert lyrics.quality_rank == 0 + + +def test_lyrics_chain_prefers_synced_candidate_over_exact_plain_fallback(monkeypatch) -> None: + """可信候选中应优先同步质量,再以匹配度和来源优先级打破平局。""" + chain = LyricsChain() + responses = iter([ + MusicLyrics(provider="legacy", plain_lyrics="plain", match_score=100), + [MusicLyrics(provider="licensed", synced_lyrics="[00:01]sync", match_score=95)], + ]) + monkeypatch.setattr(chain, "run_module", lambda *_args, **_kwargs: next(responses)) + + result = chain.get_music_lyrics(MusicInfo(title="Track", artists=["Artist"])) + + assert result is not None + assert result.provider == "licensed" + + +def test_lyrics_chain_selects_async_candidates(monkeypatch) -> None: + """通用歌词链异步入口应返回候选中的最优结果。""" + chain = LyricsChain() + run_module = AsyncMock(side_effect=[ + MusicLyrics(provider="legacy", plain_lyrics="plain", match_score=100), + [MusicLyrics(provider="lrclib", synced_lyrics="[00:01]sync", match_score=100)], + ]) + monkeypatch.setattr(chain, "async_run_module", run_module) + + result = asyncio.run(chain.async_get_music_lyrics( + MusicInfo(title="Track", artists=["Artist"]) + )) + + assert result is not None + assert result.synced_lyrics == "[00:01]sync" + + +def test_audio_helper_reads_id3_synced_and_plain_lyrics(monkeypatch, tmp_path) -> None: + """ID3 SYLT 和 USLT 应转换为本地高置信歌词候选。""" + tags = Mock() + tags.getall.side_effect = lambda name: { + "SYLT": [SYLT(encoding=3, lang="zho", format=2, type=1, text=[("晴天", 1250)])], + "USLT": [USLT(encoding=3, lang="zho", text="晴天")], + }[name] + tags.items.return_value = [] + monkeypatch.setattr("app.application.audio.MutagenFile", lambda *_args, **_kwargs: SimpleNamespace(tags=tags)) + + lyrics = AudioMetadataHelper.read_lyrics(tmp_path / "track.mp3") + + assert lyrics is not None + assert lyrics.provider == "embedded" + assert lyrics.synced_lyrics == "[00:01.25]晴天" + assert lyrics.plain_lyrics == "晴天" + + +def test_scrape_never_downgrades_existing_lrc_to_plain_text(monkeypatch, tmp_path) -> None: + """即使调用方要求覆盖,也不能用纯文本替换已有同步歌词。""" + chain = object.__new__(ScrapingChain) + chain.storagechain = Mock() + existing = FileItem(storage="local", path=(tmp_path / "track.lrc").as_posix(), type="file") + chain.storagechain.get_file_item.side_effect = lambda storage, path: ( + existing if str(path).endswith(".lrc") else None + ) + lyrics_chain = Mock() + lyrics_chain.budget_exceeded = False + lyrics_chain.get_music_lyrics.return_value = MusicLyrics( + provider="theaudiodb", + plain_lyrics="plain", + match_score=100, + ) + monkeypatch.setattr(AudioMetadataHelper, "read_lyrics", lambda _path: None) + write = Mock(return_value=True) + monkeypatch.setattr(chain, "_write_music_lyrics_sidecar", write) + + status = chain._scrape_music_lyrics( + fileitem=FileItem(storage="local", path=(tmp_path / "track.flac").as_posix(), type="file"), + local_path=tmp_path / "track.flac", + scrape_info=MetaMusic(title="Track", artists=["Artist"]), + lyrics_option=SimpleNamespace(is_skip=False, is_upgrade=False), + overwrite=True, + lyrics_chain=lyrics_chain, + album_info=None, + ) + + assert status == "protected" + write.assert_not_called() + + +def test_music_lyrics_sidecars_match_only_same_stem_audio() -> None: + """整理链只关联同目录同主干名歌词,Lyricsfile 双扩展名也应正确剥离。""" + audio = FileItem(storage="local", path="/music/Track.flac", name="Track.flac", type="file", extension="flac") + lrc = FileItem(storage="local", path="/music/Track.lrc", name="Track.lrc", type="file", extension="lrc") + lyricsfile = FileItem( + storage="local", + path="/music/Track.lyricsfile.yaml", + name="Track.lyricsfile.yaml", + type="file", + extension="yaml", + ) + other = FileItem(storage="local", path="/music/Notes.txt", name="Notes.txt", type="file", extension="txt") + + from app.chain.transfer import TransferChain + + transfer_chain = object.__new__(TransferChain) + transfer_chain._subtitle_exts = () + transfer_chain._audio_exts = (".flac",) + assert transfer_chain._get_related_main_file_key(lrc, [audio]) == ("local", "/music/Track.flac") + assert transfer_chain._get_related_main_file_key(lyricsfile, [audio]) == ("local", "/music/Track.flac") + assert transfer_chain._get_related_main_file_key(other, [audio]) is None diff --git a/tests/test_music_scrape.py b/tests/test_music_scrape.py index 72c320673..663907a5b 100644 --- a/tests/test_music_scrape.py +++ b/tests/test_music_scrape.py @@ -181,9 +181,9 @@ def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> No ) -def test_default_scraping_config_enables_missing_only_music_lyrics() -> None: - """新安装和未保存过该字段的用户应默认仅在缺失时下载歌词。""" - assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY +def test_default_scraping_config_enables_music_lyrics_quality_upgrade() -> None: + """新安装和未保存过该字段的用户应默认升级歌词且不允许质量降级。""" + assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.UPGRADE def test_album_track_match_uses_disc_track_title_and_duration() -> None: @@ -252,7 +252,7 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None: ) music_chain = Mock() - with patch("app.chain.scraping.LrclibChain", return_value=music_chain): + with patch("app.chain.scraping.LyricsChain", return_value=music_chain): success, message = chain.scrape_music_metadata( FileItem( storage="local", @@ -265,7 +265,10 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None: ) assert success is True - assert message == "已刮削 1 个音频文件,歌词新增 1 首、已存在 0 首、未匹配 0 首" + assert message == ( + "已刮削 1 个音频文件,歌词新增 1 首、升级 0 首、已存在 0 首、" + "防降级保护 0 首、未匹配 0 首" + ) call = chain._scrape_music_file.call_args assert call.kwargs["write_tags"] is False assert call.kwargs["with_cover"] is False diff --git a/tests/test_music_source_chains.py b/tests/test_music_source_chains.py index e4a6608db..475da2bb6 100644 --- a/tests/test_music_source_chains.py +++ b/tests/test_music_source_chains.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock from app.chain.acoustid import AcoustIdChain from app.chain.douban import DoubanChain from app.chain.listenbrainz import ListenBrainzChain -from app.chain.lrclib import LrclibChain +from app.chain.lyrics import LyricsChain from app.chain.musicbrainz import MusicBrainzChain from app.chain.theaudiodb import TheAudioDbChain from app.domain.context import MusicAlbumInfo, MusicInfo, MusicLyrics @@ -95,9 +95,9 @@ def test_acoustid_chain_normalizes_fingerprint_result(monkeypatch) -> None: assert result == "recording-1" -def test_lrclib_chain_converts_dictionary_result(monkeypatch) -> None: - """LRCLIB 来源链应把字典结果转换为标准歌词对象。""" - chain = LrclibChain() +def test_lyrics_chain_converts_dictionary_result(monkeypatch) -> None: + """通用歌词链应把来源返回的字典转换为标准歌词对象。""" + chain = LyricsChain() monkeypatch.setattr(chain, "run_module", Mock(return_value={ "provider": "lrclib", "provider_id": "1", diff --git a/tests/test_musixmatch_module.py b/tests/test_musixmatch_module.py new file mode 100644 index 000000000..e5678f3c8 --- /dev/null +++ b/tests/test_musixmatch_module.py @@ -0,0 +1,52 @@ +from app.domain.context import MusicInfo +from app.modules.musixmatch import MusixmatchModule + + +def _payload(name: str, item: dict) -> dict: + """构造 Musixmatch 官方 message/body 响应包装。""" + return { + "message": { + "header": {"status_code": 200}, + "body": {name: item}, + } + } + + +def test_musixmatch_prefers_authorized_subtitle(monkeypatch) -> None: + """官方 matcher 返回可用字幕时不应再请求纯文本歌词。""" + module = MusixmatchModule() + calls = [] + + def request(method, params): + calls.append((method, params)) + return _payload("subtitle", { + "subtitle_id": 12, + "subtitle_body": "[00:01.00]Track", + "subtitle_language": "en", + "restricted": 0, + }) + + monkeypatch.setattr(module, "_request", request) + results = module.music_lyrics_candidates( + MusicInfo(title="Track", artists=["Artist"], duration=180) + ) + + assert len(results) == 1 + assert results[0].synced_lyrics == "[00:01.00]Track" + assert calls[0][0] == "matcher.subtitle.get" + assert calls[0][1]["f_subtitle_length_max_deviation"] == 2 + assert len(calls) == 1 + + +def test_musixmatch_restricted_results_are_not_saved(monkeypatch) -> None: + """授权计划标记 restricted 的字幕和歌词均不得写入本地。""" + module = MusixmatchModule() + responses = iter([ + _payload("subtitle", {"restricted": 1, "subtitle_body": "blocked"}), + _payload("lyrics", {"restricted": 1, "lyrics_body": "blocked"}), + ]) + monkeypatch.setattr(module, "_request", lambda *_args, **_kwargs: next(responses)) + + assert module.music_lyrics_candidates( + MusicInfo(title="Track", artists=["Artist"]) + ) == [] diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 447d2bd57..ef9619762 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -50,6 +50,75 @@ def test_manual_music_transfer_forwards_entity_namespace(monkeypatch): assert captured["music_type"] == "album" +def test_manual_music_directory_defaults_to_album_namespace(monkeypatch): + """旧客户端只声明音乐目录时,后端应按整张专辑而不是单曲解释媒体 ID。""" + captured = {} + + class FakeTransferChain: + """记录手动整理调用参数。""" + + def manual_transfer(self, **kwargs): + captured.update(kwargs) + return True, "" + + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + + response = manual_transfer( + transer_item=ManualTransferItem( + fileitem=FileItem( + storage="local", + path="/downloads/叶惠美", + name="叶惠美", + type="dir", + ), + type_name="音乐", + media_source="musicbrainz", + media_id="977e6978-139d-425c-bb98-6b0c62d1e45e", + ), + background=True, + history_query=SimpleNamespace(get=lambda _history_id: None), + _="token", + ) + + assert response.success is True + assert captured["music_type"] == "album" + + +def test_manual_music_file_defaults_to_recording_namespace(monkeypatch): + """旧客户端只声明音乐文件时,后端应继续按单曲解释媒体 ID。""" + captured = {} + + class FakeTransferChain: + """记录手动整理调用参数。""" + + def manual_transfer(self, **kwargs): + captured.update(kwargs) + return True, "" + + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + + response = manual_transfer( + transer_item=ManualTransferItem( + fileitem=FileItem( + storage="local", + path="/downloads/晴天.flac", + name="晴天.flac", + type="file", + extension="flac", + ), + type_name="音乐", + media_source="musicbrainz", + media_id="recording-1", + ), + background=True, + history_query=SimpleNamespace(get=lambda _history_id: None), + _="token", + ) + + assert response.success is True + assert captured["music_type"] == "recording" + + def test_manual_transfer_from_history_preserves_download_context(monkeypatch): """复用历史识别信息时应传递原下载上下文。""" history = SimpleNamespace(