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

This commit is contained in:
jxxghp
2026-08-25 21:02:26 +08:00
parent f28e8b1538
commit 574c50586e
31 changed files with 1228 additions and 105 deletions
+13 -3
View File
@@ -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,
+79 -2
View File
@@ -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:
"""只从文件名和目录结构解析音乐元数据。"""
+1
View File
@@ -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
+12
View File
@@ -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
-29
View File
@@ -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
+128
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
"""从模块或插件返回字典恢复标准歌词对象。"""
+15 -1
View File
@@ -208,6 +208,13 @@ class TransHandler:
return True
return False
def __is_music_lyrics_file(_fileitem: FileItem) -> bool:
"""判断是否为音乐音轨的歌词附件。"""
path = str(_fileitem.path or _fileitem.name or "").casefold()
return mediainfo.type == MediaType.MUSIC and path.endswith(
(".lrc", ".txt", ".lyricsfile.yaml")
)
def __is_extra_file(_fileitem: FileItem) -> bool:
"""
判断是否为附加文件
@@ -219,6 +226,8 @@ class TransHandler:
extension = f".{_fileitem.extension.lower()}"
if extension in settings.RMT_SUBEXT:
return True
if __is_music_lyrics_file(_fileitem):
return True
if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT:
return True
return False
@@ -384,6 +393,11 @@ class TransHandler:
# 目的文件名
if need_rename:
file_extension = (
".lyricsfile.yaml"
if str(fileitem.path or "").casefold().endswith(".lyricsfile.yaml")
else f".{fileitem.extension}"
)
new_file = self.get_rename_path(
path=target_path,
template_string=rename_format,
@@ -391,7 +405,7 @@ class TransHandler:
meta=in_meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
file_ext=f".{fileitem.extension}",
file_ext=file_extension,
),
source_path=fileitem.path,
source_item=fileitem,
+43 -14
View File
@@ -3,30 +3,32 @@ import threading
import time
from typing import Any, Optional, Tuple, Union
from app.runtime.cache import cached
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.adapters.network.http import RequestUtils
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.runtime.cache import cached
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
from app.schemas.types import ModuleType, OtherModulesType
from app.adapters.network.http import RequestUtils
settings = RuntimeSettingsCompat()
class LrclibModule(_ModuleBase):
"""通过 LRCLIB 获取与单个音轨匹配的同步歌词或纯文本歌词。"""
_base_url = "https://lrclib.net"
_source = "lrclib"
_request_interval = 0.3
_request_lock = threading.Lock()
_last_request_at = 0.0
_cooldown_until = 0.0
_match_pattern = re.compile(r"[^\w]+", flags=re.UNICODE)
def init_module(self) -> None:
"""初始化无状态的 LRCLIB 歌词模块"""
"""配置变化后清理跨实例请求缓存和供应商冷却状态"""
self._request_json.cache_clear()
type(self)._cooldown_until = 0.0
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""LRCLIB 无需密钥,是否请求由音乐歌词刮削策略控制。"""
@@ -79,6 +81,7 @@ class LrclibModule(_ModuleBase):
if duration:
exact_params["duration"] = duration
payload = self._request_json("/api/get", params=exact_params)
match_score = 100
if not payload:
results = self._request_json(
"/api/search",
@@ -95,7 +98,16 @@ class LrclibModule(_ModuleBase):
album=album,
duration=duration,
)
return self._to_lyrics(payload)
match_score = 90
return self._to_lyrics(payload, match_score=match_score)
def music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
) -> list[MusicLyrics]:
"""向通用歌词链返回候选列表,保留旧单结果接口兼容插件生态。"""
lyrics = self.music_lyrics(music)
return [lyrics] if lyrics else []
@classmethod
def _select_result(
@@ -152,14 +164,15 @@ class LrclibModule(_ModuleBase):
return None
@classmethod
def _to_lyrics(cls, payload: Any) -> Optional[MusicLyrics]:
def _to_lyrics(cls, payload: Any, match_score: int = 90) -> Optional[MusicLyrics]:
"""把 LRCLIB 响应转换为标准歌词对象。"""
if not isinstance(payload, dict) or payload.get("id") is None:
return None
plain_lyrics = str(payload.get("plainLyrics") or "").strip() or None
synced_lyrics = str(payload.get("syncedLyrics") or "").strip() or None
lyricsfile = str(payload.get("lyricsfile") or "").strip() or None
instrumental = bool(payload.get("instrumental"))
if not instrumental and not plain_lyrics and not synced_lyrics:
if not instrumental and not plain_lyrics and not synced_lyrics and not lyricsfile:
return None
return MusicLyrics(
provider=cls._source,
@@ -167,6 +180,9 @@ class LrclibModule(_ModuleBase):
instrumental=instrumental,
plain_lyrics=plain_lyrics,
synced_lyrics=synced_lyrics,
lyricsfile=lyricsfile,
match_score=match_score,
provider_priority=20,
)
@classmethod
@@ -174,9 +190,12 @@ class LrclibModule(_ModuleBase):
cls,
path: str,
params: Optional[dict[str, Any]],
base_url: Optional[str] = None,
) -> Any:
"""串行执行一次 LRCLIB 请求,确保批量专辑刮削遵守最小请求间隔。"""
with cls._request_lock:
if time.monotonic() < cls._cooldown_until:
return None
delay = cls._request_interval - (time.monotonic() - cls._last_request_at)
if delay > 0:
time.sleep(delay)
@@ -187,7 +206,10 @@ class LrclibModule(_ModuleBase):
},
proxies=settings.PROXY,
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
).get_res(
f"{(base_url or str(settings.LRCLIB_BASE_URL)).rstrip('/')}{path}",
params=params,
)
cls._last_request_at = time.monotonic()
return response
@@ -197,9 +219,10 @@ class LrclibModule(_ModuleBase):
cls,
path: str,
params: Optional[dict[str, Any]] = None,
base_url: Optional[str] = None,
) -> Any:
"""请求 LRCLIB JSON 接口,缓存命中与未命中结果并按 Retry-After 重试一次。"""
response = cls._request_once(path, params)
response = cls._request_once(path, params, base_url)
if response is None:
return None
try:
@@ -208,8 +231,14 @@ class LrclibModule(_ModuleBase):
if response.status_code in (429, 503):
retry_after = cls._retry_after_seconds(response.headers.get("Retry-After"))
response.close()
max_wait = max(int(settings.LYRICS_PROVIDER_RETRY_MAX_WAIT), 0)
if retry_after > max_wait:
cls._cooldown_until = time.monotonic() + retry_after
logger.warning(f"LRCLIB 进入冷却 {retry_after:g} 秒,跳过当前批次后续请求")
response = None
return None
time.sleep(retry_after)
response = cls._request_once(path, params)
response = cls._request_once(path, params, base_url)
if response is None:
return None
if response.status_code == 404:
+1 -1
View File
@@ -12,4 +12,4 @@ priority = 5
[activation]
policy = "bootstrap"
watch = []
watch = ["LRCLIB_BASE_URL"]
+166
View File
@@ -0,0 +1,166 @@
import time
from typing import Any, Optional, Tuple, Union
from app.adapters.network.http import RequestUtils
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
from app.modules import _ModuleBase
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
from app.schemas.types import ModuleType, OtherModulesType
settings = RuntimeSettingsCompat()
class MusixmatchModule(_ModuleBase):
"""使用用户授权的 Musixmatch 官方 API 获取同步或纯文本歌词。"""
_source = "musixmatch"
_cooldown_until = 0.0
def init_module(self) -> None:
"""初始化无持久状态的授权歌词模块。"""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""仅在配置官方 API Key 后启用模块。"""
return "MUSIXMATCH_API_KEY", True
def stop(self) -> None:
"""停止模块;当前没有需要释放的资源。"""
def test(self) -> Tuple[bool, str]:
"""验证 API Key 和官方接口连通性。"""
if not str(settings.MUSIXMATCH_API_KEY or "").strip():
return False, "Musixmatch API Key 未配置"
payload = self._request("matcher.lyrics.get", {"q_track": "test", "q_artist": "test"})
return (True, "") if payload is not None else (False, "Musixmatch API 连接或授权失败")
@staticmethod
def get_name() -> str:
"""返回模块展示名称。"""
return "Musixmatch"
@staticmethod
def get_type() -> ModuleType:
"""返回模块所属类型。"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""返回 Musixmatch 模块子类型。"""
return OtherModulesType.Musixmatch
@staticmethod
def get_priority() -> int:
"""授权同步歌词优先于免费来源参与候选评分。"""
return 4
def music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
) -> list[MusicLyrics]:
"""按标题、艺术家和时长调用官方 matcher 接口。"""
title = str(getattr(music, "title", None) or "").strip()
artists = list(getattr(music, "artists", None) or [])
artist = str((artists[0] if artists else getattr(music, "album_artist", None)) or "").strip()
if not title or not artist:
return []
params: dict[str, Any] = {"q_track": title, "q_artist": artist}
duration = self._optional_int(getattr(music, "duration", None))
if duration:
params.update({
"f_subtitle_length": duration,
"f_subtitle_length_max_deviation": 2,
})
subtitle = self._response_item(self._request("matcher.subtitle.get", params), "subtitle")
if subtitle and not subtitle.get("restricted"):
body = str(subtitle.get("subtitle_body") or "").strip()
if body:
return [MusicLyrics(
provider=self._source,
provider_id=self._optional_text(subtitle.get("subtitle_id")),
synced_lyrics=body,
language=self._optional_text(subtitle.get("subtitle_language")),
match_score=95,
provider_priority=30,
)]
lyrics = self._response_item(
self._request("matcher.lyrics.get", {"q_track": title, "q_artist": artist}),
"lyrics",
)
if not lyrics or lyrics.get("restricted"):
return []
body = str(lyrics.get("lyrics_body") or "").strip()
instrumental = bool(lyrics.get("instrumental"))
if not body and not instrumental:
return []
return [MusicLyrics(
provider=self._source,
provider_id=self._optional_text(lyrics.get("lyrics_id")),
instrumental=instrumental,
plain_lyrics=body or None,
language=self._optional_text(lyrics.get("lyrics_language")),
match_score=92,
provider_priority=30,
)]
def _request(self, method: str, params: dict[str, Any]) -> Optional[dict[str, Any]]:
"""请求官方 API,并对限流或服务过载设置进程内冷却。"""
api_key = str(settings.MUSIXMATCH_API_KEY or "").strip()
if not api_key or time.monotonic() < self._cooldown_until:
return None
response = RequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=20,
).get_res(
f"{str(settings.MUSIXMATCH_BASE_URL).rstrip('/')}/{method}",
params={**params, "apikey": api_key},
)
if response is None:
return None
try:
if response.status_code in (429, 503):
retry_after = self._optional_int(response.headers.get("Retry-After")) or 60
type(self)._cooldown_until = time.monotonic() + retry_after
logger.warning(f"Musixmatch 进入冷却 {retry_after}")
return None
if response.status_code != 200:
logger.warning(f"Musixmatch 请求失败:HTTP {response.status_code}")
return None
payload = response.json()
status = self._optional_int(
((payload.get("message") or {}).get("header") or {}).get("status_code")
) if isinstance(payload, dict) else None
if status != 200:
if status in (401, 402, 429):
logger.warning(f"Musixmatch API 拒绝请求:状态码 {status}")
return None
return payload
except (TypeError, ValueError) as err:
logger.warning(f"Musixmatch 响应解析失败:{err}")
return None
finally:
response.close()
@staticmethod
def _response_item(payload: Any, name: str) -> Optional[dict[str, Any]]:
"""从官方 message/body 包装中提取歌词或字幕对象。"""
if not isinstance(payload, dict):
return None
item = (((payload.get("message") or {}).get("body") or {}).get(name))
return item if isinstance(item, dict) else None
@staticmethod
def _optional_int(value: Any) -> Optional[int]:
"""安全转换整数响应字段。"""
try:
return int(float(value)) if value not in (None, "") else None
except (TypeError, ValueError):
return None
@staticmethod
def _optional_text(value: Any) -> Optional[str]:
"""安全转换非空文本字段。"""
text = str(value or "").strip()
return text or None
+19
View File
@@ -0,0 +1,19 @@
schema_version = 1
id = "MusixmatchModule"
kind = "host_module"
entrypoint = "app.modules.musixmatch:MusixmatchModule"
depends_on = []
[metadata]
name = "Musixmatch"
type = "other"
subtype = "Musixmatch"
priority = 4
[activation]
policy = "when_configured"
watch = ["MUSIXMATCH_API_KEY", "MUSIXMATCH_BASE_URL"]
[activation.selector]
kind = "setting_truthy"
key = "MUSIXMATCH_API_KEY"
+10
View File
@@ -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")),
+1
View File
@@ -68,6 +68,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
"listenbrainz",
"lrclib",
"musicbrainz",
"musixmatch",
"navidrome",
"plex",
"postgresql",
+3
View File
@@ -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 = "质量升级"
# 刮削目标类型
+1
View File
@@ -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,
+6
View File
@@ -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` 显示单个配置项的类型、默认值和当前值
+3 -3
View File
@@ -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 关系组合。
+2 -2
View File
@@ -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)
+24 -10
View File
@@ -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",
+34 -4
View File
@@ -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"
}
-1
View File
@@ -14,7 +14,6 @@ MUSIC_SOURCE_CHAIN_FILES = (
"acoustid.py",
"douban.py",
"listenbrainz.py",
"lrclib.py",
"musicbrainz.py",
"theaudiodb.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"]
+159
View File
@@ -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
+8 -5
View File
@@ -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
+4 -4
View File
@@ -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",
+52
View File
@@ -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"])
) == []
+69
View File
@@ -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(