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