feat(music): 支持多源识别与元数据刮削

This commit is contained in:
jxxghp
2026-08-11 23:33:14 +08:00
parent 1197ece12b
commit 3f1b0cdb05
38 changed files with 3764 additions and 284 deletions

View File

@@ -134,10 +134,7 @@ class RecognizeMediaTool(MoviePilotTool):
metainfo.artists = [artist]
if album:
metainfo.album = album
mediainfo = await music_chain.async_recognize_media(
meta=metainfo,
source="musicbrainz",
)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
if mediainfo:
context = Context(meta_info=metainfo, media_info=mediainfo)
return self._format_context_result(context, "音乐标题")

View File

@@ -43,7 +43,11 @@ class ScrapeMetadataInput(BaseModel):
)
media_source: Optional[str] = Field(
None,
description="Music metadata source, normally musicbrainz. Must be paired with media_id",
description=(
"Music metadata source: musicbrainz, theaudiodb, or doubanmusic. "
"When omitted, automatic music recognition compares all sources. "
"Must be paired with media_id when an ID is supplied"
),
)
media_id: Optional[str] = Field(
None,
@@ -209,6 +213,7 @@ class ScrapeMetadataTool(MoviePilotTool):
fileitem=fileitem,
mediainfo=mediainfo,
overwrite=bool(overwrite),
source=media_source,
)
result = {
"success": success,

View File

@@ -18,7 +18,15 @@ from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
MediaSource = Literal[
"themoviedb",
"douban",
"bangumi",
"anilist",
"musicbrainz",
"theaudiodb",
"doubanmusic",
]
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:

View File

@@ -19,14 +19,18 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.utils.media import MEDIA_SOURCE_ID_FIELDS, parse_media_key
from app.utils.media import (
MEDIA_SOURCE_ID_FIELDS,
is_music_media_source,
parse_media_key,
)
router = APIRouter()
MediaSource = str
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它现有来源使用数字 ID。"""
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它内置来源使用数字 ID。"""
if source == "musicbrainz":
try:
UUID(media_id)
@@ -122,8 +126,8 @@ async def recognize(
"""
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
# MusicBrainz 仅支持音乐识别,非音频后缀的标题统一按音乐元数据解析
if source == "musicbrainz" and not isinstance(metainfo, MetaMusic):
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
if is_music_media_source(source) and not isinstance(metainfo, MetaMusic):
metainfo = MusicChain.parse_query(title)
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
@@ -218,9 +222,13 @@ async def search(
return obj.source
media_chain = MediaChain()
if type == "music" or source == "musicbrainz":
if type == "music" or is_music_media_source(source):
# 音乐搜索统一入口,与影视搜索共用 /media/search
music_infos = await MusicChain().async_search(query=title, limit=count)
music_search_params = {"query": title, "limit": count}
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
if source:
music_search_params["source"] = source
music_infos = await MusicChain().async_search(**music_search_params)
return [
info.to_dict()
for info in music_infos
@@ -283,12 +291,12 @@ def scrape(
is_music = (
type_name == MediaType.MUSIC
or media_source == "musicbrainz"
or is_music_media_source(media_source)
or MediaChain.is_audio_path(fileitem.path)
)
if is_music:
if type_name not in (None, MediaType.MUSIC):
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
@@ -303,6 +311,7 @@ def scrape(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,
source=media_source,
)
return schemas.Response(success=success, message=message)

View File

@@ -21,7 +21,10 @@ router = APIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)]
PageParam = Annotated[int, Query(ge=1)]
MusicSourceParam = Annotated[str, Query(pattern="^musicbrainz$")]
MusicSourceParam = Annotated[
str,
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
]
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
MusicRangeParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_CHART_RANGES)})$")]

View File

@@ -41,7 +41,7 @@ from app.schemas import (
MessageResponse,
)
from app.utils.identity import normalize_internal_user_id
from app.utils.media import normalize_media_source
from app.utils.media import is_music_media_source, normalize_media_source
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.category import CategoryConfig
from app.schemas.types import (
@@ -635,6 +635,72 @@ class ChainBase(metaclass=ABCMeta):
return "anilist", None, None, None, int(anilistid)
return source, None, None, None, None
def _run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""按媒体领域执行同步原生识别,音乐请求只允许进入音乐数据源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(source)
):
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
from app.chain.music import MusicChain
music_chain = MusicChain()
if source:
with fresh(not cache):
return music_chain.recognize_from_source(
source=source,
meta=meta if isinstance(meta, MetaMusic) else None,
mediaid=module_kwargs.get("mediaid"),
cache=cache,
)
if isinstance(meta, MetaMusic):
return music_chain.recognize_best(meta=meta, cache=cache)
return None
with fresh(not cache):
return self.run_module("recognize_media", **module_kwargs)
async def _async_run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""按媒体领域执行异步原生识别,音乐请求只允许进入音乐数据源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(source)
):
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
from app.chain.music import MusicChain
music_chain = MusicChain()
if source:
async with async_fresh(not cache):
return await music_chain.async_recognize_from_source(
source=source,
meta=meta if isinstance(meta, MetaMusic) else None,
mediaid=module_kwargs.get("mediaid"),
cache=cache,
)
if isinstance(meta, MetaMusic):
return await music_chain.async_recognize_best(meta=meta, cache=cache)
return None
async with async_fresh(not cache):
return await self.async_run_module(
"async_recognize_media", **module_kwargs
)
def recognize_media(
self,
meta: MetaBase = None,
@@ -685,7 +751,9 @@ class ChainBase(metaclass=ABCMeta):
anilistid=anilistid,
)
# 检索显式 TMDB ID 由请求方自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
if not mtype and not tmdbid and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
@@ -700,11 +768,7 @@ class ChainBase(metaclass=ABCMeta):
"episode_group": episode_group,
"cache": cache,
}
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
**module_kwargs,
)
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = self._supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
@@ -731,20 +795,21 @@ class ChainBase(metaclass=ABCMeta):
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
mediainfo = self._run_native_media_recognize(
{
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"source": shared_params.get("source"),
"mediaid": shared_params.get("mediaid"),
"tmdbid": shared_params.get("tmdbid"),
"doubanid": shared_params.get("doubanid"),
"bangumiid": shared_params.get("bangumiid"),
"anilistid": shared_params.get("anilistid"),
"episode_group": episode_group,
"cache": cache,
},
cache,
)
if mediainfo:
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
self._record_media_recognize_share_hit()
@@ -801,7 +866,9 @@ class ChainBase(metaclass=ABCMeta):
anilistid=anilistid,
)
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
if not mtype and not tmdbid and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
share_query_meta = share_meta or meta
module_kwargs = {
@@ -816,11 +883,7 @@ class ChainBase(metaclass=ABCMeta):
"episode_group": episode_group,
"cache": cache,
}
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
**module_kwargs,
)
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = await self._async_supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
@@ -847,20 +910,21 @@ class ChainBase(metaclass=ABCMeta):
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
mediainfo = await self._async_run_native_media_recognize(
{
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"source": shared_params.get("source"),
"mediaid": shared_params.get("mediaid"),
"tmdbid": shared_params.get("tmdbid"),
"doubanid": shared_params.get("doubanid"),
"bangumiid": shared_params.get("bangumiid"),
"anilistid": shared_params.get("anilistid"),
"episode_group": episode_group,
"cache": cache,
},
cache,
)
if mediainfo:
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
await run_in_threadpool(self._record_media_recognize_share_hit)
@@ -1122,6 +1186,8 @@ class ChainBase(metaclass=ABCMeta):
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息
"""
if mediainfo and mediainfo.type == MediaType.MUSIC:
return mediainfo
return self.run_module("obtain_images", mediainfo=mediainfo)
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
@@ -1130,6 +1196,8 @@ class ChainBase(metaclass=ABCMeta):
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息
"""
if mediainfo and mediainfo.type == MediaType.MUSIC:
return mediainfo
return await self.async_run_module("async_obtain_images", mediainfo=mediainfo)
def obtain_specific_image(

View File

@@ -40,6 +40,7 @@ from app.schemas.types import (
SystemConfigKey,
)
from app.utils.http import RequestUtils
from app.utils.media import is_music_media_source
from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
@@ -211,6 +212,34 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self.storagechain = StorageChain()
self.scraping_policies = ScrapingConfig.from_system_config()
def _run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""自动音乐识别交给 MusicChain 多源评分,显式来源保持原有单源分发。"""
meta = module_kwargs.get("meta")
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
from app.chain.music import MusicChain
return MusicChain().recognize_best(meta=meta, cache=cache)
return super()._run_native_media_recognize(module_kwargs, cache)
async def _async_run_native_media_recognize(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""异步自动音乐识别并发比较多源结果,显式来源保持原有单源分发。"""
meta = module_kwargs.get("meta")
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
from app.chain.music import MusicChain
return await MusicChain().async_recognize_best(meta=meta, cache=cache)
return await super()._async_run_native_media_recognize(module_kwargs, cache)
def on_config_changed(self):
self.scraping_policies = ScrapingConfig.from_system_config()
@@ -641,6 +670,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
mtype: Optional[MediaType] = None,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
@@ -649,9 +679,11 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:param mtype: 上游已确定的媒体类型
"""
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=metainfo,
mtype=mtype,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
@@ -772,6 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
@@ -780,6 +813,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param mtype: 上游已确定的媒体类型
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
@@ -790,7 +824,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title = metainfo.title
share_meta = deepcopy(metainfo)
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
is_music = isinstance(metainfo, MetaMusic)
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
is_recognized = (
(lambda result: bool(result and result.source)) if is_music else None
)
@@ -799,6 +833,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""使用请求级数据源执行原生识别。"""
return self.recognize_media(
meta=metainfo,
mtype=mtype,
source=source,
share_meta=share_meta,
episode_group=episode_group,
@@ -806,6 +841,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def plugin_recognize() -> Optional[MediaInfo]:
"""执行辅助识别并保持请求级数据源约束。"""
if is_music and not isinstance(metainfo, MetaMusic):
return None
return self.recognize_help(
title=title,
org_meta=metainfo,
@@ -1009,11 +1046,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
meta = AudioMetadataHelper.read(file_path)
else:
meta = MetaMusic(
org_string=file_path.stem, title=file_path.stem, parse_title=True
)
return AudioMetadataHelper.read(file_path)
meta = MetaMusic(
org_string=file_path.stem, title=file_path.stem, parse_title=True
)
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
return meta.apply_path_context(file_path)
@@ -1070,7 +1106,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recognize_music_by_path(
self,
path: Union[str, Path],
source: str = "musicbrainz",
source: Optional[str] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
meta = self.read_path_meta(path)
@@ -1079,7 +1115,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
if not result.source:
if not result.source and source in (None, "musicbrainz"):
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
matched = self._music_album_dir_fallback(path)
if matched:
@@ -1089,7 +1125,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def async_recognize_music_by_path(
self,
path: Union[str, Path],
source: str = "musicbrainz",
source: Optional[str] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
# Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。
@@ -1099,7 +1135,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
if not result.source:
if not result.source and source in (None, "musicbrainz"):
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
matched = await run_in_threadpool(self._music_album_dir_fallback, path)
if matched:
@@ -1108,7 +1144,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _is_music_path_request(self, path: str, source: Optional[str]) -> bool:
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
return self.is_audio_path(path) or source == "musicbrainz"
return self.is_audio_path(path) or is_music_media_source(source)
def recognize_by_path(
self,
@@ -1130,7 +1166,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, source):
music_meta, music_info = self.recognize_music_by_path(
path, source=source or "musicbrainz"
path, source=source
)
return Context(meta_info=music_meta, media_info=music_info)
file_path = Path(path)
@@ -1655,6 +1691,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
source: Optional[str] = None,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
@@ -1668,7 +1705,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
and len(files) > 1
and mediainfo.music_type != MUSIC_ENTITY_ALBUM
):
return False, "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
# 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。
nfo_option = self.scraping_policies.option("music", "nfo")
@@ -1726,6 +1763,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
lyrics_overwrite=overwrite or lyrics_option.is_overwrite,
music_chain=music_chain,
album_info=album_info,
source=source,
)
if not result.metadata_success:
failures.append(
@@ -1806,6 +1844,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
lyrics_overwrite: bool = False,
music_chain: Optional["MusicChain"] = None,
album_info: Optional[MusicAlbumInfo] = None,
source: Optional[str] = None,
) -> _MusicScrapeFileResult:
"""下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。"""
storage = self.storagechain
@@ -1834,6 +1873,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
lyrics_overwrite=lyrics_overwrite,
music_chain=music_chain,
album_info=album_info,
source=source,
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
@@ -1853,6 +1893,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
lyrics_overwrite=lyrics_overwrite,
music_chain=music_chain,
album_info=album_info,
source=source,
)
def _apply_music_file_scrape(
@@ -1869,9 +1910,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
lyrics_overwrite: bool,
music_chain: Optional["MusicChain"],
album_info: Optional[MusicAlbumInfo],
source: Optional[str],
) -> _MusicScrapeFileResult:
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, source=source)
metadata_requested = write_tags or with_cover
metadata_success = True
if metadata_requested:
@@ -1884,6 +1926,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cover_overwrite=cover_overwrite,
cover=cover,
scrape_info=scrape_info,
source=source,
)
lyrics_status = self._scrape_music_lyrics(
@@ -1990,6 +2033,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cls,
local_path: Path,
mediainfo: Optional[MusicInfo],
source: Optional[str] = None,
) -> Optional[MetaMusic | MusicInfo]:
"""在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。"""
if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM:
@@ -2002,7 +2046,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if mediainfo:
return None
_, recognized = cls.recognize_music_by_path(local_path, source="musicbrainz")
_, recognized = cls.recognize_music_by_path(local_path, source=source)
return recognized
def _write_music_metadata(
@@ -2015,9 +2059,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cover_overwrite: bool,
cover: Optional[tuple[Optional[bytes], str]] = None,
scrape_info: Optional[MetaMusic | MusicInfo] = None,
source: Optional[str] = None,
) -> bool:
"""解析单个本地音轨并按独立策略写入标签和封面。"""
scrape_info = scrape_info or self._resolve_music_scrape_info(local_path, mediainfo)
scrape_info = scrape_info or self._resolve_music_scrape_info(
local_path,
mediainfo,
source=source,
)
if not scrape_info or not scrape_info.title:
logger.warning(f"无法识别音乐信息:{local_path}")
return False
@@ -2492,6 +2541,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
mtype: Optional[MediaType] = None,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息(异步版本)
@@ -2500,10 +2550,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:param mtype: 上游已确定的媒体类型
:return: 统一媒体信息
"""
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=metainfo,
mtype=mtype,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
@@ -2515,6 +2567,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def _async_recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
@@ -2523,6 +2576,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
异步根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param mtype: 上游已确定的媒体类型
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
@@ -2533,7 +2587,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title = metainfo.title
share_meta = deepcopy(metainfo)
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
is_music = isinstance(metainfo, MetaMusic)
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
is_recognized = (
(lambda result: bool(result and result.source)) if is_music else None
)
@@ -2542,6 +2596,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""异步使用请求级数据源执行原生识别。"""
return await self.async_recognize_media(
meta=metainfo,
mtype=mtype,
source=source,
share_meta=share_meta,
episode_group=episode_group,
@@ -2549,6 +2604,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def plugin_recognize() -> Optional[MediaInfo]:
"""异步执行辅助识别并保持请求级数据源约束。"""
if is_music and not isinstance(metainfo, MetaMusic):
return None
return await self.async_recognize_help(
title=title,
org_meta=metainfo,
@@ -2727,7 +2784,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, source):
music_meta, music_info = await self.async_recognize_music_by_path(
path, source=source or "musicbrainz"
path, source=source
)
return Context(meta_info=music_meta, media_info=music_info)
file_path = Path(path)

View File

@@ -1,10 +1,13 @@
import asyncio
import re
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Iterable, Optional, Union
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
from app.core.cache import async_fresh, fresh
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_ALBUM,
@@ -17,6 +20,13 @@ from app.core.context import (
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas.types import MediaType
from app.utils.media import (
MUSIC_MEDIA_SOURCE_ORDER,
is_music_media_source,
normalize_media_source,
)
from app.utils.zhconv import convert as zhconv_convert
class MusicChain(ChainBase):
@@ -27,6 +37,9 @@ class MusicChain(ChainBase):
_album_dir_cache_max = 128
# 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理
_album_match_min_files = 2
# 自动识别会比较全部来源;该顺序仅用于同分时的确定性选择。
_recognize_source_order = MUSIC_MEDIA_SOURCE_ORDER
_recognize_min_score = 45.0
@classmethod
def parse_query(cls, query: str) -> MetaMusic:
@@ -110,16 +123,112 @@ class MusicChain(ChainBase):
break
return results
def search(self, query: str, limit: int = 20) -> list[MusicInfo]:
"""调用已启用的音乐元数据模块搜索候选。"""
def search(
self,
query: str,
limit: int = 20,
source: Optional[str] = None,
) -> list[MusicInfo]:
"""按请求来源调用音乐元数据模块搜索候选,未指定时默认使用 MusicBrainz。"""
meta = self.parse_query(query)
candidates = self.run_module("search_music", meta=meta, limit=limit)
candidates = self.run_module(
"search_music",
meta=meta,
limit=limit,
source=source or "musicbrainz",
)
return self.normalize_candidates(candidates, limit=limit)
async def async_search(self, query: str, limit: int = 20) -> list[MusicInfo]:
"""异步调用已启用的音乐元数据模块搜索候选。"""
def recognize_best(
self,
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""依次查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
candidates: list[MusicInfo] = []
offline_fallback: Optional[MusicInfo] = None
with fresh(not cache):
for source in self._recognize_source_order:
result = self._recognize_from_source(meta, source, cache)
candidate = self._normalize_recognize_result(result, source)
if candidate:
candidates.append(candidate)
elif isinstance(result, MusicInfo) and not result.source:
# MusicBrainz 会返回无远端身份的离线结果,全部来源失败时仍需保留。
offline_fallback = offline_fallback or result
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
async def async_recognize_best(
self,
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""并发查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
async with async_fresh(not cache):
results = await asyncio.gather(*(
self._async_recognize_from_source(meta, source, cache)
for source in self._recognize_source_order
))
candidates: list[MusicInfo] = []
offline_fallback: Optional[MusicInfo] = None
for source, result in zip(self._recognize_source_order, results):
candidate = self._normalize_recognize_result(result, source)
if candidate:
candidates.append(candidate)
elif isinstance(result, MusicInfo) and not result.source:
offline_fallback = offline_fallback or result
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
def recognize_from_source(
self,
source: str,
meta: Optional[MetaMusic] = None,
mediaid: Optional[str] = None,
cache: bool = True,
) -> Optional[MusicInfo]:
"""只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
normalized_source = normalize_media_source(source)
if not is_music_media_source(normalized_source):
return None
return self._recognize_from_source(
meta=meta,
source=normalized_source,
cache=cache,
mediaid=mediaid,
)
async def async_recognize_from_source(
self,
source: str,
meta: Optional[MetaMusic] = None,
mediaid: Optional[str] = None,
cache: bool = True,
) -> Optional[MusicInfo]:
"""异步只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
normalized_source = normalize_media_source(source)
if not is_music_media_source(normalized_source):
return None
return await self._async_recognize_from_source(
meta=meta,
source=normalized_source,
cache=cache,
mediaid=mediaid,
)
async def async_search(
self,
query: str,
limit: int = 20,
source: Optional[str] = None,
) -> list[MusicInfo]:
"""异步按请求来源搜索音乐候选,未指定时默认使用 MusicBrainz。"""
meta = self.parse_query(query)
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
candidates = await self.async_run_module(
"search_music",
meta=meta,
limit=limit,
source=source or "musicbrainz",
)
return self.normalize_candidates(candidates, limit=limit)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
@@ -305,6 +414,200 @@ class MusicChain(ChainBase):
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
return MetaMusic.compact_text(value)
@staticmethod
def _normalize_recognize_result(
result: Any,
source: str,
) -> Optional[MusicInfo]:
"""标准化单个来源结果,并拒绝插件或模块返回的跨来源身份。"""
if isinstance(result, dict):
try:
result = MusicInfo.from_dict(result)
except (TypeError, ValueError):
return None
if not isinstance(result, MusicInfo):
return None
if not result.source or not result.media_id or result.source != source:
return None
if result.music_type != MUSIC_ENTITY_RECORDING:
return None
return result
def _recognize_from_source(
self,
meta: Optional[MetaMusic],
source: str,
cache: bool,
mediaid: Optional[str] = None,
) -> Optional[MusicInfo]:
"""调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。"""
module = self._music_recognize_module(source)
if not module:
return None
try:
return module.recognize_media(
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
)
except Exception as err:
logger.warning(f"{source} 音乐自动识别失败:{err}")
return None
async def _async_recognize_from_source(
self,
meta: Optional[MetaMusic],
source: str,
cache: bool,
mediaid: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步调用指定音乐来源模块,单个来源失败不影响其它候选。"""
module = self._music_recognize_module(source)
if not module:
return None
try:
async_method = getattr(module, "async_recognize_media", None)
if async_method:
return await async_method(
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
)
return await run_in_threadpool(
module.recognize_media,
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
)
except Exception as err:
logger.warning(f"{source} 音乐自动识别失败:{err}")
return None
def _music_recognize_module(self, source: str) -> Optional[Any]:
"""枚举运行中的系统模块并返回声明了指定音乐来源的实现。"""
for module in self.modulemanager.get_running_modules("recognize_media"):
get_music_source = getattr(module, "get_music_source", None)
if get_music_source and get_music_source() == source:
return module
return None
@classmethod
def _select_best_recognize_candidate(
cls,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
) -> Optional[MusicInfo]:
"""按统一证据评分选择最佳音轨,同分时使用默认来源顺序。"""
source_order = {
source: index for index, source in enumerate(cls._recognize_source_order)
}
ranked: list[tuple[float, int, MusicInfo]] = []
for candidate in candidates:
score = cls._recognize_candidate_score(meta, candidate)
if score is None or score < cls._recognize_min_score:
continue
logger.debug(
f"音乐自动识别候选:{candidate.source} {candidate.title},评分 {score:.1f}"
)
ranked.append((
score,
-source_order.get(candidate.source or "", len(source_order)),
candidate,
))
if not ranked:
return None
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_score, _, best = ranked[0]
logger.info(
f"音乐自动识别采用 {best.source}{best.title},匹配评分 {best_score:.1f}"
)
return best
@classmethod
def _recognize_candidate_score(
cls,
meta: MetaMusic,
candidate: MusicInfo,
) -> Optional[float]:
"""综合曲名、艺术家、专辑、ISRC、时长、曲序和年份计算匹配分。"""
if candidate.music_type != MUSIC_ENTITY_RECORDING:
return None
isrc_match = bool(
meta.isrc
and candidate.isrc
and cls._match_similarity(meta.isrc, candidate.isrc) == 1.0
)
if meta.isrc and candidate.isrc and not isrc_match:
return None
title_similarity = cls._match_similarity(meta.title, candidate.title)
if not isrc_match and (not meta.title or title_similarity < 0.7):
return None
score = title_similarity * 50
expected_artists = cls._unique_texts([
*(meta.artists or []),
meta.album_artist,
])
candidate_artists = cls._unique_texts([
*(candidate.artists or []),
candidate.album_artist,
])
if expected_artists:
artist_similarity = max(
(
cls._match_similarity(expected, actual)
for expected in expected_artists
for actual in candidate_artists
),
default=0.0,
)
if not isrc_match and artist_similarity < 0.6:
return None
score += artist_similarity * 25
if meta.album and candidate.album:
score += cls._match_similarity(meta.album, candidate.album) * 12
if meta.duration and candidate.duration:
duration_delta = abs(meta.duration - candidate.duration) / max(
meta.duration, candidate.duration
)
if duration_delta <= 0.02:
score += 8
elif duration_delta <= 0.05:
score += 6
elif duration_delta <= 0.1:
score += 3
elif duration_delta > 0.2:
score -= 8
if meta.track_number and candidate.track_number:
score += 3 if meta.track_number == candidate.track_number else -1
if meta.year and candidate.year:
year_delta = abs(int(meta.year) - int(candidate.year))
score += 2 if year_delta == 0 else 1 if year_delta == 1 else 0
if isrc_match:
score += 50
return score
@staticmethod
def _match_similarity(left: Optional[str], right: Optional[str]) -> float:
"""忽略繁简、大小写和标点后计算两段音乐文本的相似度。"""
normalized_left = MetaMusic.compact_text(
zhconv_convert(str(left or ""), "zh-hans")
)
normalized_right = MetaMusic.compact_text(
zhconv_convert(str(right or ""), "zh-hans")
)
if not normalized_left or not normalized_right:
return 0.0
return SequenceMatcher(None, normalized_left, normalized_right).ratio()
def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
"""按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。
@@ -364,11 +667,10 @@ class MusicChain(ChainBase):
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
meta = AudioMetadataHelper.read(file_path)
else:
meta = MetaMusic(
org_string=file_path.stem, title=file_path.stem, parse_title=True
)
return AudioMetadataHelper.read(file_path)
meta = MetaMusic(
org_string=file_path.stem, title=file_path.stem, parse_title=True
)
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
return meta.apply_path_context(file_path)

View File

@@ -525,9 +525,10 @@ class JobManager:
self._job_view.pop(mediaid)
# 移除季集信息
if mediaid in self._season_episodes:
episodes = getattr(task.meta, "episode_list", None) or []
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
- set(task.meta.episode_list)
- set(episodes)
)
return task, mediaid
return None, None
@@ -2022,7 +2023,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
):
# 下载记录中已存在识别信息
mediainfo: Optional[MediaInfo] = self.recognize_media(
mtype=MediaType(download_history.type),
mtype=task.mtype or MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid,
bangumiid=download_history.bangumiid,
@@ -2045,6 +2046,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
if task.mtype:
recognize_kwargs["mtype"] = task.mtype
mediainfo = MediaChain().recognize_by_meta(
task.meta, **recognize_kwargs
)
@@ -2055,10 +2058,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
if task.mtype:
recognize_kwargs["mtype"] = task.mtype
mediainfo = MediaChain().recognize_by_meta(
task.meta, **recognize_kwargs
)
# 音乐必须先经过音乐元数据模块识别;远端不可用时再保留本地标签结果,
# 避免因离线兜底提前赋值而跳过音乐识别链。
if not mediainfo and isinstance(task.meta, MetaMusic):
mediainfo = self._music_info_from_meta(task.meta)
# 按名称识别时已在识别链路补图这里只补齐显式ID识别的场景。
if mediainfo and need_obtain_images:
self.obtain_images(mediainfo=mediainfo)
@@ -3745,8 +3755,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_meta, task_mediainfo = self._match_music_album_context(
file_item, file_path, file_meta
)
if not task_mediainfo:
task_mediainfo = self._music_info_from_meta(file_meta)
if (
not manual
and self._is_movie_year_conflict(file_meta, task_mediainfo)
@@ -3759,6 +3767,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta=file_meta,
mediainfo=task_mediainfo,
media_source=media_source,
mtype=mtype,
target_directory=target_directory,
target_storage=target_storage,
target_path=target_path,

View File

@@ -44,6 +44,8 @@ class SystemConfModel(BaseModel):
fanart: int = 0
# MusicBrainz请求缓存数量
musicbrainz: int = 0
# TheAudioDB请求缓存数量
theaudiodb: int = 0
# ListenBrainz请求缓存数量
listenbrainz: int = 0
# 元数据缓存过期时间(秒)
@@ -205,11 +207,11 @@ class ConfigModel(BaseModel):
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# ==================== 媒体元数据配置 ====================
# 媒体搜索来源 themoviedb/douban/bangumi/anilist多个用,分隔
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban/bangumi/anilist
# 媒体识别来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban/bangumi/anilist
# 刮削来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
SCRAP_SOURCE: str = "themoviedb"
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS: List[int] = Field(default=[16])
@@ -229,6 +231,8 @@ class ConfigModel(BaseModel):
# ==================== 音乐配置 ====================
# 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)
MUSIC_COVER_PROXY: str = ""
# TheAudioDB API Key默认使用官方公开的免费 V1 Key可通过环境变量覆盖
THEAUDIODB_API_KEY: str = "123"
# ==================== TVDB配置 ====================
# TVDB API Key
@@ -527,6 +531,7 @@ class ConfigModel(BaseModel):
"anilist.co",
"coverartarchive.org",
"archive.org",
"theaudiodb.com",
"commons.wikimedia.org",
"upload.wikimedia.org",
]
@@ -999,6 +1004,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
bangumi=512,
fanart=512,
musicbrainz=512,
theaudiodb=512,
listenbrainz=256,
meta=(self.META_CACHE_EXPIRE or 72) * 3600,
scheduler=100,
@@ -1012,6 +1018,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
bangumi=256,
fanart=128,
musicbrainz=256,
theaudiodb=256,
listenbrainz=128,
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
scheduler=50,

View File

@@ -212,9 +212,14 @@ class MusicInfo:
return None
@property
def douban_id(self) -> None:
"""音乐不使用豆瓣 ID兼容现有下载历史字段"""
return None
def douban_id(self) -> str | None:
"""豆瓣音乐来源返回原生条目 ID其它音乐源保持兼容空值"""
return self.media_id if self.source == "doubanmusic" else None
@property
def theaudiodb_id(self) -> str | None:
"""TheAudioDB 来源返回原生条目 ID供通用身份解析复用。"""
return self.media_id if self.source == "theaudiodb" else None
@property
def bangumi_id(self) -> None:

View File

@@ -1,4 +1,12 @@
from .metabase import MetaBase
from .metavideo import MetaVideo
from .metaanime import MetaAnime
from .metamusic import MetaMusic
from .metamusic import (
MetaMusic,
MusicNameContext,
MusicNameParseResult,
MusicNameParser,
MusicNamePattern,
MusicNamePatternMatch,
MusicNameRegistry,
)

File diff suppressed because it is too large Load Diff

View File

@@ -16,19 +16,22 @@ class AudioMetadataHelper:
@classmethod
def read(cls, path: Path) -> MetaMusic:
"""读取本地音频文件标签;读取失败时返回基于文件名的最小元数据"""
fallback = MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=path.suffix.lstrip(".").upper() or None,
)
"""读取本地音频标签,并以完整文件名模式和目录线索补充缺失字段"""
def filename_fallback() -> MetaMusic:
"""构造无标签结果,完整文件名解析只在确有需要时执行。"""
return MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=path.suffix.lstrip(".").upper() or None,
).apply_path_context(path)
try:
audio = MutagenFile(path, easy=True)
except Exception as err:
logger.warning(f"读取音频标签失败:{path} - {err}")
return fallback
return filename_fallback()
if not audio:
return fallback
return filename_fallback()
tags = audio.tags or {}
track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber"))
@@ -52,7 +55,7 @@ class AudioMetadataHelper:
bitrate=cls._optional_int(getattr(info, "bitrate", None)),
duration=round(info.length) if info and getattr(info, "length", None) else None,
isrc=cls._first(tags, "isrc"),
)
).apply_path_context(path)
@classmethod
def write(

View File

@@ -126,6 +126,7 @@
"指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID",
"媒体ID格式无效": "Invalid media ID format",
"MusicBrainz 只能用于音乐刮削": "MusicBrainz can only be used for music scraping",
"音乐元数据源只能用于音乐刮削": "Music metadata sources can only be used for music scraping",
"刮削失败,无法识别音乐信息": "Scraping failed: unable to recognize music information",
"刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information",
"刮削路径不存在": "Scraping path does not exist",

View File

@@ -126,6 +126,7 @@
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
"媒体ID格式无效": "媒體ID格式無效",
"MusicBrainz 只能用于音乐刮削": "MusicBrainz 只能用於音樂刮削",
"音乐元数据源只能用于音乐刮削": "音樂中繼資料來源只能用於音樂刮削",
"刮削失败,无法识别音乐信息": "刮削失敗,無法識別音樂資訊",
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
"刮削路径不存在": "刮削路徑不存在",

View File

@@ -229,6 +229,12 @@ class AniListModule(_ModuleBase):
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
# AniList 只处理动画影视,不能在音乐模块未响应时接管音乐请求。
if (
kwargs.get("mtype") == MediaType.MUSIC
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
@@ -258,6 +264,12 @@ class AniListModule(_ModuleBase):
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
# 与同步入口保持同一类型边界,音乐请求不得进入 AniList。
if (
kwargs.get("mtype") == MediaType.MUSIC
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = (

View File

@@ -93,6 +93,12 @@ class BangumiModule(_ModuleBase):
:param source: 请求级识别数据源
:return: 识别的媒体信息,包括剧集信息
"""
# Bangumi 只处理影视,不能在音乐模块未响应时接管音乐请求。
if (
kwargs.get("mtype") == MediaType.MUSIC
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):
@@ -129,6 +135,12 @@ class BangumiModule(_ModuleBase):
:param source: 请求级识别数据源
:return: 识别的媒体信息,包括剧集信息
"""
# 与同步入口保持同一类型边界,音乐请求不得进入 Bangumi。
if (
kwargs.get("mtype") == MediaType.MUSIC
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):

View File

@@ -1,12 +1,12 @@
import re
from typing import List, Optional, Tuple, Union
from typing import Any, List, Optional, Tuple, Union
import cn2an
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicAlbumInfo, MusicInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase
@@ -17,11 +17,14 @@ from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.limit import rate_limit_exponential
from app.utils.media import is_media_source_enabled
from app.utils.media import is_media_source_enabled, is_media_source_selected
from app.utils.zhconv import convert as zhconv_convert
class DoubanModule(_ModuleBase):
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
_music_source = "doubanmusic"
doubanapi: DoubanApi = None
scraper: DoubanScraper = None
@@ -48,6 +51,11 @@ class DoubanModule(_ModuleBase):
def get_name() -> str:
return "豆瓣"
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
return DoubanModule._music_source
@staticmethod
def get_type() -> ModuleType:
"""
@@ -69,6 +77,424 @@ class DoubanModule(_ModuleBase):
"""
return 2
def search_music(
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
) -> Optional[List[MusicInfo]]:
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
if not is_media_source_selected(source, self._music_source):
return None
keyword = meta.album or meta.title
if not keyword:
return []
result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100)))
return self._build_music_search_results(result)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按豆瓣音乐原生 ID 获取专辑或专辑内曲目详情。"""
if source != self._music_source or not media_id:
return None
album_id, separator, track_id = str(media_id).partition(":")
album = self.music_album(source, album_id)
if not album:
return None
if separator and track_id:
return next(
(
track for track in album.tracks
if track.media_id == media_id or str(track.track_number or "") == track_id
),
None,
)
return album.to_music_info()
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._music_source or not media_id:
return None
info = self.doubanapi.music_detail(subject_id=str(media_id))
return self._douban_music_to_album(info) if info else None
def _recognize_music_media(
self,
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
) -> Optional[MusicInfo]:
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
return None
resolved_media_id = mediaid or (meta.media_id if meta else None)
if resolved_media_id:
return self.recognize_music(source, str(resolved_media_id))
if not meta:
return None
candidates = self.search_music(meta=meta, limit=20, source=source) or []
expected_title = meta.album or meta.title
for candidate in candidates:
if not self._same_music_text(expected_title, candidate.title):
continue
if meta.artists and candidate.artists and not any(
self._same_music_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue
if meta.album and meta.title:
album = self.music_album(source, candidate.media_id)
matched_track = self._select_douban_music_track(meta, album)
if matched_track:
return matched_track
continue
return candidate
return None
async def _async_recognize_music_media(
self,
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
) -> Optional[MusicInfo]:
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
return None
resolved_media_id = mediaid or (meta.media_id if meta else None)
if resolved_media_id:
album_id, separator, track_id = str(resolved_media_id).partition(":")
info = await self.doubanapi.async_music_detail(subject_id=album_id)
album = self._douban_music_to_album(info) if info else None
if not album:
return None
if separator and track_id:
return next(
(
track for track in album.tracks
if track.media_id == resolved_media_id
or str(track.track_number or "") == track_id
),
None,
)
return album.to_music_info()
if not meta:
return None
keyword = meta.album or meta.title
if not keyword:
return None
result = await self.doubanapi.async_music_search(keyword=keyword, count=20)
candidates = self._build_music_search_results(result)
expected_title = meta.album or meta.title
for candidate in candidates:
if not self._same_music_text(expected_title, candidate.title):
continue
if meta.artists and candidate.artists and not any(
self._same_music_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue
if meta.album and meta.title:
info = await self.doubanapi.async_music_detail(
subject_id=str(candidate.media_id)
)
album = self._douban_music_to_album(info) if info else None
matched_track = self._select_douban_music_track(meta, album)
if matched_track:
return matched_track
continue
return candidate
return None
@classmethod
def _select_douban_music_track(
cls,
meta: MetaMusic,
album: Optional[MusicAlbumInfo],
) -> Optional[MusicInfo]:
"""从豆瓣专辑曲目中选择与本地曲名、艺术家及曲序最一致的音轨。"""
if not album:
return None
candidates = [
track for track in album.tracks
if cls._same_music_text(meta.title, track.title)
]
if meta.artists:
candidates = [
track for track in candidates
if any(
cls._same_music_text(expected, actual)
for expected in meta.artists
for actual in track.artists
)
]
if not candidates:
return None
candidates.sort(
key=lambda track: (
bool(meta.track_number and track.track_number == meta.track_number),
-abs((meta.duration or track.duration or 0) - (track.duration or meta.duration or 0)),
),
reverse=True,
)
return candidates[0]
@classmethod
def _build_music_search_results(cls, result: Optional[dict]) -> List[MusicInfo]:
"""把豆瓣音乐搜索响应转换为专辑候选列表。"""
items = (result or {}).get("items") or (result or {}).get("musics") or []
candidates = []
for item in items:
if not isinstance(item, dict):
continue
target_type = str(item.get("target_type") or item.get("type") or "").casefold()
target = item.get("target") if isinstance(item.get("target"), dict) else item
type_name = str(target.get("type_name") or target.get("subtype") or "")
if target_type and target_type not in {"music", "音乐"}:
continue
if type_name and type_name not in {"音乐", "music"}:
continue
media_id = cls._douban_music_text(
target.get("id") or item.get("target_id") or item.get("id")
)
title = cls._douban_music_text(target.get("title") or target.get("name"))
if not media_id or not title:
continue
artists = cls._douban_music_artists(target)
release_date = cls._douban_music_date(target)
cover_url = cls._douban_music_cover(target)
candidate = MusicInfo(
source=cls._music_source,
media_id=media_id,
music_type=MUSIC_ENTITY_ALBUM,
title=title,
artists=artists,
album=title,
album_artist=" / ".join(artists) or None,
album_id=media_id,
year=cls._douban_music_year(target.get("year") or release_date),
release_date=release_date,
cover_url=cover_url,
names=[title],
detail_link=f"https://music.douban.com/subject/{media_id}/",
)
candidates.append(candidate)
return candidates
@classmethod
def _douban_music_to_album(cls, info: dict[str, Any]) -> Optional[MusicAlbumInfo]:
"""把豆瓣音乐详情转换为标准专辑信息和曲目。"""
media_id = cls._douban_music_text(info.get("id") or info.get("subject_id"))
title = cls._douban_music_text(info.get("title") or info.get("name"))
if not media_id or not title:
return None
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
artists = cls._douban_music_artists(info)
release_date = cls._douban_music_date(info)
tags = [
cls._douban_music_text(item.get("name") if isinstance(item, dict) else item)
for item in (info.get("tags") or [])
]
genres = [str(item) for item in info.get("genres") or [] if item]
rating = info.get("rating") if isinstance(info.get("rating"), dict) else {}
album = MusicAlbumInfo(
source=cls._music_source,
media_id=media_id,
title=title,
artists=artists,
album_type=cls._douban_music_first(
info.get("media") or attrs.get("media")
) or "Album",
release_date=release_date,
cover_url=cls._douban_music_cover(info),
genres=genres,
tags=[item for item in tags if item],
rating=cls._douban_music_float(rating.get("value") or rating.get("average")),
rating_votes=cls._douban_music_int(
rating.get("count") or rating.get("numRaters") or info.get("ratings_count")
),
detail_link=f"https://music.douban.com/subject/{media_id}/",
raw_data={
"overview": cls._douban_music_text(info.get("intro") or info.get("summary")),
"publisher": cls._douban_music_first(
info.get("publisher") or attrs.get("publisher")
),
},
)
album.tracks = cls._douban_music_tracks(info, album)
return album
@classmethod
def _douban_music_tracks(
cls,
info: dict[str, Any],
album: MusicAlbumInfo,
) -> List[MusicInfo]:
"""从豆瓣新旧响应结构中提取专辑曲目。"""
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
# Frodo 当前音乐详情使用 songstracks/attrs.tracks 兼容旧接口响应。
tracks = info.get("songs") or info.get("tracks") or attrs.get("tracks") or []
if isinstance(tracks, str):
tracks = tracks.splitlines()
elif not isinstance(tracks, list):
tracks = []
elif len(tracks) == 1 and isinstance(tracks[0], str) and "\n" in tracks[0]:
tracks = tracks[0].splitlines()
results = []
for index, item in enumerate(tracks, start=1):
if isinstance(item, dict):
title = cls._douban_music_text(item.get("title") or item.get("name"))
track_number = cls._douban_music_int(item.get("track_number") or item.get("position")) or index
duration = cls._douban_music_int(item.get("duration"))
duration = duration if duration and duration > 0 else None
disc_number = cls._douban_music_int(
item.get("disc_number") or item.get("disc")
)
artists = cls._douban_music_artists(item) or list(album.artists)
cover_url = cls._douban_music_text(item.get("cover_url")) or album.cover_url
raw_data = {
key: value
for key, value in {
"apple_album_id": item.get("apple_album_id"),
"apple_track_id": item.get("apple_track_id"),
"preview_url": item.get("preview_url"),
}.items()
if value not in (None, "")
}
else:
title = cls._clean_douban_track_title(item)
track_number = index
duration = None
disc_number = None
artists = list(album.artists)
cover_url = album.cover_url
raw_data = {}
if not title:
continue
results.append(MusicInfo(
source=cls._music_source,
# 豆瓣歌曲没有独立 subject ID使用专辑内绝对顺序避免多碟曲序重复。
media_id=f"{album.media_id}:{index}",
title=title,
artists=artists,
album=album.title,
album_artist=album.artist or None,
album_id=album.media_id,
album_type=album.album_type,
year=album.year,
release_date=album.release_date,
disc_number=disc_number,
track_number=track_number,
duration=duration,
cover_url=cover_url,
genres=list(album.genres),
names=[title],
detail_link=album.detail_link,
raw_data=raw_data,
))
for track in results:
track.total_tracks = len(results)
return results
@classmethod
def _douban_music_artists(cls, info: dict[str, Any]) -> List[str]:
"""从豆瓣新旧响应结构中提取艺术家名称。"""
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
values = (
info.get("artists")
or info.get("artist_names")
or info.get("author")
or info.get("singer")
or attrs.get("singer")
or []
)
if isinstance(values, str):
values = [values]
artists = []
seen = set()
for item in values:
value = item.get("name") if isinstance(item, dict) else item
text = cls._douban_music_text(value)
identity = MetaMusic.compact_text(text) if text else ""
if not text or identity in seen:
continue
seen.add(identity)
artists.append(text)
return artists
@classmethod
def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]:
"""从豆瓣多种图片字段中提取清晰封面。"""
pic = info.get("pic") if isinstance(info.get("pic"), dict) else {}
cover = info.get("cover") if isinstance(info.get("cover"), dict) else {}
cover_img = info.get("cover_img") if isinstance(info.get("cover_img"), dict) else {}
return next(
(
text for value in [
pic.get("large"),
cover_img.get("url"),
cover.get("large"),
cover.get("normal"),
info.get("cover_url"),
info.get("image"),
]
if (text := cls._douban_music_text(value))
),
None,
)
@classmethod
def _douban_music_date(cls, info: dict[str, Any]) -> Optional[str]:
"""从豆瓣新旧响应结构中提取首个发行日期。"""
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
return cls._douban_music_first(info.get("pubdate") or attrs.get("pubdate"))
@staticmethod
def _clean_douban_track_title(value: Any) -> Optional[str]:
"""清理豆瓣旧接口曲目文本开头的序号。"""
text = str(value or "").strip()
return re.sub(r"^\s*(?:\d+[\.、)]\s*)", "", text) or None
@staticmethod
def _douban_music_text(value: Any) -> Optional[str]:
"""把豆瓣外部响应值转换为去空白文本。"""
text = str(value).strip() if value is not None else ""
return text or None
@classmethod
def _douban_music_first(cls, value: Any) -> Optional[str]:
"""从豆瓣列表或标量字段中提取首个文本。"""
if isinstance(value, list):
return next((text for item in value if (text := cls._douban_music_text(item))), None)
return cls._douban_music_text(value)
@staticmethod
def _douban_music_int(value: Any) -> Optional[int]:
"""将豆瓣外部响应值安全转换为整数。"""
try:
return int(value) if value not in (None, "") else None
except (TypeError, ValueError):
return None
@staticmethod
def _douban_music_float(value: Any) -> float:
"""将豆瓣外部评分安全转换为浮点数。"""
try:
return float(value) if value not in (None, "") else 0.0
except (TypeError, ValueError):
return 0.0
@classmethod
def _douban_music_year(cls, value: Any) -> Optional[int]:
"""从豆瓣年份或日期文本中提取四位年份。"""
text = cls._douban_music_text(value)
return int(text[:4]) if text and text[:4].isdigit() else None
@staticmethod
def _same_music_text(left: Optional[str], right: Optional[str]) -> bool:
"""使用音乐元数据紧凑文本规则比较豆瓣候选。"""
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
@staticmethod
def _prepare_search_names(meta: MetaBase) -> List[str]:
"""
@@ -258,6 +684,16 @@ class DoubanModule(_ModuleBase):
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
source = kwargs.get("source")
if source == self._music_source:
return self._recognize_music_media(
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
)
# 音乐请求必须显式使用 doubanmusic避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
return None
return self._recognize_media_core(
meta=meta,
mtype=mtype,
@@ -278,6 +714,16 @@ class DoubanModule(_ModuleBase):
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
source = kwargs.get("source")
if source == self._music_source:
return await self._async_recognize_music_media(
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
)
# 音乐请求必须显式使用 doubanmusic避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
return None
return await self._async_recognize_media_core(
meta=meta,
mtype=mtype,

View File

@@ -370,6 +370,31 @@ class DoubanApi(metaclass=WeakSingleton):
return await self.__async_invoke_search(self._urls["book_search"], q=keyword,
start=start, count=count, _ts=ts)
def music_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
"""搜索豆瓣音乐条目。"""
return self.__invoke_search(
self._urls["search_subject"],
type="music",
q=keyword,
start=start,
count=count,
_ts=ts,
)
async def async_music_search(self, keyword: str, start: Optional[int] = 0,
count: Optional[int] = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
"""异步搜索豆瓣音乐条目。"""
return await self.__async_invoke_search(
self._urls["search_subject"],
type="music",
q=keyword,
start=start,
count=count,
_ts=ts,
)
def group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
@@ -590,6 +615,14 @@ class DoubanApi(metaclass=WeakSingleton):
"""
return await self.__async_invoke_search(self._urls["book_detail"] + subject_id)
def music_detail(self, subject_id: str) -> dict:
"""获取豆瓣音乐详情。"""
return self.__invoke_search(self._urls["music_detail"] + subject_id)
async def async_music_detail(self, subject_id: str) -> dict:
"""异步获取豆瓣音乐详情。"""
return await self.__async_invoke_search(self._urls["music_detail"] + subject_id)
def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""

View File

@@ -10,7 +10,6 @@ from requests import Session
from app.core.cache import cached
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
@@ -22,6 +21,7 @@ from app.modules import _ModuleBase
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_selected
from app.utils.zhconv import convert as zhconv_convert
@@ -115,6 +115,11 @@ class MusicBrainzModule(_ModuleBase):
"""返回模块展示名称。"""
return "MusicBrainz"
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
return MusicBrainzModule._source
@staticmethod
def get_type() -> ModuleType:
"""返回模块所属的媒体识别类型。"""
@@ -130,8 +135,15 @@ class MusicBrainzModule(_ModuleBase):
"""音乐识别在所有 MediaRecognize 模块中最先响应,避免音乐请求被影视模块误识别。"""
return 0
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
def search_music(
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
) -> Optional[list[MusicInfo]]:
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
if not is_media_source_selected(source, self._source):
return None
normalized_limit = max(1, min(limit, 100))
recordings = self._search_recordings(meta, limit=normalized_limit)
albums = self._search_albums(meta, limit=normalized_limit)
@@ -623,6 +635,9 @@ class MusicBrainzModule(_ModuleBase):
**kwargs,
) -> Optional[MusicInfo]:
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
if source and source != self._source:
return None
# 非音乐请求交给影视识别模块,不占用识别管线
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
return None

View File

@@ -0,0 +1,514 @@
from typing import Any, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from app.core.cache import cached
from app.core.config import settings
from app.core.context import (
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_selected
class TheAudioDbModule(_ModuleBase):
"""通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。"""
_source = "theaudiodb"
_base_url = "https://www.theaudiodb.com/api/v1/json"
_detail_url = "https://www.theaudiodb.com"
def init_module(self) -> None:
"""初始化无状态的 TheAudioDB 模块。"""
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""TheAudioDB 使用环境配置中的 API Key无独立启用开关。"""
return None
def stop(self) -> None:
"""停止模块;当前实现没有需要释放的持久资源。"""
def test(self) -> Tuple[bool, str]:
"""测试 TheAudioDB 艺术家搜索接口连通性。"""
result = self._request_json("search.php", {"s": "coldplay"})
return (True, "") if result is not None else (False, "TheAudioDB 网络连接失败")
@staticmethod
def get_name() -> str:
"""返回模块展示名称。"""
return "TheAudioDB"
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
return TheAudioDbModule._source
@staticmethod
def get_type() -> ModuleType:
"""返回模块所属的媒体识别类型。"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""返回 TheAudioDB 模块子类型。"""
return MediaRecognizeType.TheAudioDB
@staticmethod
def get_priority() -> int:
"""返回音乐识别优先级,位于默认 MusicBrainz 之后。"""
return 1
def search_music(
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
) -> Optional[list[MusicInfo]]:
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
if not is_media_source_selected(source, self._source):
return None
normalized_limit = max(1, min(limit, 100))
tracks = self._search_tracks(meta)
albums = self._search_albums(meta)
artists = self._search_artists(meta)
return self._interleave_results(
tracks,
albums,
artists,
limit=normalized_limit,
)
def recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
if source != self._source:
return None
if not isinstance(meta, MetaMusic):
if mtype == MediaType.MUSIC and mediaid:
return self.recognize_music(source, str(mediaid))
return None
resolved_media_id = mediaid or meta.media_id
if resolved_media_id:
return self.recognize_music(source, str(resolved_media_id))
matched = self._select_track(meta, self._search_tracks(meta))
if matched:
return matched
album = self._select_album(meta, self._search_albums(meta))
return album.to_music_info() if album else None
async def async_recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""在线程池执行 TheAudioDB 同步识别,避免阻塞事件循环。"""
return await run_in_threadpool(
self.recognize_media,
meta,
mtype=mtype,
source=source,
mediaid=mediaid,
**kwargs,
)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 TheAudioDB 原生 ID 获取单曲详情,未命中时回退到专辑。"""
if source != self._source or not media_id:
return None
payload = self._request_json("track.php", {"h": media_id})
track = self._first_entity(payload, "track", "tracks")
if track:
return self._track_to_info(track)
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._source or not media_id:
return None
payload = self._request_json("album.php", {"m": media_id})
item = self._first_entity(payload, "album", "albums")
if not item:
return None
album = self._album_to_info(item)
tracks_payload = self._request_json("track.php", {"m": media_id})
album.tracks = [
info
for track in self._entities(tracks_payload, "track", "tracks")
if (info := self._track_to_info(track, album=album))
]
return album
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
if source != self._source or not media_id:
return None
payload = self._request_json("artist.php", {"i": media_id})
item = self._first_entity(payload, "artists", "artist")
return self._artist_to_info(item) if item else None
def music_artist_albums(
self,
source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""按 TheAudioDB 艺术家 ID 分页返回专辑列表。"""
if source != self._source or not media_id:
return []
payload = self._request_json("album.php", {"i": media_id})
albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
if album_type:
normalized_type = album_type.casefold()
albums = [
album for album in albums
if (album.album_type or "").casefold() == normalized_type
]
start = max(page - 1, 0) * max(1, count)
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
def clear_cache(self) -> None:
"""清除 TheAudioDB 请求缓存。"""
self._request_json.cache_clear()
def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]:
"""使用曲名和艺术家搜索 TheAudioDB 单曲。"""
title = meta.title
if not title:
return []
params = {"t": title}
if meta.artists:
params["s"] = meta.artists[0]
payload = self._request_json("searchtrack.php", params)
return [
info
for item in self._entities(payload, "track", "tracks")
if (info := self._track_to_info(item))
]
def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]:
"""使用专辑名和艺术家搜索 TheAudioDB 专辑。"""
album_name = meta.album or meta.title
if not album_name:
return []
params = {"a": album_name}
if meta.artists:
params["s"] = meta.artists[0]
payload = self._request_json("searchalbum.php", params)
return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
def _search_artists(self, meta: MetaMusic) -> list[MusicArtistInfo]:
"""使用艺术家线索搜索 TheAudioDB 艺术家。"""
name = meta.artists[0] if meta.artists else meta.title
if not name:
return []
payload = self._request_json("search.php", {"s": name})
return [
self._artist_to_info(item)
for item in self._entities(payload, "artists", "artist")
]
@classmethod
def _select_track(
cls,
meta: MetaMusic,
candidates: list[MusicInfo],
) -> Optional[MusicInfo]:
"""按曲名和可用艺术家线索选择可信单曲候选。"""
for candidate in candidates:
if not cls._same_text(meta.title, candidate.title):
continue
if meta.artists and not any(
cls._same_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue
return candidate
return None
@classmethod
def _select_album(
cls,
meta: MetaMusic,
candidates: list[MusicAlbumInfo],
) -> Optional[MusicAlbumInfo]:
"""按专辑名和可用艺术家线索选择可信专辑候选。"""
expected_title = meta.album or meta.title
for candidate in candidates:
if not cls._same_text(expected_title, candidate.title):
continue
if meta.artists and not any(
cls._same_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue
return candidate
return None
@classmethod
def _track_to_info(
cls,
item: dict[str, Any],
album: Optional[MusicAlbumInfo] = None,
) -> Optional[MusicInfo]:
"""将 TheAudioDB 单曲响应转换为标准音乐信息。"""
media_id = cls._text(item.get("idTrack") or item.get("id"))
title = cls._text(item.get("strTrack") or item.get("name"))
if not media_id or not title:
return None
artist = cls._text(item.get("strArtist"))
artist_id = cls._text(item.get("idArtist"))
duration_ms = cls._optional_int(item.get("intDuration"))
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
return MusicInfo(
source=cls._source,
media_id=media_id,
title=title,
artists=[artist] if artist else list(album.artists if album else []),
artist_ids=[artist_id] if artist_id else list(album.artist_ids if album else []),
album=cls._text(item.get("strAlbum")) or (album.title if album else None),
album_artist=artist or (album.artist if album else None),
album_id=cls._text(item.get("idAlbum")) or (album.media_id if album else None),
year=album.year if album else None,
release_date=album.release_date if album else None,
disc_number=cls._optional_int(item.get("intCD")),
track_number=cls._optional_int(item.get("intTrackNumber")),
duration=duration_ms // 1000 if duration_ms else None,
isrc=cls._text(item.get("strISRC")),
cover_url=cls._first_text(
item,
"strTrackThumb",
"strTrack3DCase",
) or (album.cover_url if album else None),
lyrics=cls._text(item.get("strTrackLyrics")),
category=" / ".join(genres),
genres=genres,
names=cls._unique_texts([title, item.get("strTrackAlternate")]),
detail_link=f"{cls._detail_url}/track/{media_id}",
raw_data={
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
"musicbrainz_album_id": cls._text(item.get("strMusicBrainzAlbumID")),
},
)
@classmethod
def _album_to_info(cls, item: dict[str, Any]) -> MusicAlbumInfo:
"""将 TheAudioDB 专辑响应转换为标准专辑信息。"""
media_id = cls._text(item.get("idAlbum") or item.get("id"))
title = cls._text(item.get("strAlbum") or item.get("name"))
artist = cls._text(item.get("strArtist"))
artist_id = cls._text(item.get("idArtist"))
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
release_date = cls._text(item.get("strReleaseDate"))
if not release_date:
release_date = cls._text(item.get("intYearReleased"))
return MusicAlbumInfo(
source=cls._source,
media_id=media_id,
title=title,
artists=[artist] if artist else [],
artist_ids=[artist_id] if artist_id else [],
album_type=cls._text(item.get("strReleaseFormat")),
release_date=release_date,
cover_url=cls._first_text(
item,
"strAlbumThumbHQ",
"strAlbumThumb",
"strAlbum3DCase",
"strAlbumCDart",
),
genres=genres,
tags=cls._unique_texts([item.get("strMood"), item.get("strStyle")]),
rating=cls._optional_float(item.get("intScore")),
rating_votes=cls._optional_int(item.get("intScoreVotes")),
detail_link=f"{cls._detail_url}/album/{media_id}" if media_id else None,
raw_data={"description": cls._localized_text(item, "strDescription")},
)
@classmethod
def _artist_to_info(cls, item: dict[str, Any]) -> MusicArtistInfo:
"""将 TheAudioDB 艺术家响应转换为标准艺术家信息。"""
media_id = cls._text(item.get("idArtist") or item.get("id"))
name = cls._text(item.get("strArtist") or item.get("name"))
links = {}
website = cls._text(item.get("strWebsite"))
if website:
links["official homepage"] = website
return MusicArtistInfo(
source=cls._source,
media_id=media_id,
name=name,
disambiguation=cls._text(item.get("strArtistAlternate")),
artist_type=cls._text(item.get("strStyle")),
gender=cls._text(item.get("strGender")),
country=cls._text(item.get("strCountry")),
begin_date=cls._text(item.get("intFormedYear") or item.get("intBornYear")),
end_date=cls._text(item.get("intDiedYear") or item.get("strDisbanded")),
ended=bool(item.get("intDiedYear") or item.get("strDisbanded")),
genres=cls._unique_texts([item.get("strGenre"), item.get("strStyle")]),
aliases=cls._split_text(item.get("strArtistAlternate")),
image_url=cls._first_text(
item,
"strArtistThumb",
"strArtistFanart",
"strArtistWideThumb",
"strArtistCutout",
),
detail_link=f"{cls._detail_url}/artist/{media_id}" if media_id else None,
external_links=links,
raw_data={
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
"biography": cls._localized_text(item, "strBiography"),
},
)
@staticmethod
def _interleave_results(
tracks: list[MusicInfo],
albums: list[MusicAlbumInfo],
artists: list[MusicArtistInfo],
limit: int,
) -> list[MusicInfo]:
"""交错合并三类搜索结果,避免单一实体占满候选列表。"""
groups = [tracks, [item.to_music_info() for item in albums], [item.to_music_info() for item in artists]]
results: list[MusicInfo] = []
for index in range(max((len(group) for group in groups), default=0)):
for group in groups:
if index < len(group):
results.append(group[index])
if len(results) >= limit:
return results
return results
@classmethod
@cached(maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True)
def _request_json(
cls,
endpoint: str,
params: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
"""请求 TheAudioDB V1 JSON 接口并统一处理错误响应。"""
api_key = str(settings.THEAUDIODB_API_KEY or "").strip()
if not api_key:
logger.warning("TheAudioDB API Key 未配置,跳过请求")
return None
response = RequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
).get_res(
url=f"{cls._base_url}/{api_key}/{endpoint}",
params=params or {},
)
if not response or response.status_code != 200:
return None
try:
payload = response.json()
except ValueError as err:
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
return None
return payload if isinstance(payload, dict) else None
@staticmethod
def _entities(
payload: Optional[dict[str, Any]],
*keys: str,
) -> list[dict[str, Any]]:
"""从兼容 V1/V2 命名的响应字段中提取实体列表。"""
if not payload:
return []
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
return [value]
return []
@classmethod
def _first_entity(
cls,
payload: Optional[dict[str, Any]],
*keys: str,
) -> Optional[dict[str, Any]]:
"""返回响应中的首个实体。"""
entities = cls._entities(payload, *keys)
return entities[0] if entities else None
@staticmethod
def _text(value: Any) -> Optional[str]:
"""把外部响应值转换为去空白文本。"""
text = str(value).strip() if value is not None else ""
return text or None
@classmethod
def _first_text(cls, item: dict[str, Any], *keys: str) -> Optional[str]:
"""按优先级返回外部响应中的首个非空文本。"""
return next((text for key in keys if (text := cls._text(item.get(key)))), None)
@classmethod
def _localized_text(cls, item: dict[str, Any], prefix: str) -> Optional[str]:
"""优先返回中文说明,不存在时回退到英文说明。"""
return cls._first_text(item, f"{prefix}CN", f"{prefix}EN")
@staticmethod
def _optional_int(value: Any) -> Optional[int]:
"""将外部响应值安全转换为整数。"""
try:
return int(value) if value not in (None, "") else None
except (TypeError, ValueError):
return None
@staticmethod
def _optional_float(value: Any) -> float:
"""将外部评分安全转换为浮点数。"""
try:
return float(value) if value not in (None, "") else 0.0
except (TypeError, ValueError):
return 0.0
@classmethod
def _split_text(cls, value: Any) -> list[str]:
"""把分号或斜线分隔的外部文本转换为去重列表。"""
text = cls._text(value)
if not text:
return []
return cls._unique_texts(text.replace("/", ";").split(";"))
@classmethod
def _unique_texts(cls, values: list[Any]) -> list[str]:
"""过滤空值并按大小写无关方式去重。"""
results = []
seen = set()
for value in values:
text = cls._text(value)
identity = text.casefold() if text else ""
if not text or identity in seen:
continue
seen.add(identity)
results.append(text)
return results
@staticmethod
def _same_text(left: Optional[str], right: Optional[str]) -> bool:
"""使用音乐元数据紧凑文本规则比较标题和艺术家。"""
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))

View File

@@ -476,6 +476,9 @@ class TheMovieDbModule(_ModuleBase):
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
# 验证参数
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
return None
@@ -562,6 +565,9 @@ class TheMovieDbModule(_ModuleBase):
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
# 验证参数
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
return None

View File

@@ -9,6 +9,7 @@ from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
from app.schemas.types import MediaType
class DownloaderTorrent(BaseModel):
@@ -63,6 +64,7 @@ class TransferTask(BaseModel):
meta: Optional[Any] = None
mediainfo: Optional[Any] = None
media_source: Optional[str] = None
mtype: Optional[MediaType] = None
target_directory: Optional[TransferDirectoryConf] = None
target_storage: Optional[str] = None
target_path: Optional[Path] = None

View File

@@ -423,6 +423,8 @@ class MediaRecognizeType(Enum):
AniList = "AniList"
# MusicBrainz
MusicBrainz = "MusicBrainz"
# TheAudioDB
TheAudioDB = "TheAudioDB"
# 用户配置Key字典

View File

@@ -9,6 +9,11 @@ MEDIA_SOURCE_ALIASES = {
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
"musicbrainz": "musicbrainz",
"theaudiodb": "theaudiodb",
"audio_db": "theaudiodb",
"doubanmusic": "doubanmusic",
"douban_music": "doubanmusic",
}
MEDIA_SOURCE_PREFIXES = {
@@ -16,6 +21,9 @@ MEDIA_SOURCE_PREFIXES = {
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
"musicbrainz": "musicbrainz",
"theaudiodb": "theaudiodb",
"doubanmusic": "doubanmusic",
}
MEDIA_SOURCE_ID_FIELDS = {
@@ -23,8 +31,19 @@ MEDIA_SOURCE_ID_FIELDS = {
"douban": ("douban_id", "doubanid"),
"bangumi": ("bangumi_id", "bangumiid"),
"anilist": ("anilist_id", "anilistid"),
"musicbrainz": ("media_id",),
"theaudiodb": ("media_id", "theaudiodb_id"),
"doubanmusic": ("media_id", "douban_id", "doubanid"),
}
MUSIC_MEDIA_SOURCE_ORDER = ("musicbrainz", "theaudiodb", "doubanmusic")
MUSIC_MEDIA_SOURCES = frozenset(MUSIC_MEDIA_SOURCE_ORDER)
def is_music_media_source(source: Optional[str]) -> bool:
"""判断单个请求级来源是否为内置音乐元数据源。"""
return normalize_media_source(source) in MUSIC_MEDIA_SOURCES
def normalize_media_source(source: Optional[str]) -> Optional[str]:
"""规范化媒体数据源名称,兼容外部使用的 ``tmdb`` 前缀。"""

View File

@@ -15,6 +15,7 @@ from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
from app.agent.tools.impl.recognize_media import RecognizeMediaTool
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
from app.agent.tools.impl.search_media import SearchMediaTool
from app.core.context import (
@@ -60,6 +61,33 @@ def _album() -> MusicInfo:
)
def test_recognize_music_title_uses_media_chain_automatic_sources():
"""Agent 音乐标题识别应进入 MediaChain 自动多源流程,不再固定 MusicBrainz。"""
expected = _recording()
recognize = AsyncMock(return_value=expected)
tool = RecognizeMediaTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.recognize_media.MediaChain.async_recognize_by_meta",
new=recognize,
):
result = asyncio.run(
tool.run(
title="晴天",
media_type="music",
artist="周杰伦",
album="叶惠美",
)
)
payload = json.loads(result)
assert payload["media_info"]["media_source"] == "musicbrainz"
recognized_meta = recognize.await_args.args[0]
assert recognized_meta.artists == ["周杰伦"]
assert recognized_meta.album == "叶惠美"
assert "source" not in recognize.await_args.kwargs
def test_search_media_filters_music_entities_and_returns_stable_identity():
"""音乐搜索应区分单曲和专辑,并返回后续工具可复用的来源 ID。"""
async_search = AsyncMock(return_value=[_recording(), _album()])

View File

@@ -112,6 +112,49 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
assert meta.audio_format == "MP3"
def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, monkeypatch):
"""标签不可读时应直接使用完整动态模式解析复杂音乐文件名。"""
audio_path = tmp_path / (
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
)
audio_path.write_bytes(b"fake-flac")
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
meta = AudioMetadataHelper.read(audio_path)
assert meta.artists == ["S.H.E"]
assert meta.title == "S.H.E十七音乐会"
assert meta.year == 2018
assert meta.audio_format == "FLAC"
def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields(
tmp_path,
monkeypatch,
):
"""真实标签优先,缺失的艺术家和年份由完整文件名解析补齐。"""
audio_path = tmp_path / "Daft Punk - Get Lucky 2013 FLAC.flac"
audio_path.write_bytes(b"fake-flac")
audio = SimpleNamespace(
tags={"title": ["Tagged Title"]},
info=SimpleNamespace(
length=369.4,
bitrate=1411200,
bits_per_sample=16,
sample_rate=44100,
),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read(audio_path)
assert meta.title == "Tagged Title"
assert meta.artists == ["Daft Punk"]
assert meta.year == 2013
assert meta.duration == 369
assert meta.sample_rate == 44100
def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
"""音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。"""
class FakeAudio:

View File

@@ -515,7 +515,7 @@ def test_chain_recognize_media_reports_music_share_result():
meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003)
music = _music_info()
with patch.object(chain, "run_module", return_value=music), patch(
with patch("app.chain.music.MusicChain.recognize_best", return_value=music), patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=True,
) as report_mock, patch(
@@ -534,11 +534,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
meta = MetaMusic(title="晴天", artists=["周杰伦"])
music = _music_info()
with patch.object(
chain,
"run_module",
side_effect=[None, music],
) as run_module, patch(
with patch(
"app.chain.music.MusicChain.recognize_best",
return_value=None,
) as recognize_best, patch(
"app.chain.music.MusicChain.recognize_from_source",
return_value=music,
) as recognize_source, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"},
), patch(
@@ -563,11 +565,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
result = chain.recognize_media(meta=meta, cache=False)
assert result is music
assert run_module.call_count == 2
second_call = run_module.call_args_list[1]
assert second_call.kwargs["mtype"] == MediaType.MUSIC
assert second_call.kwargs["source"] == "musicbrainz"
assert second_call.kwargs["mediaid"] == "recording-1"
recognize_best.assert_called_once_with(meta=meta, cache=False)
recognize_source.assert_called_once_with(
source="musicbrainz",
meta=meta,
mediaid="recording-1",
cache=False,
)
def test_chain_recognize_media_skips_music_report_for_fallback_result():
@@ -576,7 +580,7 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result():
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"])
with patch.object(chain, "run_module", return_value=fallback), patch(
with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), patch(
"app.chain.MoviePilotServerHelper.recognize_report"
) as report_mock, patch(
"app.chain.settings.MEDIA_RECOGNIZE_SHARE", True

View File

@@ -146,4 +146,28 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
fileitem=fileitem,
mediainfo=info,
overwrite=True,
source="musicbrainz",
)
def test_scrape_music_without_source_keeps_automatic_recognition() -> None:
"""未选择音乐源时刮削入口应传递空来源,让底层比较全部识别源。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
media_chain = Mock()
media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain):
result = scrape(
fileitem=fileitem,
storage="local",
type_name=MediaType.MUSIC,
_=Mock(),
)
assert result.success is True
media_chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=None,
overwrite=True,
source=None,
)

View File

@@ -1,4 +1,15 @@
from app.core.meta import MetaMusic
from typing import Optional
import pytest
from app.core.meta import (
MetaMusic,
MusicNameContext,
MusicNameParseResult,
MusicNameParser,
MusicNamePattern,
MusicNameRegistry,
)
def parse_title(title: str) -> MetaMusic:
@@ -6,6 +17,279 @@ def parse_title(title: str) -> MetaMusic:
return MetaMusic(org_string=title, title=title, parse_title=True)
def test_music_name_registry_supports_dynamic_pattern_and_parser():
"""外部程序可独立注册命名模式和解析器,并在使用后完整注销。"""
pattern_name = "test_program"
parser_name = "test_program_parser"
def match_program(context: MusicNameContext):
"""匹配测试程序的双冒号命名。"""
if not context.text.startswith("PROGRAM::"):
return None
parts = context.text.split("::")
return parts if len(parts) == 3 else None
def parse_program(context, matched):
"""把测试程序命名解析为艺术家和标题。"""
_prefix, artist, title = matched.payload
return MusicNameParseResult(
title=title,
artists=[artist],
year=context.year,
)
MusicNameRegistry.register_pattern(
MusicNamePattern(pattern_name, match_program, priority=1000)
)
MusicNameRegistry.register_parser(
MusicNameParser(parser_name, (pattern_name,), parse_program, priority=1000)
)
try:
context = MusicNameContext(
raw="PROGRAM::周杰伦::晴天",
normalized="PROGRAM::周杰伦::晴天",
text="PROGRAM::周杰伦::晴天",
artists=(),
)
matched = MusicNameRegistry.match_pattern(context)
parser = MusicNameRegistry.match_parser(matched)
assert matched.pattern_name == pattern_name
assert parser.name == parser_name
# FLAC 由公共层剔除,扩展解析器只需处理自身命名结构。
meta = parse_title("PROGRAM::周杰伦::晴天 FLAC")
assert meta.artists == ["周杰伦"]
assert meta.title == "晴天"
assert meta.audio_format == "FLAC"
finally:
MusicNameRegistry.unregister_parser(parser_name)
MusicNameRegistry.unregister_pattern(pattern_name)
assert parser_name not in {parser.name for parser in MusicNameRegistry.get_parsers()}
assert pattern_name not in {pattern.name for pattern in MusicNameRegistry.get_patterns()}
@pytest.mark.parametrize(
("raw", "artists", "title", "year", "audio_format"),
[
(
"The Beatles - Vinyl Collection【2020】【CD】【FLAC分轨】",
["The Beatles"],
"Vinyl Collection",
2020,
"FLAC",
),
(
"Primeval - Forged In Earth【2026】【WEB】【FLAC分轨】(24/48bit)",
["Primeval"],
"Forged In Earth",
2026,
"FLAC",
),
(
"Professor Green - Alive Till I'm Dead 2010-FLAC 分轨-nbarock",
["Professor Green"],
"Alive Till I'm Dead",
2010,
"FLAC",
),
(
"Togenashi Togeari 5th One Man Live Moments of Sound 2025 "
"1080p BluRay x265 10bit FLAC 2.0-ADE",
[],
"Togenashi Togeari 5th One Man Live Moments of Sound",
2025,
"FLAC",
),
(
"田震 - 田震 (1996) FLAC {HRS-004-2}",
["田震"],
"田震",
1996,
"FLAC",
),
(
"[2022.02.23] 中恵光城 - SELENiTE -Mitsuki Nakae Works Best Album- "
"[CD][FLAC+CUE+LOG+BK][KDSD-01049]",
["中恵光城"],
"SELENiTE -Mitsuki Nakae Works Best Album",
None,
"FLAC",
),
(
"[260123] 映画「超かぐや姫!」劇中曲「超かぐや姫! 」 "
"[48kHz/24bit][FLAC]",
[],
"映画「超かぐや姫!」劇中曲「超かぐや姫! 」",
None,
"FLAC",
),
(
"[Audio-4U] 茶太 — Chata 1.0 (flac)",
["茶太"],
"Chata 1.0",
None,
"FLAC",
),
],
)
def test_apply_title_real_site_music_samples(
raw: str,
artists: list[str],
title: str,
year: Optional[int],
audio_format: str,
):
"""真实站点音乐种子标题应剔除公共干扰并保留有效命名字段。"""
meta = parse_title(raw)
assert meta.artists == artists
assert meta.title == title
assert meta.year == year
assert meta.audio_format == audio_format
@pytest.mark.parametrize(
("raw", "artists", "title", "year"),
[
(
"Aimer-Aimer Hall Tour 2022 ''Walpurgisnacht'' Live at "
"TOKYO GARDEN THEATER Blu-ray 1080p AVC LPCM 2.0",
[],
"Aimer-Aimer Hall Tour ''Walpurgisnacht'' Live at TOKYO GARDEN THEATER",
2022,
),
(
"MANATSU NO ZENKOKU TOUR 2021 FINAL! IN TOKYO DOME "
"Blu-ray 1080p AVC LPCM 2.0",
[],
"MANATSU NO ZENKOKU TOUR FINAL! IN TOKYO DOME",
2021,
),
(
"Rainie Yang - Ban Shu Xuan Yan 2008 DVD 480i MPEG-2 MPEG-2",
["Rainie Yang"],
"Ban Shu Xuan Yan",
2008,
),
(
"SARD UNDERGROUND LIVE TOUR 2025 FANTASY "
"Blu-ray 1080p AVC LPCM2.0",
[],
"SARD UNDERGROUND LIVE TOUR FANTASY",
2025,
),
(
"Kylie: Tension Tour Live 2026 2160p NF WEB-DL "
"DDP 5.1 H.265-CHORTLE",
[],
"Kylie: Tension Tour Live",
2026,
),
(
"Nogizaka46 2021 'Kimi ni Shikarareta' Type-A, B, C, D"
"Blu-ray 1080p AVC",
[],
"Nogizaka46 'Kimi ni Shikarareta' Type-A, B, C, D",
2021,
),
(
"SBS Korea Pop Music Festival in Summer 2026 "
"1080p AAC 2.0 x264@JJL",
[],
"SBS Korea Pop Music Festival in Summer",
2026,
),
(
"RTHK31 China Philharmonic Orchestra Concert Series - "
"23rd Anniversary Concert 260704 1080i HDTV H264-NGBRTHK31",
["RTHK31 China Philharmonic Orchestra Concert Series"],
"23rd Anniversary Concert",
2026,
),
],
)
def test_music_video_scene_pattern_uses_music_specific_token_parser(
raw: str,
artists: list[str],
title: str,
year: int,
):
"""影视式音乐资源应按音乐 token 语义清理,且保留年份后的演出名称。"""
context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None)
matched = MusicNameRegistry.match_pattern(context)
meta = parse_title(raw)
assert matched.pattern_name == "music_video_scene"
assert meta.artists == artists
assert meta.title == title
assert meta.year == year
@pytest.mark.parametrize(
"raw",
[
"Daft Punk - Random Access Memories 2013 FLAC",
"[Audio-4U] 茶太 — Chata 1.0 (flac)",
],
)
def test_music_video_scene_pattern_requires_combined_video_signature(raw: str):
"""普通音频标题只有格式或版本数字时,不得误入音乐视频场景模式。"""
context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None)
matched = MusicNameRegistry.match_pattern(context)
assert matched.pattern_name != "music_video_scene"
@pytest.mark.parametrize(
("raw", "artists", "title", "year", "audio_format"),
[
(
"The Bug Club - On the Intricate Inner Workings of the System "
"2025-FLAC 分軌-Redacted",
["The Bug Club"],
"On the Intricate Inner Workings of the System",
2025,
"FLAC",
),
(
"李宇春 - 皇后与梦想 - 2006-FLAC分轨-OpenCD-九月萌",
["李宇春"],
"皇后与梦想",
2006,
"FLAC",
),
("西班牙幻想曲SACD", [], "西班牙幻想曲", None, "DSD"),
("無字天碟 IndefinableWAV+CUE原抓", [], "無字天碟 Indefinable", None, "WAV"),
("刘星-无所事事WAV+CUE原抓", ["刘星"], "无所事事", None, "WAV"),
("喜多郎-古事记SACD", ["喜多郎"], "古事记", None, "DSD"),
("巫启贤太傻黄金版WAV分轨原抓", [], "巫启贤太傻(黄金版)", None, "WAV"),
(
"王若琳 - The Adult Storybook 2009 SACD",
["王若琳"],
"The Adult Storybook",
2009,
"DSD",
),
],
)
def test_common_audio_release_noise_is_removed(
raw: str,
artists: list[str],
title: str,
year: Optional[int],
audio_format: str,
):
"""真实音频发布尾链只提供格式和年份,不应污染艺术家或标题。"""
meta = parse_title(raw)
assert meta.artists == artists
assert meta.title == title
assert meta.year == year
assert meta.audio_format == audio_format
def test_strip_track_prefix_handles_dot_separator():
"""曲序前缀 01. 应剥离并返回曲名。"""
track, disc, title = MetaMusic.split_track_prefix("01.晴天")
@@ -135,6 +419,48 @@ def test_apply_path_context_keeps_existing_tags(tmp_path):
assert meta.year == 2004
def test_apply_path_context_uses_full_dynamic_filename_parser(tmp_path):
"""无标签文件名应进入完整动态模式,清理音乐视频场景规格。"""
audio_file = tmp_path / (
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
)
audio_file.write_bytes(b"fake-flac")
meta = MetaMusic(
org_string=audio_file.name,
title=audio_file.stem,
audio_format="FLAC",
)
meta.apply_path_context(audio_file)
assert meta.artists == ["S.H.E"]
assert meta.title == "S.H.E十七音乐会"
assert meta.year == 2018
assert meta.audio_format == "FLAC"
def test_apply_path_context_only_fills_missing_tag_fields(tmp_path):
"""部分标签存在时保留标签值,只从完整文件名解析补充空字段。"""
audio_file = tmp_path / "周杰伦 - 文件名曲目 2018 FLAC.flac"
audio_file.write_bytes(b"fake-flac")
meta = MetaMusic(
org_string=audio_file.name,
title="标签曲名",
artists=[],
album="标签专辑",
year=2020,
)
meta.apply_path_context(audio_file)
assert meta.title == "标签曲名"
assert meta.artists == ["周杰伦"]
assert meta.album == "标签专辑"
assert meta.year == 2020
assert meta.audio_format == "FLAC"
assert meta.audio_lossless is True
def test_apply_title_splits_artist_and_track():
"""标准「歌手 - 曲名」种子标题应拆分艺术家与曲名。"""
meta = parse_title("周杰伦 - 晴天")
@@ -180,6 +506,10 @@ def test_apply_title_splits_latin_hyphen_artist_album():
assert meta.year == 1971
# 左侧单词Heize-Undo与右侧发布组标签不触发拆分
assert parse_title("Heize-Undo.2022.FLAC").artists == []
# 全大写复合词属于艺术家名本身,不能从 KUNG-FU 中间拆开。
compound = parse_title("ASIAN KUNG-FU GENERATION Discography (2003-2026) [FLAC]")
assert compound.artists == []
assert compound.title == "ASIAN KUNG-FU GENERATION Discography (2003-2026)"
def test_apply_title_splits_various_artists_prefix():
@@ -348,6 +678,19 @@ def test_apply_title_album_marker():
assert meta.year == 2006
def test_apply_title_bilingual_album_marker_prefix():
"""双语原声命名应保留英文艺术家/标题,不把整段前缀当成艺术家。"""
meta = parse_title(
"Max Richter - Ad Astra Original Motion Picture Soundtrack "
"马克斯·里希特 - 《星际探索》电影原声带 2019 FLAC-SeedPool"
)
assert meta.artists == ["Max Richter"]
assert meta.title == "Ad Astra Original Motion Picture Soundtrack"
assert meta.album == "星际探索"
assert meta.year == 2019
def test_apply_title_strips_cue_and_plus():
"""APE+CUE 类格式联合写法应剔除,残留加号不阻断标题提取。"""
meta = parse_title("世界著名古典大师名版收藏15RCA发烧古典系列-2007-FLAC-APE+CUE")
@@ -365,6 +708,17 @@ def test_apply_title_cjk_hyphen_artist_suffix():
assert meta.title == "因为有你"
def test_apply_title_does_not_split_ascii_hyphen_inside_cjk_title():
"""CJK 标题中的 A-on 等拉丁复合词不能生成虚假的艺术家。"""
meta = parse_title(
"[250226] 重戦機エルガイム A-on STORE 連動特典"
"「重戦機エルガイム(カセット版復刻CD)」 [FLAC+CUE]"
)
assert meta.artists == []
assert meta.title.startswith("重戦機エルガイム A-on STORE")
def test_apply_title_double_em_dash_split():
"""双破折号分隔的「主题——歌手」写法应拆分艺术家。"""
meta = parse_title("为你盛开——许巍 无尽光芒巡回演唱会 2025")
@@ -455,3 +809,11 @@ def test_apply_title_collection_with_space_sample_rate():
assert meta.title is None
assert meta.year == 2022
assert meta.audio_format == "FLAC"
@pytest.mark.parametrize("title", ["孙楠 - 楠得精选 2001", "[合集] 缘之空音乐合集 [FLAC]"])
def test_apply_title_keeps_collection_words_inside_work_name(title: str):
"""合集/精选嵌在作品名中时是有效文字,只清理独立发行标签。"""
meta = parse_title(title)
assert "精选" in (meta.title or "") or "合集" in (meta.title or "")

View File

@@ -1,4 +1,4 @@
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
from app.chain.media import MediaChain
from app.chain.music import MusicChain
@@ -6,6 +6,7 @@ from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.modules.musicbrainz import MusicBrainzModule
from app.schemas.types import MediaType
def test_parse_query_supports_artist_title_format():
@@ -321,7 +322,167 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(meta=meta, source="musicbrainz")
recognize.assert_awaited_once_with(meta=meta, source=None)
def test_recognize_best_compares_all_sources_and_prefers_stronger_evidence(monkeypatch):
"""自动识别应查询全部来源,并让专辑、时长和曲序证据更完整的候选胜出。"""
chain = MusicChain()
meta = MetaMusic(
title="Yellow",
artists=["Coldplay"],
album="Parachutes",
duration=269,
track_number=5,
)
candidates = {
"musicbrainz": MusicInfo(
source="musicbrainz",
media_id="mb-1",
title="Yellow",
artists=["Coldplay"],
album="Greatest Hits",
duration=240,
track_number=1,
),
"theaudiodb": MusicInfo(
source="theaudiodb",
media_id="adb-1",
title="Yellow",
artists=["Coldplay"],
album="Parachutes",
duration=269,
track_number=5,
),
"doubanmusic": MusicInfo(
source="doubanmusic",
media_id="db-1:5",
title="Yellow",
artists=["Coldplay"],
album="Parachutes",
),
}
requested = []
def fake_recognize_source(_meta, source, _cache):
"""按来源返回候选并记录实际查询顺序。"""
requested.append(source)
return candidates[source]
monkeypatch.setattr(chain, "_recognize_from_source", fake_recognize_source)
result = chain.recognize_best(meta)
assert requested == ["musicbrainz", "theaudiodb", "doubanmusic"]
assert result is candidates["theaudiodb"]
def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
"""单源识别只允许调用声明该音乐来源的模块,忽略同接口影视模块。"""
chain = MusicChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
video_module = Mock(spec=["recognize_media"])
music_module = Mock(spec=["get_music_source", "recognize_media"])
music_module.get_music_source.return_value = "musicbrainz"
music_module.recognize_media.return_value = expected
monkeypatch.setattr(
chain.modulemanager,
"get_running_modules",
Mock(return_value=[video_module, music_module]),
)
result = chain.recognize_from_source(
source="musicbrainz",
meta=meta,
cache=True,
)
assert result is expected
video_module.recognize_media.assert_not_called()
music_module.recognize_media.assert_called_once_with(
meta=meta,
mtype=MediaType.MUSIC,
source="musicbrainz",
mediaid=None,
cache=True,
)
def test_recognize_best_uses_source_order_only_for_equal_scores(monkeypatch):
"""候选证据完全相同时应按 MusicBrainz、TheAudioDB、豆瓣音乐顺序稳定选择。"""
chain = MusicChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
candidates = {
source: MusicInfo(
source=source,
media_id=f"{source}-1",
title="晴天",
artists=["周杰伦"],
)
for source in ("musicbrainz", "theaudiodb", "doubanmusic")
}
monkeypatch.setattr(
chain,
"_recognize_from_source",
lambda _meta, source, _cache: candidates[source],
)
assert chain.recognize_best(meta) is candidates["musicbrainz"]
def test_async_recognize_best_queries_sources_concurrently(monkeypatch):
"""异步自动识别应并发查询各来源,而不是串行等待三个远端请求。"""
import asyncio
chain = MusicChain()
active = 0
max_active = 0
async def fake_async_recognize_source(_meta, source, _cache):
"""记录同时执行的来源数并返回同分候选。"""
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.01)
active -= 1
return MusicInfo(
source=source,
media_id=f"{source}-1",
title="晴天",
artists=["周杰伦"],
)
monkeypatch.setattr(
chain,
"_async_recognize_from_source",
fake_async_recognize_source,
)
result = asyncio.run(
chain.async_recognize_best(MetaMusic(title="晴天", artists=["周杰伦"]))
)
assert max_active == 3
assert result and result.source == "musicbrainz"
def test_recognize_candidate_rejects_wrong_artist_even_when_title_matches():
"""已知艺术家时,同名异人的候选不能仅凭曲名命中。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
candidate = MusicInfo(
source="theaudiodb",
media_id="wrong-1",
title="晴天",
artists=["其他歌手"],
)
assert MusicChain._recognize_candidate_score(meta, candidate) is None
def test_async_chart_forwards_album_entity(monkeypatch):

View File

@@ -80,6 +80,33 @@ def test_media_search_routes_music_queries_with_query_kwarg():
media_chain.return_value.async_search.assert_not_called()
def test_media_search_forwards_explicit_music_source():
"""统一音乐搜索应把显式选择的可扩展音乐源转发给 MusicChain。"""
chain = Mock()
chain.async_search = AsyncMock(return_value=[])
with (
patch("app.api.endpoints.media.MusicChain", return_value=chain),
patch.object(media_endpoints, "MediaChain"),
):
result = asyncio.run(
media_endpoints.search(
title="Coldplay",
type="music",
count=20,
source="theaudiodb",
_=Mock(),
)
)
assert result == []
chain.async_search.assert_awaited_once_with(
query="Coldplay",
limit=20,
source="theaudiodb",
)
def test_recognize_music_returns_detail():
"""音乐识别接口应按来源和 ID 经统一识别入口返回详情。"""
from app.chain.media import MediaChain

View File

@@ -0,0 +1,278 @@
"""TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。"""
from unittest.mock import AsyncMock, Mock
import pytest
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.core.meta import MetaMusic
from app.modules.douban import DoubanModule
from app.modules.theaudiodb import TheAudioDbModule
from app.schemas.types import MediaRecognizeType, MediaType
def test_theaudiodb_module_maps_track_and_album(monkeypatch):
"""TheAudioDB 原生响应应保留来源 ID并换算毫秒时长。"""
module = TheAudioDbModule()
request = Mock(side_effect=[
{
"track": [{
"idTrack": "32793500",
"strTrack": "Yellow",
"strArtist": "Coldplay",
"idArtist": "111239",
"idAlbum": "2109619",
"strAlbum": "Parachutes",
"intTrackNumber": "5",
"intDuration": "269000",
"strTrackThumb": "https://www.theaudiodb.com/images/track.jpg",
}]
},
{"album": []},
{"artists": []},
])
monkeypatch.setattr(module, "_request_json", request)
results = module.search_music(
MetaMusic(title="Yellow", artists=["Coldplay"]),
source="theaudiodb",
)
assert results and len(results) == 1
assert results[0].source == "theaudiodb"
assert results[0].media_id == "32793500"
assert results[0].album_id == "2109619"
assert results[0].duration == 269
assert module.get_subtype() == MediaRecognizeType.TheAudioDB
def test_theaudiodb_module_ignores_other_sources(monkeypatch):
"""显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。"""
module = TheAudioDbModule()
request = Mock()
monkeypatch.setattr(module, "_request_json", request)
searched = module.search_music(MetaMusic(title="Yellow"), source="musicbrainz")
recognized = module.recognize_media(
meta=MetaMusic(title="Yellow"),
source="doubanmusic",
)
assert searched is None
assert recognized is None
request.assert_not_called()
def test_douban_music_search_and_album_mapping(monkeypatch):
"""豆瓣模块应把音乐条目映射为专辑,并生成可用于曲目识别的复合 ID。"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.music_search.return_value = {
"items": [{
"target_type": "music",
"target": {
"id": "1401853",
"title": "范特西",
"artists": [{"name": "周杰伦"}],
"year": "2001",
},
}]
}
module.doubanapi.music_detail.return_value = {
"id": "1401853",
"title": "范特西",
"singer": [{"id": "1050015", "name": "周杰伦"}],
"pubdate": ["2001-09-14"],
"media": ["CD"],
"publisher": ["阿尔发音乐"],
"songs": [
{
"title": "爱在西元前",
"track_number": 1,
"artist_names": ["周杰伦"],
"duration": 221,
},
{
"title": "爸我回来了",
"track_number": 2,
"artist_names": ["周杰伦"],
"cover_url": "https://img.example/track.jpg",
},
],
"rating": {"average": "9.4", "numRaters": "12345"},
}
results = module.search_music(
MetaMusic(title="范特西", artists=["周杰伦"]),
source="doubanmusic",
)
album = module.music_album("doubanmusic", "1401853")
assert results and results[0].source == "doubanmusic"
assert results[0].music_type == MUSIC_ENTITY_ALBUM
assert album and album.source == "doubanmusic"
assert album.year == 2001
assert album.artists == ["周杰伦"]
assert album.album_type == "CD"
assert [track.media_id for track in album.tracks] == ["1401853:1", "1401853:2"]
assert album.tracks[0].title == "爱在西元前"
assert album.tracks[0].duration == 221
assert album.tracks[1].cover_url == "https://img.example/track.jpg"
def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
"""自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.music_search.return_value = {
"items": [{
"target_type": "music",
"target": {
"id": "1401853",
"title": "范特西",
"artists": [{"name": "周杰伦"}],
},
}]
}
module.doubanapi.music_detail.return_value = {
"id": "1401853",
"title": "范特西",
"singer": [{"name": "周杰伦"}],
"songs": [
{"title": "爱在西元前", "track_number": 1},
{"title": "爸我回来了", "track_number": 2},
],
}
result = module.recognize_media(
meta=MetaMusic(
title="爸我回来了",
artists=["周杰伦"],
album="范特西",
track_number=2,
),
source="doubanmusic",
)
assert result and result.music_type == "recording"
assert result.media_id == "1401853:2"
assert result.album == "范特西"
def test_douban_music_mapping_keeps_legacy_attrs_tracks():
"""豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。"""
album = DoubanModule._douban_music_to_album({
"id": "1401853",
"title": "范特西",
"attrs": {
"singer": ["周杰伦"],
"tracks": ["01. 爱在西元前", "02. 爸我回来了"],
},
})
assert album and album.artists == ["周杰伦"]
assert [track.title for track in album.tracks] == ["爱在西元前", "爸我回来了"]
@pytest.mark.asyncio
async def test_douban_music_async_recognize_maps_real_songs(monkeypatch):
"""异步豆瓣自动识别应从真实 songs 字段返回具体音轨。"""
module = DoubanModule()
module.doubanapi = Mock()
module.doubanapi.async_music_search = AsyncMock(return_value={
"items": [{
"target_type": "music",
"target": {"id": "1401853", "title": "范特西"},
}],
})
module.doubanapi.async_music_detail = AsyncMock(return_value={
"id": "1401853",
"title": "范特西",
"singer": [{"name": "周杰伦"}],
"songs": [
{"title": "爱在西元前", "track_number": 1},
{"title": "爸我回来了", "track_number": 2},
],
})
result = await module.async_recognize_media(
meta=MetaMusic(
title="爱在西元前",
artists=["周杰伦"],
album="范特西",
),
source="doubanmusic",
)
assert result and result.media_id == "1401853:1"
assert result.title == "爱在西元前"
assert result.artists == ["周杰伦"]
@pytest.mark.asyncio
async def test_douban_recognize_media_routes_only_douban_music(monkeypatch):
"""豆瓣音乐使用独立 source不能与影视豆瓣入口或其它音乐源串线。"""
module = DoubanModule()
expected = MusicInfo(source="doubanmusic", media_id="1401853", title="范特西")
recognize_music = Mock(return_value=expected)
recognize_video = Mock()
async_recognize_music = AsyncMock(return_value=expected)
monkeypatch.setattr(module, "_recognize_music_media", recognize_music)
monkeypatch.setattr(module, "_recognize_media_core", recognize_video)
monkeypatch.setattr(module, "_async_recognize_music_media", async_recognize_music)
recognized = module.recognize_media(
meta=MetaMusic(title="范特西"),
source="doubanmusic",
mediaid="1401853",
)
ignored = module.recognize_media(
meta=MetaMusic(title="范特西"),
source="theaudiodb",
)
async_recognized = await module.async_recognize_media(
mtype=MediaType.MUSIC,
source="doubanmusic",
mediaid="1401853",
)
assert recognized is expected
assert ignored is None
assert async_recognized is expected
recognize_video.assert_not_called()
@pytest.mark.asyncio
async def test_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch):
"""音乐搜索默认 MusicBrainz手动选择时原样转发其它音乐源。"""
chain = MusicChain()
run_module = Mock(return_value=[])
async_run_module = AsyncMock(return_value=[])
monkeypatch.setattr(chain, "run_module", run_module)
monkeypatch.setattr(chain, "async_run_module", async_run_module)
chain.search("Yellow")
await chain.async_search("范特西", source="doubanmusic")
assert run_module.call_args.kwargs["source"] == "musicbrainz"
assert async_run_module.await_args.kwargs["source"] == "doubanmusic"
def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch):
"""无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。"""
path = tmp_path / "Yellow.flac"
path.write_bytes(b"audio")
expected = MusicInfo(source="theaudiodb", media_id="32793500", title="Yellow")
recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected))
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize)
result = MediaChain._resolve_music_scrape_info(
path,
mediainfo=None,
source="theaudiodb",
)
assert result is expected
recognize.assert_called_once_with(path, source="theaudiodb")

View File

@@ -292,7 +292,7 @@ def test_chain_recognize_media_music_plugin_supplement():
)
event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()})
with patch.object(chain, "run_module", return_value=fallback), \
with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), \
patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event), \
patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock:

View File

@@ -8,11 +8,15 @@
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.modules.anilist import AniListModule
from app.modules.bangumi import BangumiModule
from app.modules.musicbrainz import MusicBrainzModule
from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaType
@@ -35,12 +39,15 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
chain = MediaChain()
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
result = chain.recognize_by_meta(meta, source="musicbrainz")
result = chain.recognize_by_meta(
meta, source="musicbrainz", mtype=MediaType.MUSIC
)
# 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数
chain.recognize_media.assert_called_once()
call_kwargs = chain.recognize_media.call_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["mtype"] == MediaType.MUSIC
assert call_kwargs["source"] == "musicbrainz"
assert result is expected
@@ -53,12 +60,15 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa
monkeypatch.setattr(chain, "async_recognize_media", AsyncMock(return_value=expected))
async def runner():
return await chain.async_recognize_by_meta(meta, source="musicbrainz")
return await chain.async_recognize_by_meta(
meta, source="musicbrainz", mtype=MediaType.MUSIC
)
result = asyncio.run(runner())
chain.async_recognize_media.assert_awaited_once()
call_kwargs = chain.async_recognize_media.await_args.kwargs
assert call_kwargs["meta"] is meta
assert call_kwargs["mtype"] == MediaType.MUSIC
assert call_kwargs["source"] == "musicbrainz"
assert result is expected
@@ -119,7 +129,7 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(meta=meta, source="musicbrainz")
recognize.assert_awaited_once_with(meta=meta, source=None)
def test_musicbrainz_module_recognize_media_ignores_non_music():
@@ -130,6 +140,133 @@ def test_musicbrainz_module_recognize_media_ignores_non_music():
assert result is None
def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatch):
"""显式音乐类型和来源应只调用对应音乐模块,不遍历通用影视模块。"""
expected = _music_info()
chain = ChainBase()
recognize_source = Mock(return_value=expected)
generic_dispatch = Mock()
monkeypatch.setattr(MusicChain, "recognize_from_source", recognize_source)
monkeypatch.setattr(chain, "run_module", generic_dispatch)
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
with patch(
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
):
result = chain.recognize_media(
mtype=MediaType.MUSIC,
source="musicbrainz",
mediaid="recording-1",
)
assert result is expected
recognize_source.assert_called_once_with(
source="musicbrainz",
meta=None,
mediaid="recording-1",
cache=True,
)
generic_dispatch.assert_not_called()
def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatch):
"""音乐状态即使携带错误影视来源,也不得调用 TMDB 等通用识别模块。"""
chain = ChainBase()
generic_dispatch = Mock()
async_generic_dispatch = AsyncMock()
monkeypatch.setattr(chain, "run_module", generic_dispatch)
monkeypatch.setattr(chain, "async_run_module", async_generic_dispatch)
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
sync_result = chain.recognize_media(
mtype=MediaType.MUSIC,
source="themoviedb",
mediaid="123",
)
async_result = asyncio.run(chain.async_recognize_media(
mtype=MediaType.MUSIC,
source="themoviedb",
mediaid="123",
))
assert sync_result is None
assert async_result is None
generic_dispatch.assert_not_called()
async_generic_dispatch.assert_not_awaited()
def test_themoviedb_module_recognize_media_ignores_music(monkeypatch):
"""音乐模块未响应时 TMDB 的同步和异步入口均不得接管音乐请求。"""
module = TheMovieDbModule()
tmdb = Mock()
monkeypatch.setattr(module, "tmdb", tmdb)
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"))
by_type = module.recognize_media(mtype=MediaType.MUSIC, tmdbid=123)
async_result = asyncio.run(
module.async_recognize_media(meta=MetaMusic(title="晴天"))
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert tmdb.mock_calls == []
def test_bangumi_module_recognize_media_ignores_music(monkeypatch):
"""Bangumi 的同步和异步入口不得把音乐请求识别为动画影视。"""
module = BangumiModule()
api = Mock()
monkeypatch.setattr(module, "bangumiapi", api)
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="bangumi")
by_type = module.recognize_media(mtype=MediaType.MUSIC, bangumiid=123)
async_result = asyncio.run(
module.async_recognize_media(meta=MetaMusic(title="晴天"), source="bangumi")
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert api.mock_calls == []
def test_anilist_module_recognize_media_ignores_music(monkeypatch):
"""AniList 的同步和异步入口不得把音乐请求识别为动画影视。"""
module = AniListModule()
api = Mock()
monkeypatch.setattr(module, "anilist_api", api)
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="anilist")
by_type = module.recognize_media(mtype=MediaType.MUSIC, anilistid=123)
async_result = asyncio.run(
module.async_recognize_media(meta=MetaMusic(title="晴天"), source="anilist")
)
assert by_meta is None
assert by_type is None
assert async_result is None
assert api.mock_calls == []
def test_chain_obtain_images_skips_music_modules(monkeypatch):
"""音乐封面来自音乐元数据链,同步和异步补图入口均不得调用影视模块。"""
chain = MediaChain()
run_module = Mock()
async_run_module = AsyncMock()
monkeypatch.setattr(chain, "run_module", run_module)
monkeypatch.setattr(chain, "async_run_module", async_run_module)
music = _music_info()
result = chain.obtain_images(music)
async_result = asyncio.run(chain.async_obtain_images(music))
assert result is music
assert async_result is music
run_module.assert_not_called()
async_run_module.assert_not_awaited()
def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(monkeypatch):
"""meta 携带 source+media_id 时应走详情分支,不再触发搜索。"""
module = MusicBrainzModule()
@@ -202,12 +339,11 @@ def test_musicbrainz_module_async_recognize_media(monkeypatch):
def test_chain_recognize_media_returns_musicinfo_and_reports_share():
"""ChainBase.recognize_media 收到 MusicInfo 结果应与影视统一上报共享识别。"""
"""自动多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
chain.run_module = Mock(return_value=expected)
with patch(
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
with patch.object(MusicChain, "recognize_best", return_value=expected), patch(
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
) as report_mock:
result = chain.recognize_media(meta=MetaMusic(title="晴天"))
@@ -217,11 +353,14 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share():
def test_chain_async_recognize_media_returns_musicinfo_and_reports_share():
"""异步 ChainBase 收到 MusicInfo 结果应与影视统一上报共享识别。"""
"""异步多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
chain.async_run_module = AsyncMock(return_value=expected)
with patch(
with patch.object(
MusicChain,
"async_recognize_best",
AsyncMock(return_value=expected),
), patch(
"app.helper.server.MoviePilotServerHelper.async_report_recognize_share",
AsyncMock(),
) as report_mock:

View File

@@ -132,7 +132,7 @@ def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
)
assert success is False
assert message == "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
assert message == "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:

View File

@@ -1,8 +1,10 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from jinja2 import Template
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.transfer import JobManager, TransferChain
from app.core.config import settings
@@ -10,7 +12,8 @@ from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.helper.message import TemplateHelper
from app.schemas.file import FileItem
from app.schemas.transfer import TransferTask
from app.schemas.system import TransferDirectoryConf
from app.schemas.transfer import TransferInfo, TransferTask, TransferTorrent
from app.schemas.types import MediaType
@@ -277,6 +280,7 @@ def test_job_manager_serializes_music_queue_models():
),
meta=meta,
mediainfo=info,
mtype=MediaType.MUSIC,
)
manager = JobManager()
@@ -285,3 +289,154 @@ def test_job_manager_serializes_music_queue_models():
assert job.media.type == "音乐"
assert job.media.album == "Random Access Memories"
assert job.tasks[0].meta.type == "音乐"
assert task.mtype == MediaType.MUSIC
def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
"""无下载身份的音频应先走音乐识别,远端失败后再使用本地标签兜底。"""
audio_path = tmp_path / "周杰伦 - 晴天.flac"
audio_path.write_bytes(b"fake-flac")
source_item = FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
target_item = FileItem(
storage="local",
path=(tmp_path / "library" / audio_path.name).as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
)
recognized = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
recognize = Mock(return_value=recognized)
chain = TransferChain()
monkeypatch.setattr(MediaChain, "recognize_by_meta", recognize)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
Mock(side_effect=lambda *args, **kwargs: [(source_item, False)]),
)
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None))
monkeypatch.setattr(
chain,
"transfer",
Mock(
return_value=TransferInfo(
success=True,
fileitem=source_item,
target_item=target_item,
)
),
)
state, preview = chain.do_transfer(
fileitem=source_item,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
recognize.assert_called_once()
assert isinstance(recognize.call_args.args[0], MetaMusic)
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
assert preview["items"][0]["type"] == MediaType.MUSIC.value
recognize.reset_mock()
recognize.return_value = None
state, preview = chain.do_transfer(
fileitem=source_item,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
recognize.assert_called_once()
assert isinstance(recognize.call_args.args[0], MetaMusic)
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
assert preview["items"][0]["type"] == MediaType.MUSIC.value
def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
"""下载器自动整理应把下载历史中的音乐类型传入文件规划,且不调用影视补图模块。"""
audio_path = tmp_path / "晴天.flac"
audio_path.write_bytes(b"fake-flac")
recognized = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
history = SimpleNamespace(
type=MediaType.MUSIC.value,
tmdbid=None,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="musicbrainz",
media_id="recording-1",
episode_group=None,
media_category=None,
)
chain = TransferChain()
run_module = Mock()
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
lambda _: [
SimpleNamespace(
monitor_type="downloader",
storage="local",
download_path=tmp_path.as_posix(),
)
],
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper.get_by_hash",
lambda _, download_hash: history,
)
monkeypatch.setattr(
chain,
"list_torrents",
Mock(
return_value=[
TransferTorrent(
downloader="qbittorrent",
hash="hash-1",
path=audio_path,
)
]
),
)
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=recognized))
monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, "")))
monkeypatch.setattr(chain, "run_module", run_module)
state = chain.process()
assert state is True
assert chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC
assert chain.do_transfer.call_args.kwargs["mtype"] == MediaType.MUSIC
assert chain.do_transfer.call_args.kwargs["mediainfo"] is recognized
run_module.assert_not_called()