feat: 音乐识别与刮削统一入口整合

- 音乐识别统一走 recognize_media,移除 MusicChain.recognize/async_recognize 及 /music/search 独立端点
- 音乐刮削收拢到 MediaChain.scrape_music_metadata,消除 MusicChain 对 MediaChain 的嵌套引用
- MetaInfo/MetaInfoPath 增加 force_video 参数,影视附加音轨保留季集归属
- 整理、订阅、搜索、API 层音乐识别入口全部统一
This commit is contained in:
jxxghp
2026-08-09 10:06:54 +08:00
parent 7797d34c48
commit 60aa762ca1
19 changed files with 510 additions and 447 deletions

View File

@@ -201,10 +201,10 @@ async def search(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
模糊搜索媒体、合集人物信息列表。
模糊搜索媒体、合集人物或音乐信息列表。
:param title: 搜索关键词
:param type: 搜索类型,支持 media、collection、person
:param type: 搜索类型,支持 media、music、collection、person
:param page: 页码
:param count: 每页数量
:param source: 请求级搜索数据源
@@ -221,6 +221,13 @@ async def search(
return obj.source
media_chain = MediaChain()
if type == "music" or source == "musicbrainz":
# 音乐搜索统一入口,与影视搜索共用 /media/search
music_infos = await MusicChain().async_search(title=title, limit=count)
return [
info.to_dict()
for info in music_infos
] if music_infos else []
if type == "media":
_, medias = await media_chain.async_search(title=title, source=source)
result = [media.to_dict() for media in medias] if medias else []
@@ -287,13 +294,15 @@ def scrape(
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
music_info = MusicChain().recognize(
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
music_info = MediaChain().recognize_media(
source=media_source or "musicbrainz",
media_id=normalized_media_id,
mediaid=normalized_media_id,
mtype=MediaType.MUSIC,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
success, message = MusicChain().scrape_metadata(
success, message = MediaChain().scrape_music_metadata(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,

View File

@@ -3,7 +3,9 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from app import schemas
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.schemas.types import MediaType
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.security import verify_token
from app.modules.listenbrainz import (
@@ -45,21 +47,6 @@ def _serialize_artist(info: MusicArtistInfo) -> schemas.MusicArtistInfo:
return schemas.MusicArtistInfo(**info.to_dict())
@router.get(
"/search",
summary="搜索音乐元数据",
response_model=list[schemas.MusicInfo],
)
async def search_music(
query: str = Query(min_length=1),
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按歌曲、专辑或艺术家关键词搜索标准音乐候选。"""
results = await MusicChain().async_search(query=query, limit=count)
return [_serialize_music(info) for info in results]
@router.post(
"/recognize",
summary="识别音乐元数据详情",
@@ -69,10 +56,11 @@ async def recognize_music(
request: schemas.MusicRecognizeRequest,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicInfo:
"""根据音乐元数据来源和媒体 ID 获取标准详情。"""
info = await MusicChain().async_recognize(
"""根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口"""
info = await MediaChain().async_recognize_media(
source=request.source,
media_id=request.media_id,
mediaid=request.media_id,
mtype=MediaType.MUSIC,
)
if not info:
raise HTTPException(status_code=404, detail="未识别到音乐信息")

View File

@@ -14,7 +14,7 @@ from transmission_rpc import File
from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh
from app.core.config import settings
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.module import ModuleManager
@@ -683,7 +683,7 @@ class ChainBase(metaclass=ABCMeta):
bangumiid=bangumiid,
anilistid=anilistid,
)
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
# 检索显式 TMDB ID 由请求方自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
@@ -705,6 +705,9 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
MoviePilotServerHelper.report_recognize_share(
meta=meta,
@@ -713,7 +716,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and self._can_use_media_recognize_share(
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
@@ -815,6 +818,9 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
await MoviePilotServerHelper.async_report_recognize_share(
meta=meta,
@@ -823,7 +829,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and self._can_use_media_recognize_share(
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)

View File

@@ -1,7 +1,7 @@
import os
from copy import deepcopy
from pathlib import Path
from tempfile import NamedTemporaryFile
from tempfile import NamedTemporaryFile, TemporaryDirectory
from threading import Lock
from typing import Optional, List, Tuple, Union
@@ -9,11 +9,12 @@ from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import Context, MediaInfo
from app.core.context import Context, MediaInfo, MusicInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas import FileItem
from app.schemas.types import (
@@ -140,6 +141,7 @@ class ScrapingConfig:
("tv", ["nfo", "poster", "backdrop", "logo", "banner", "thumb", "clearart", "landscape"]),
("season", ["nfo", "poster", "backdrop", "banner", "thumb", "landscape"]),
("episode", ["nfo", "thumb"]),
("music", ["nfo", "poster"]),
]
for md in mds
]
@@ -741,10 +743,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return MusicChain().recognize_by_meta(metainfo, source=source)
return self.recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)
@@ -1061,6 +1065,16 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 刮削锁
with scraping_lock:
# 音乐刮削与影视共用 MediaChain 入口,按 ScrapingConfig 的音乐项写入标签与封面
if getattr(mediainfo, "type", None) == MediaType.MUSIC:
_, message = self.scrape_music_metadata(
fileitem=fileitem,
mediainfo=mediainfo,
overwrite=overwrite or self.scraping_policies.option("music", "nfo").is_overwrite,
)
if message:
logger.info(f"音乐刮削:{message}")
return
# 检查文件项是否存在
if not self.storagechain.get_item(fileitem):
logger.warn(f"文件项不存在:{fileitem.path}")
@@ -1380,6 +1394,132 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(f"{filepath.name} 刮削完成")
def scrape_music_metadata(
self,
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
音乐刮削被收拢到 MediaChain 统一分发,避免音乐链反向依赖媒体链造成嵌套。
"""
files = self._music_audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
# 读取音乐刮削策略music_nfo 控制标签写入music_poster 控制封面嵌入
nfo_option = self.scraping_policies.option("music", "nfo")
poster_option = self.scraping_policies.option("music", "poster")
if nfo_option.is_skip:
return False, "音乐标签刮削策略为跳过,请先在高级设置中开启"
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
# 未指定识别信息时按文件名识别音乐,识别阶段不读取文件标签
meta = MetaInfoPath(Path(audio_item.path))
info = self.recognize_media(meta=meta, mtype=MediaType.MUSIC, source="musicbrainz")
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_music_file(
audio_item,
info,
overwrite=overwrite or nfo_option.is_overwrite,
with_cover=not poster_option.is_skip,
):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
return False, "".join(failures[:3])
return True, f"已刮削 {len(files)} 个音频文件"
@staticmethod
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过统一请求封装下载音乐封面,并返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
response = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT,
timeout=20,
).get_res(url)
if not response:
return None, "image/jpeg"
try:
if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg"
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime
finally:
response.close()
@staticmethod
def _is_music_audio_file(path: str) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self._is_music_audio_file(fileitem.path or "") else []
return [
item
for item in self.storagechain.list_files(fileitem, recursion=True) or []
if item.type == "file" and self._is_music_audio_file(item.path or "")
]
def _scrape_music_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
overwrite: bool,
with_cover: bool = True,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = (
self._download_music_cover(mediainfo.cover_url) if with_cover else (None, "image/jpeg")
)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
):
return False
parent = storage.get_parent_item(fileitem)
if not parent:
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
return False
return bool(
storage.upload_file(
parent,
local_path,
new_name=fileitem.name or local_path.name,
)
)
def _handle_movie_scraping(
self,
fileitem: schemas.FileItem,
@@ -1739,10 +1879,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return await MusicChain().async_recognize_by_meta(metainfo, source=source)
return await self.async_recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)

View File

@@ -1,11 +1,9 @@
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Iterable, Optional
from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_RECORDING,
@@ -14,9 +12,7 @@ from app.core.context import (
MusicInfo,
)
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.utils.http import RequestUtils
class MusicChain(ChainBase):
@@ -84,76 +80,6 @@ class MusicChain(ChainBase):
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
return self.normalize_candidates(candidates, limit=limit)
def recognize(
self,
source: str,
media_id: str,
) -> Optional[MusicInfo]:
"""按音乐元数据源和媒体 ID 获取标准化详情。"""
result = self.run_module(
"recognize_music",
source=source,
media_id=media_id,
)
if isinstance(result, MusicInfo):
return result
if isinstance(result, dict):
return MusicInfo.from_dict(result)
return None
async def async_recognize(
self,
source: str,
media_id: str,
) -> Optional[MusicInfo]:
"""异步按音乐元数据源和媒体 ID 获取标准化详情。"""
result = await self.async_run_module(
"recognize_music",
source=source,
media_id=media_id,
)
if isinstance(result, MusicInfo):
return result
if isinstance(result, dict):
return MusicInfo.from_dict(result)
return None
def recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = self.recognize(resolved_source, str(meta.media_id))
if info:
return info
candidates = self.run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(
meta, results, source=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
async def async_recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = await self.async_recognize(resolved_source, str(meta.media_id))
if info:
return info
candidates = await self.async_run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(
meta, results, source=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
"""读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
candidates = self.run_module(
@@ -316,144 +242,31 @@ class MusicChain(ChainBase):
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def read_path_meta(cls, path: str | Path) -> MetaMusic:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return cls.parse_query(file_path.stem)
def parse_path_meta(cls, path: str | Path) -> MetaMusic:
"""音频文件名解析最小音乐元数据,识别阶段不读取文件标签"""
return cls.parse_query(Path(path).stem)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.read_path_meta(path)
candidates = await self.async_run_module(
"search_music",
meta=meta,
limit=10,
)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
if matched:
return meta, matched
return meta, self._info_from_meta(meta)
"""根据文件名的歌曲信息识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = await self.async_recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
def recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
meta = self.read_path_meta(path)
candidates = self.run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
return meta, matched or self._info_from_meta(meta)
def scrape_metadata(
self,
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,复用现有存储下载上传能力。"""
files = self._audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
_, info = self.recognize_by_path(audio_item.path)
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_audio_file(audio_item, info, overwrite=overwrite):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
return False, "".join(failures[:3])
return True, f"已刮削 {len(files)} 个音频文件"
@staticmethod
def _download_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过统一请求封装下载封面,并返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
response = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT,
timeout=20,
).get_res(url)
if not response:
return None, "image/jpeg"
try:
if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg"
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime
finally:
response.close()
def _audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self.is_audio_path(fileitem.path or "") else []
return [
item
for item in StorageChain().list_files(fileitem, recursion=True) or []
if item.type == "file" and self.is_audio_path(item.path or "")
]
def _scrape_audio_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
overwrite: bool,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = self._download_cover(mediainfo.cover_url)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
):
return False
parent = storage.get_parent_item(fileitem)
if not parent:
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
return False
return bool(
storage.upload_file(
parent,
local_path,
new_name=fileitem.name or local_path.name,
)
)
"""同步根据文件名识别音乐,并保留离线最小结果。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = self.recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
@classmethod
def to_meta(cls, info: MusicInfo) -> MetaMusic:
@@ -474,37 +287,6 @@ class MusicChain(ChainBase):
media_id=info.media_id,
)
@classmethod
def _select_path_candidate(
cls,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
source: str,
) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的文件识别候选。"""
normalized_source = cls._normalize_text(source).casefold()
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
if cls._same_text(meta.title, candidate.title):
score += 4
if meta.artists and any(
cls._same_text(meta.artists[0], artist)
for artist in candidate.artists
):
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
if meta.isrc and cls._same_text(meta.isrc, candidate.isrc):
score += 5
ranked.append((score, candidate))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
@@ -525,11 +307,6 @@ class MusicChain(ChainBase):
names=[name for name in (meta.title, meta.album) if name],
)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
"""忽略空白和大小写比较两个音乐文本字段。"""
return cls._normalize_text(left).casefold() == cls._normalize_text(right).casefold()
@classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""

View File

@@ -547,16 +547,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = MusicChain().recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 recognize_media 内路由到 MusicChain
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
return []
@@ -880,16 +875,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = await MusicChain().async_recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
@@ -1083,16 +1073,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = await MusicChain().async_recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '

View File

@@ -1340,29 +1340,31 @@ class SubscribeChain(ChainBase):
@staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
musicchain = MusicChain()
if subscribe.media_source and subscribe.media_id:
mediainfo = musicchain.recognize(
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = MediaChain().recognize_media(
source=subscribe.media_source,
media_id=str(subscribe.media_id),
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
)
if mediainfo:
return mediainfo
candidates = musicchain.search(subscribe.name, limit=1)
candidates = MusicChain().search(subscribe.name, limit=1)
return candidates[0] if candidates else None
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""异步按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
musicchain = MusicChain()
if subscribe.media_source and subscribe.media_id:
mediainfo = await musicchain.async_recognize(
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = await MediaChain().async_recognize_media(
source=subscribe.media_source,
media_id=str(subscribe.media_id),
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
)
if mediainfo:
return mediainfo
candidates = await musicchain.async_search(subscribe.name, limit=1)
candidates = await MusicChain().async_search(subscribe.name, limit=1)
return candidates[0] if candidates else None
def _search_music_subscribe(self, subscribe: Subscribe) -> None:

View File

@@ -13,7 +13,6 @@ from app import schemas
from app.agent import ReplyMode, prompt_manager, agent_manager
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
@@ -1591,7 +1590,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or not transferinfo
or not transferinfo.need_scrape
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
or task.mediainfo.type == MediaType.MUSIC
):
return
@@ -1655,7 +1653,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or not transferinfo
or not transferinfo.need_scrape
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
or task.mediainfo.type == MediaType.MUSIC
):
return
@@ -3154,8 +3151,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts:
path_meta = AudioMetadataHelper.read(source_path)
else:
# 影视场景附加音轨(如评论音轨)强制按视频解析,保留季集归属
path_meta = MetaInfoPath(
source_path, custom_words=custom_word_list
source_path, custom_words=custom_word_list, force_video=True
)
if not path_meta:
return None
@@ -4055,24 +4053,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid or bangumiid or anilistid or media_id:
# 有输入媒体ID时单个识别
# 识别媒体信息
if mtype == MediaType.MUSIC and media_source and media_id:
mediainfo = MusicChain().recognize(
source=media_source,
media_id=media_id,
)
else:
mediainfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
# 有输入媒体ID时预先识别,音乐与影视统一走 recognize_media 按类型分发
mediainfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
if not mediainfo:
return (
False,
@@ -4080,11 +4071,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
f"tmdbid{tmdbid}doubanid{doubanid}"
f"type: {mtype.value if mtype else None}",
)
else:
if media_source and not isinstance(mediainfo, MusicInfo):
mediainfo.scrape_source = media_source
if not isinstance(mediainfo, MusicInfo):
self.obtain_images(mediainfo=mediainfo)
if media_source and not isinstance(mediainfo, MusicInfo):
mediainfo.scrape_source = media_source
if not isinstance(mediainfo, MusicInfo):
self.obtain_images(mediainfo=mediainfo)
# 开始整理
state, errmsg = self.do_transfer(
@@ -4117,7 +4107,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(f"{fileitem.path} 整理完成")
return True, errmsg if preview else ""
else:
# 没有输入TMDBID时按文件识别
# 没有输入媒体ID时按文件识别
state, errmsg = self.do_transfer(
fileitem=fileitem,
target_storage=target_storage,

View File

@@ -419,17 +419,19 @@ def _requires_python_metainfo(
return contains_extended_id and not rust_accel.supports_extended_media_ids()
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None,
force_video: bool = False) -> MetaBase:
"""
根据标题和副标题识别元数据
:param title: 标题、种子名、文件名
:param subtitle: 副标题、描述
:param custom_words: 自定义识别词列表
:param force_video: 音频后缀的影视附加轨(如评论音轨)强制按视频解析,用于影视整理场景
:return: MetaAnime、MetaVideo、MetaMusic
"""
# 音频文件名直接走音乐分支,避免进入影视季集解析
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析
audio_suffix = Path(title).suffix.lower() if title else ""
if audio_suffix in settings.RMT_AUDIOEXT:
if not force_video and audio_suffix in settings.RMT_AUDIOEXT:
return MetaMusic(
org_string=title,
title=Path(title).stem,
@@ -451,15 +453,16 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
return meta
def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool = False) -> MetaBase:
"""
根据路径识别元数据
:param path: 路径
:param custom_words: 自定义识别词列表
:param force_video: 音频后缀的影视附加轨(如评论音轨)强制按视频解析,用于影视整理场景
"""
# 音频文件直接构造音乐元数据,不参与父目录季集合并
# 音频文件直接构造音乐元数据,不参与父目录季集合并,影视附加音轨强制走视频解析
audio_suffix = path.suffix.lower()
if audio_suffix in settings.RMT_AUDIOEXT:
if not force_video and audio_suffix in settings.RMT_AUDIOEXT:
return MetaMusic(
org_string=path.name,
title=path.stem,

View File

@@ -1,6 +1,9 @@
import re
import threading
import time
from typing import Any, Optional, Tuple, Union
from typing import Any, Iterable, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from app.core.cache import cached
from app.core.config import settings
@@ -11,10 +14,10 @@ from app.core.context import (
MusicInfo,
MusicRelease,
)
from app.core.meta import MetaMusic
from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import MediaRecognizeType, ModuleType
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
@@ -94,8 +97,8 @@ class MusicBrainzModule(_ModuleBase):
@staticmethod
def get_priority() -> int:
"""返回音乐元数据模块执行优先级"""
return 5
"""音乐识别在所有 MediaRecognize 模块中最先响应,避免音乐请求被影视模块误识别"""
return 0
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""根据标准音乐搜索条件返回 MusicBrainz 录音候选。"""
@@ -112,6 +115,108 @@ class MusicBrainzModule(_ModuleBase):
if (info := self._recording_to_info(item))
]
def recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
# 非音乐请求交给影视识别模块,不占用识别管线
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
return None
# 无 MetaMusic 元数据时仅响应本数据源的详情识别请求
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
return self.recognize_music(source, str(mediaid))
return None
# 携带数据源与原生 ID 的请求优先按详情识别
resolved_source = source or meta.media_source
if resolved_source and (mediaid or meta.media_id):
info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
if info:
return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兜底
candidates = self.search_music(meta, limit=10)
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source)
return matched or self._info_from_meta(meta)
async def async_recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""同步分发到音乐识别的异步版本,避免阻塞共享事件循环。"""
return await run_in_threadpool(
self.recognize_media,
meta,
mtype=mtype,
source=source,
mediaid=mediaid,
**kwargs,
)
@classmethod
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(source).casefold()
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
if meta.title and cls._same_text(meta.title, candidate.title):
score += 4
if meta.artists and any(
cls._same_text(meta.artists[0], artist)
for artist in candidate.artists
):
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
if meta.isrc and cls._same_text(meta.isrc, candidate.isrc):
score += 5
ranked.append((score, candidate))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""音乐识别无候选时,把元数据转换为可展示的最小信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
title=meta.title,
artists=list(meta.artists),
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
disc_number=meta.disc_number,
track_number=meta.track_number,
total_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
"""忽略大小写比较两个音乐文本字段。"""
return cls._normalize_text(left).casefold() == cls._normalize_text(right).casefold()
@classmethod
def _normalize_text(cls, value: Optional[str]) -> str:
"""清理音乐检索文本中的多余空白。"""
return re.sub(r"\s+", " ", str(value or "")).strip()
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。"""
if source != self._source or not media_id:

View File

@@ -490,6 +490,7 @@ class ScrapingTarget(NameValueEnum):
TV = "电视剧"
SEASON = ""
EPISODE = ""
MUSIC = "音乐"
# 刮削元数据类型

View File

@@ -74,7 +74,7 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del
)
monkeypatch.setattr(
"app.chain.transfer.MetaInfoPath",
lambda path, custom_words=None: FakeMeta(1),
lambda path, custom_words=None, **kwargs: FakeMeta(1),
)

View File

@@ -112,18 +112,18 @@ def test_recognize_file_routes_audio_to_music_chain() -> None:
def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
"""手动音乐刮削应接受 MusicBrainz UUID 并入音乐标签写入流程"""
"""手动音乐刮削应接受 MusicBrainz UUID 并经统一识别入口后写入音乐标签。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
info = MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
)
chain = Mock()
chain.recognize.return_value = info
chain.scrape_metadata.return_value = (True, "已刮削 1 个音频文件")
media_chain = Mock()
media_chain.recognize_media.return_value = info
media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MusicChain", return_value=chain):
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain):
result = scrape(
fileitem=fileitem,
storage="local",
@@ -134,11 +134,12 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
)
assert result.success is True
chain.recognize.assert_called_once_with(
media_chain.recognize_media.assert_called_once_with(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
mediaid="977e6978-139d-425c-bb98-6b0c62d1e45e",
mtype=MediaType.MUSIC,
)
chain.scrape_metadata.assert_called_once_with(
media_chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=info,
overwrite=True,

View File

@@ -1,6 +1,7 @@
from app.chain.music import MusicChain
from app.core.meta import MetaMusic
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.modules.musicbrainz import MusicBrainzModule
def test_parse_query_supports_artist_title_format():
@@ -155,7 +156,7 @@ def test_async_chart_applies_music_explore_filters(monkeypatch):
assert [item.title for item in results] == ["B", "C"]
def test_select_path_candidate_prefers_matching_audio_tags():
def test_musicbrainz_module_select_candidate_prefers_matching_audio_tags():
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidates = [
@@ -169,7 +170,7 @@ def test_select_path_candidate_prefers_matching_audio_tags():
),
]
selected = MusicChain._select_path_candidate(meta, candidates, source="musicbrainz")
selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz")
assert selected is candidates[1]

View File

@@ -12,17 +12,16 @@ from app.api.endpoints.music import (
music_artist_albums,
music_artist_related,
recognize_music,
search_music,
)
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.schemas.music import MusicRecognizeRequest
from app.schemas.types import MediaType
def test_music_routes_are_registered():
"""V1 API 应注册音乐搜索、详情识别、探索及艺术家专辑浏览路由。"""
"""V1 API 应注册音乐详情识别、探索及艺术家专辑浏览路由。"""
routes = {(route.path, tuple(route.methods or [])) for route in api_router.routes}
assert any(path == "/music/search" and "GET" in methods for path, methods in routes)
assert any(path == "/music/recognize" and "POST" in methods for path, methods in routes)
assert any(path == "/music/explore" and "GET" in methods for path, methods in routes)
assert any(path == "/music/album/{album_id}" and "GET" in methods for path, methods in routes)
@@ -35,35 +34,17 @@ def test_music_routes_are_registered():
path == "/music/artist/{artist_id}/related" and "GET" in methods
for path, methods in routes
)
def test_search_music_serializes_chain_results():
"""音乐搜索接口应返回统一的 MusicInfo 响应。"""
chain = Mock()
chain.async_search = AsyncMock(
return_value=[
MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
]
assert any(
path == "/media/search" and "GET" in methods for path, methods in routes
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(search_music(query="晴天", count=10, _=Mock()))
assert len(result) == 1
assert result[0].title == "晴天"
assert result[0].artist == "周杰伦"
chain.async_search.assert_awaited_once_with(query="晴天", limit=10)
def test_recognize_music_returns_detail():
"""音乐识别接口应按来源和 ID 返回详情。"""
"""音乐识别接口应按来源和 ID 经统一识别入口返回详情。"""
from app.chain.media import MediaChain
chain = Mock()
chain.async_recognize = AsyncMock(
chain.async_recognize_media = AsyncMock(
return_value=MusicInfo(
source="musicbrainz",
media_id="recording-1",
@@ -71,7 +52,7 @@ def test_recognize_music_returns_detail():
)
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
with patch("app.api.endpoints.music.MediaChain", return_value=chain):
result = asyncio.run(
recognize_music(
request=MusicRecognizeRequest(
@@ -83,19 +64,22 @@ def test_recognize_music_returns_detail():
)
assert result.media_id == "recording-1"
chain.async_recognize.assert_awaited_once_with(
chain.async_recognize_media.assert_awaited_once_with(
source="musicbrainz",
media_id="recording-1",
mediaid="recording-1",
mtype=MediaType.MUSIC,
)
def test_recognize_music_returns_404_for_unknown_item():
"""音乐详情不存在时接口应返回 404。"""
from app.chain.media import MediaChain
chain = Mock()
chain.async_recognize = AsyncMock(return_value=None)
chain.async_recognize_media = AsyncMock(return_value=None)
with (
patch("app.api.endpoints.music.MusicChain", return_value=chain),
patch("app.api.endpoints.music.MediaChain", return_value=chain),
pytest.raises(HTTPException) as error,
):
asyncio.run(

View File

@@ -1,17 +1,18 @@
"""音乐识别统一入口路由测试。
覆盖 MediaChain 同步/异步 ``recognize_by_meta`` 与 ``recognize_by_path`` 按
``MetaMusic`` 路由到 ``MusicChain``,以及 ``MusicChain.recognize_by_meta`` 自身的
详情、搜索匹配与离线兜底分支
``MetaMusic`` 路由到音乐模块,以及 MusicBrainz 模块 ``recognize_media`` /
``async_recognize_media`` 对音乐请求的详情、搜索匹配与兜底分支ChainBase
对 MusicInfo 结果短路返回(不参与影视共享上报)。
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
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.musicbrainz import MusicBrainzModule
from app.schemas.types import MediaType
@@ -27,88 +28,152 @@ def _music_info() -> MusicInfo:
)
def test_media_chain_recognize_by_meta_routes_metamusic_to_musicchain():
"""MetaMusic 应绕过影视识别链,直接交给 MusicChain.recognize_by_meta"""
def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
"""MetaMusic 应绕过影视识别,由统一模块分发直接响应"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
music_chain = Mock()
music_chain.recognize_by_meta = Mock(return_value=expected)
chain = MediaChain()
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
with patch("app.chain.music.MusicChain", return_value=music_chain):
result = MediaChain().recognize_by_meta(meta, source="musicbrainz")
result = chain.recognize_by_meta(meta, source="musicbrainz")
music_chain.recognize_by_meta.assert_called_once_with(meta, source="musicbrainz")
chain.recognize_media.assert_called_once_with(meta=meta, source="musicbrainz")
assert result is expected
def test_media_chain_async_recognize_by_meta_routes_metamusic_to_musicchain():
"""异步识别同样应把 MetaMusic 路由到 MusicChain.async_recognize_by_meta"""
def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
"""异步识别同样应把 MetaMusic 路由到统一模块识别入口"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
music_chain = Mock()
music_chain.async_recognize_by_meta = AsyncMock(return_value=expected)
chain = MediaChain()
monkeypatch.setattr(chain, "async_recognize_media", AsyncMock(return_value=expected))
async def runner():
with patch("app.chain.music.MusicChain", return_value=music_chain):
return await MediaChain().async_recognize_by_meta(meta, source="musicbrainz")
return await chain.async_recognize_by_meta(meta, source="musicbrainz")
result = asyncio.run(runner())
music_chain.async_recognize_by_meta.assert_awaited_once_with(meta, source="musicbrainz")
chain.async_recognize_media.assert_awaited_once_with(meta=meta, source="musicbrainz")
assert result is expected
def test_media_chain_recognize_by_path_routes_audio_file_to_musicchain():
"""音频文件路径应经 MetaInfoPath 构造 MetaMusic 并路由到音乐识别链"""
def test_media_chain_recognize_by_path_routes_audio_file_to_module(monkeypatch):
"""音频文件路径应经 MetaInfoPath 构造 MetaMusic 并路由到统一模块识别入口"""
expected = _music_info()
music_chain = Mock()
music_chain.recognize_by_meta = Mock(return_value=expected)
chain = MediaChain()
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
with patch("app.chain.music.MusicChain", return_value=music_chain):
context = MediaChain().recognize_by_path("/music/周杰伦 - 晴天.flac")
context = chain.recognize_by_path("/music/周杰伦 - 晴天.flac")
routed_meta = music_chain.recognize_by_meta.call_args.args[0]
routed_meta = chain.recognize_media.call_args.kwargs["meta"]
assert isinstance(routed_meta, MetaMusic)
assert context.media_info is expected
assert isinstance(context.meta_info, MetaMusic)
def test_music_chain_recognize_by_meta_uses_detail_when_meta_has_identity(monkeypatch):
def test_musicbrainz_module_recognize_media_ignores_non_music():
"""非音乐请求应直接返回 None不占用影视识别管线。"""
result = MusicBrainzModule().recognize_media(
meta=None, mtype=MediaType.MOVIE, source="themoviedb", mediaid="123"
)
assert result is None
def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(monkeypatch):
"""meta 携带 source+media_id 时应走详情分支,不再触发搜索。"""
chain = MusicChain()
module = MusicBrainzModule()
meta = MetaMusic(title="晴天", media_source="musicbrainz", media_id="recording-1")
expected = _music_info()
monkeypatch.setattr(chain, "recognize", Mock(return_value=expected))
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
search_mock = Mock(return_value=[])
monkeypatch.setattr(chain, "run_module", search_mock)
monkeypatch.setattr(module, "search_music", search_mock)
result = chain.recognize_by_meta(meta, source="musicbrainz")
result = module.recognize_media(meta=meta, source="musicbrainz")
chain.recognize.assert_called_once_with("musicbrainz", "recording-1")
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
search_mock.assert_not_called()
assert result is expected
def test_music_chain_recognize_by_meta_matches_search_candidate(monkeypatch):
def test_musicbrainz_module_recognize_media_matches_search_candidate(monkeypatch):
"""无身份时应按标题搜索并选择匹配候选。"""
chain = MusicChain()
module = MusicBrainzModule()
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidate = _music_info()
monkeypatch.setattr(chain, "run_module", Mock(return_value=[candidate]))
monkeypatch.setattr(module, "search_music", Mock(return_value=[candidate]))
result = chain.recognize_by_meta(meta)
result = module.recognize_media(meta=meta)
assert result is candidate
def test_music_chain_recognize_by_meta_falls_back_to_offline_when_no_match(monkeypatch):
"""搜索无候选时应返回离线兜底,且兜底结果不带远端 source"""
chain = MusicChain()
def test_musicbrainz_module_recognize_media_falls_back_to_offline_when_no_match(monkeypatch):
"""搜索无候选时应返回元数据兜底,且兜底结果不带远端身份"""
module = MusicBrainzModule()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
monkeypatch.setattr(chain, "run_module", Mock(return_value=[]))
monkeypatch.setattr(module, "search_music", Mock(return_value=[]))
result = chain.recognize_by_meta(meta)
result = module.recognize_media(meta=meta)
assert result is not None
assert result.title == "未知曲目"
# 离线兜底不携带远端来源,订阅等场景据此判定未真实命中
assert result.source is None
assert isinstance(result, MusicInfo)
def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypatch):
"""mtype 为音乐且指定数据源原生 ID 时应直接按音乐详情识别。"""
module = MusicBrainzModule()
expected = _music_info()
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
result = module.recognize_media(
mtype=MediaType.MUSIC, source="musicbrainz", mediaid="recording-1"
)
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
assert result is expected
def test_musicbrainz_module_async_recognize_media(monkeypatch):
"""异步模块入口应在线程池中调用同步实现。"""
module = MusicBrainzModule()
expected = _music_info()
sync_mock = Mock(return_value=expected)
monkeypatch.setattr(module, "recognize_media", sync_mock)
result = asyncio.run(module.async_recognize_media(
meta=MetaMusic(title="晴天"), mtype=MediaType.MUSIC
))
sync_mock.assert_called_once()
assert result is expected
def test_chain_recognize_media_returns_musicinfo_without_shared_report():
"""ChainBase.recognize_media 收到 MusicInfo 结果应直接返回,不触发影视共享上报。"""
expected = _music_info()
chain = MediaChain()
chain.run_module = Mock(return_value=expected)
with patch(
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
) as report_mock:
result = chain.recognize_media(meta=MetaMusic(title="晴天"))
report_mock.assert_not_called()
assert result is expected
def test_chain_async_recognize_media_returns_musicinfo_without_shared_report():
"""异步 ChainBase 收到 MusicInfo 结果应直接返回,不触发影视共享上报。"""
expected = _music_info()
chain = MediaChain()
chain.async_run_module = AsyncMock(return_value=expected)
with patch(
"app.helper.server.MoviePilotServerHelper.async_report_recognize_share"
) as report_mock:
async def runner():
return await chain.async_recognize_media(meta=MetaMusic(title="晴天"))
result = asyncio.run(runner())
report_mock.assert_not_called()
assert result is expected

View File

@@ -44,8 +44,8 @@ def test_music_context_builder_keeps_only_music_category():
assert contexts[0].torrent_info.category == MediaType.MUSIC.value
def test_search_by_id_routes_music_identity_to_music_chain():
"""MusicBrainz 精确身份搜索应使用 MusicChain 识别进入现有搜索处理链。"""
def test_search_by_id_routes_music_identity_to_recognize_and_process():
"""MusicBrainz 精确身份搜索应经统一识别入口识别进入现有搜索处理链。"""
chain = SearchChain()
music = MusicInfo(
source="musicbrainz",
@@ -56,10 +56,9 @@ def test_search_by_id_routes_music_identity_to_music_chain():
expected = [Mock()]
with (
patch("app.chain.search.MusicChain") as music_chain,
patch.object(chain, "recognize_media", return_value=music) as recognize,
patch.object(chain, "process", return_value=expected) as process,
):
music_chain.return_value.recognize.return_value = music
result = chain.search_by_id(
source="musicbrainz",
mediaid="recording-1",
@@ -68,9 +67,14 @@ def test_search_by_id_routes_music_identity_to_music_chain():
)
assert result == expected
music_chain.return_value.recognize.assert_called_once_with(
recognize.assert_called_once_with(
source="musicbrainz",
media_id="recording-1",
mediaid="recording-1",
tmdbid=None,
doubanid=None,
bangumiid=None,
anilistid=None,
mtype=MediaType.MUSIC,
)
process.assert_called_once_with(
mediainfo=music,

View File

@@ -861,7 +861,7 @@ class TransferJobManagerTest(unittest.TestCase):
return_value=storage_chain,
), patch(
"app.chain.transfer.MetaInfoPath",
side_effect=lambda path, custom_words=None: FakeMeta(1),
side_effect=lambda path, custom_words=None, **kwargs: FakeMeta(1),
):
state, errmsg = TransferChain.do_transfer(
chain,
@@ -942,7 +942,7 @@ class TransferJobManagerTest(unittest.TestCase):
return_value=storage_chain,
), patch(
"app.chain.transfer.MetaInfoPath",
side_effect=lambda path, custom_words=None: FakeMeta(1),
side_effect=lambda path, custom_words=None, **kwargs: FakeMeta(1),
):
state, errmsg = TransferChain.do_transfer(
chain,

View File

@@ -120,7 +120,7 @@ def test_sync_extra_subtitle_inherits_matching_video_episode(monkeypatch):
planned.append((task.fileitem.path, task.meta.begin_episode))
return True, ""
def fake_meta_info_path(path, custom_words=None):
def fake_meta_info_path(path, custom_words=None, **kwargs):
"""
模拟字幕文件自身会被误识别为第一集的场景。
"""
@@ -209,7 +209,7 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch):
planned.append((task.fileitem.path, task.meta.begin_episode))
return True, ""
def fake_meta_info_path(path, custom_words=None):
def fake_meta_info_path(path, custom_words=None, **kwargs):
"""
模拟字幕自身会被误识别为第一集,主视频可正确识别为第二集。
"""
@@ -343,7 +343,7 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch
list_files=fake_list_files,
),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(2))
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(2))
state, errmsg = TransferChain.do_transfer(
chain,
@@ -422,7 +422,7 @@ def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch):
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,
@@ -501,7 +501,7 @@ def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch):
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,
@@ -562,7 +562,7 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat
planned.append((task.fileitem.path, task.meta.begin_episode))
return True, ""
def fake_meta_info_path(path, custom_words=None):
def fake_meta_info_path(path, custom_words=None, **kwargs):
"""
模拟字幕自身识别不准,但同名主视频可提供正确集数。
"""
@@ -676,7 +676,7 @@ def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeyp
delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True,
),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,