mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 11:04:12 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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="未识别到音乐信息")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, ...]:
|
||||
"""构造跨来源稳定的候选去重键。"""
|
||||
|
||||
@@ -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)} '
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -490,6 +490,7 @@ class ScrapingTarget(NameValueEnum):
|
||||
TV = "电视剧"
|
||||
SEASON = "季"
|
||||
EPISODE = "集"
|
||||
MUSIC = "音乐"
|
||||
|
||||
|
||||
# 刮削元数据类型
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user