mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
feat(music): 支持多源识别与元数据刮削
This commit is contained in:
@@ -134,10 +134,7 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
metainfo.artists = [artist]
|
||||
if album:
|
||||
metainfo.album = album
|
||||
mediainfo = await music_chain.async_recognize_media(
|
||||
meta=metainfo,
|
||||
source="musicbrainz",
|
||||
)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
|
||||
if mediainfo:
|
||||
context = Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return self._format_context_result(context, "音乐标题")
|
||||
|
||||
@@ -43,7 +43,11 @@ class ScrapeMetadataInput(BaseModel):
|
||||
)
|
||||
media_source: Optional[str] = Field(
|
||||
None,
|
||||
description="Music metadata source, normally musicbrainz. Must be paired with media_id",
|
||||
description=(
|
||||
"Music metadata source: musicbrainz, theaudiodb, or doubanmusic. "
|
||||
"When omitted, automatic music recognition compares all sources. "
|
||||
"Must be paired with media_id when an ID is supplied"
|
||||
),
|
||||
)
|
||||
media_id: Optional[str] = Field(
|
||||
None,
|
||||
@@ -209,6 +213,7 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=bool(overwrite),
|
||||
source=media_source,
|
||||
)
|
||||
result = {
|
||||
"success": success,
|
||||
|
||||
@@ -18,7 +18,15 @@ from app.schemas.types import SystemConfigKey
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
|
||||
MediaSource = Literal[
|
||||
"themoviedb",
|
||||
"douban",
|
||||
"bangumi",
|
||||
"anilist",
|
||||
"musicbrainz",
|
||||
"theaudiodb",
|
||||
"doubanmusic",
|
||||
]
|
||||
|
||||
|
||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||
|
||||
@@ -19,14 +19,18 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus
|
||||
from app.schemas import MediaType, MediaRecognizeConvertEventData
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.media import MEDIA_SOURCE_ID_FIELDS, parse_media_key
|
||||
from app.utils.media import (
|
||||
MEDIA_SOURCE_ID_FIELDS,
|
||||
is_music_media_source,
|
||||
parse_media_key,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = str
|
||||
|
||||
|
||||
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
|
||||
"""按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它现有来源使用数字 ID。"""
|
||||
"""按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它内置来源使用数字 ID。"""
|
||||
if source == "musicbrainz":
|
||||
try:
|
||||
UUID(media_id)
|
||||
@@ -122,8 +126,8 @@ async def recognize(
|
||||
"""
|
||||
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
|
||||
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
|
||||
# MusicBrainz 仅支持音乐识别,非音频后缀的标题统一按音乐元数据解析
|
||||
if source == "musicbrainz" and not isinstance(metainfo, MetaMusic):
|
||||
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
|
||||
if is_music_media_source(source) and not isinstance(metainfo, MetaMusic):
|
||||
metainfo = MusicChain.parse_query(title)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
@@ -218,9 +222,13 @@ async def search(
|
||||
return obj.source
|
||||
|
||||
media_chain = MediaChain()
|
||||
if type == "music" or source == "musicbrainz":
|
||||
if type == "music" or is_music_media_source(source):
|
||||
# 音乐搜索统一入口,与影视搜索共用 /media/search
|
||||
music_infos = await MusicChain().async_search(query=title, limit=count)
|
||||
music_search_params = {"query": title, "limit": count}
|
||||
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
|
||||
if source:
|
||||
music_search_params["source"] = source
|
||||
music_infos = await MusicChain().async_search(**music_search_params)
|
||||
return [
|
||||
info.to_dict()
|
||||
for info in music_infos
|
||||
@@ -283,12 +291,12 @@ def scrape(
|
||||
|
||||
is_music = (
|
||||
type_name == MediaType.MUSIC
|
||||
or media_source == "musicbrainz"
|
||||
or is_music_media_source(media_source)
|
||||
or MediaChain.is_audio_path(fileitem.path)
|
||||
)
|
||||
if is_music:
|
||||
if type_name not in (None, MediaType.MUSIC):
|
||||
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
|
||||
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
|
||||
music_info: Optional[MusicInfo] = None
|
||||
if normalized_media_id:
|
||||
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
@@ -303,6 +311,7 @@ def scrape(
|
||||
fileitem=fileitem,
|
||||
mediainfo=music_info,
|
||||
overwrite=True,
|
||||
source=media_source,
|
||||
)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ router = APIRouter()
|
||||
|
||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
||||
PageParam = Annotated[int, Query(ge=1)]
|
||||
MusicSourceParam = Annotated[str, Query(pattern="^musicbrainz$")]
|
||||
MusicSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
|
||||
]
|
||||
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
|
||||
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
|
||||
MusicRangeParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_CHART_RANGES)})$")]
|
||||
|
||||
@@ -41,7 +41,7 @@ from app.schemas import (
|
||||
MessageResponse,
|
||||
)
|
||||
from app.utils.identity import normalize_internal_user_id
|
||||
from app.utils.media import normalize_media_source
|
||||
from app.utils.media import is_music_media_source, normalize_media_source
|
||||
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import (
|
||||
@@ -635,6 +635,72 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return "anilist", None, None, None, int(anilistid)
|
||||
return source, None, None, None, None
|
||||
|
||||
def _run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按媒体领域执行同步原生识别,音乐请求只允许进入音乐数据源。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
mtype = module_kwargs.get("mtype")
|
||||
source = module_kwargs.get("source")
|
||||
if (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or is_music_media_source(source)
|
||||
):
|
||||
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
music_chain = MusicChain()
|
||||
if source:
|
||||
with fresh(not cache):
|
||||
return music_chain.recognize_from_source(
|
||||
source=source,
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
mediaid=module_kwargs.get("mediaid"),
|
||||
cache=cache,
|
||||
)
|
||||
if isinstance(meta, MetaMusic):
|
||||
return music_chain.recognize_best(meta=meta, cache=cache)
|
||||
return None
|
||||
with fresh(not cache):
|
||||
return self.run_module("recognize_media", **module_kwargs)
|
||||
|
||||
async def _async_run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按媒体领域执行异步原生识别,音乐请求只允许进入音乐数据源。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
mtype = module_kwargs.get("mtype")
|
||||
source = module_kwargs.get("source")
|
||||
if (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or is_music_media_source(source)
|
||||
):
|
||||
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
music_chain = MusicChain()
|
||||
if source:
|
||||
async with async_fresh(not cache):
|
||||
return await music_chain.async_recognize_from_source(
|
||||
source=source,
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
mediaid=module_kwargs.get("mediaid"),
|
||||
cache=cache,
|
||||
)
|
||||
if isinstance(meta, MetaMusic):
|
||||
return await music_chain.async_recognize_best(meta=meta, cache=cache)
|
||||
return None
|
||||
async with async_fresh(not cache):
|
||||
return await self.async_run_module(
|
||||
"async_recognize_media", **module_kwargs
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
@@ -685,7 +751,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
anilistid=anilistid,
|
||||
)
|
||||
# 检索显式 TMDB ID 由请求方自行消歧,不能被标题推断类型误导。
|
||||
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
if not mtype and not tmdbid and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
@@ -700,11 +768,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
with fresh(not cache):
|
||||
mediainfo = self.run_module(
|
||||
"recognize_media",
|
||||
**module_kwargs,
|
||||
)
|
||||
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = self._supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, source=source,
|
||||
@@ -731,20 +795,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
with fresh(not cache):
|
||||
mediainfo = self.run_module(
|
||||
"recognize_media",
|
||||
meta=meta,
|
||||
mtype=shared_params.get("mtype") or mtype,
|
||||
source=shared_params.get("source"),
|
||||
mediaid=shared_params.get("mediaid"),
|
||||
tmdbid=shared_params.get("tmdbid"),
|
||||
doubanid=shared_params.get("doubanid"),
|
||||
bangumiid=shared_params.get("bangumiid"),
|
||||
anilistid=shared_params.get("anilistid"),
|
||||
episode_group=episode_group,
|
||||
cache=cache,
|
||||
)
|
||||
mediainfo = self._run_native_media_recognize(
|
||||
{
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"source": shared_params.get("source"),
|
||||
"mediaid": shared_params.get("mediaid"),
|
||||
"tmdbid": shared_params.get("tmdbid"),
|
||||
"doubanid": shared_params.get("doubanid"),
|
||||
"bangumiid": shared_params.get("bangumiid"),
|
||||
"anilistid": shared_params.get("anilistid"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
},
|
||||
cache,
|
||||
)
|
||||
if mediainfo:
|
||||
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
self._record_media_recognize_share_hit()
|
||||
@@ -801,7 +866,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
anilistid=anilistid,
|
||||
)
|
||||
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
|
||||
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
if not mtype and not tmdbid and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
@@ -816,11 +883,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
async with async_fresh(not cache):
|
||||
mediainfo = await self.async_run_module(
|
||||
"async_recognize_media",
|
||||
**module_kwargs,
|
||||
)
|
||||
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = await self._async_supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, source=source,
|
||||
@@ -847,20 +910,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
async with async_fresh(not cache):
|
||||
mediainfo = await self.async_run_module(
|
||||
"async_recognize_media",
|
||||
meta=meta,
|
||||
mtype=shared_params.get("mtype") or mtype,
|
||||
source=shared_params.get("source"),
|
||||
mediaid=shared_params.get("mediaid"),
|
||||
tmdbid=shared_params.get("tmdbid"),
|
||||
doubanid=shared_params.get("doubanid"),
|
||||
bangumiid=shared_params.get("bangumiid"),
|
||||
anilistid=shared_params.get("anilistid"),
|
||||
episode_group=episode_group,
|
||||
cache=cache,
|
||||
)
|
||||
mediainfo = await self._async_run_native_media_recognize(
|
||||
{
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"source": shared_params.get("source"),
|
||||
"mediaid": shared_params.get("mediaid"),
|
||||
"tmdbid": shared_params.get("tmdbid"),
|
||||
"doubanid": shared_params.get("doubanid"),
|
||||
"bangumiid": shared_params.get("bangumiid"),
|
||||
"anilistid": shared_params.get("anilistid"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
},
|
||||
cache,
|
||||
)
|
||||
if mediainfo:
|
||||
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
await run_in_threadpool(self._record_media_recognize_share_hit)
|
||||
@@ -1122,6 +1186,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if mediainfo and mediainfo.type == MediaType.MUSIC:
|
||||
return mediainfo
|
||||
return self.run_module("obtain_images", mediainfo=mediainfo)
|
||||
|
||||
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
@@ -1130,6 +1196,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if mediainfo and mediainfo.type == MediaType.MUSIC:
|
||||
return mediainfo
|
||||
return await self.async_run_module("async_obtain_images", mediainfo=mediainfo)
|
||||
|
||||
def obtain_specific_image(
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.schemas.types import (
|
||||
SystemConfigKey,
|
||||
)
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_music_media_source
|
||||
from app.utils.mixins import ConfigReloadMixin
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
@@ -211,6 +212,34 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self.storagechain = StorageChain()
|
||||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||||
|
||||
def _run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""自动音乐识别交给 MusicChain 多源评分,显式来源保持原有单源分发。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
|
||||
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
return MusicChain().recognize_best(meta=meta, cache=cache)
|
||||
return super()._run_native_media_recognize(module_kwargs, cache)
|
||||
|
||||
async def _async_run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""异步自动音乐识别并发比较多源结果,显式来源保持原有单源分发。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
|
||||
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
return await MusicChain().async_recognize_best(meta=meta, cache=cache)
|
||||
return await super()._async_run_native_media_recognize(module_kwargs, cache)
|
||||
|
||||
def on_config_changed(self):
|
||||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||||
|
||||
@@ -641,6 +670,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息
|
||||
@@ -649,9 +679,11 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
"""
|
||||
mediainfo = self._recognize_with_fallback_by_meta(
|
||||
metainfo=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
episode_group=episode_group,
|
||||
obtain_images=obtain_images,
|
||||
@@ -772,6 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
def _recognize_with_fallback_by_meta(
|
||||
self,
|
||||
metainfo: MetaBase,
|
||||
mtype: Optional[MediaType] = None,
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
@@ -780,6 +813,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
根据标题识别媒体信息,必要时回退到辅助识别。
|
||||
|
||||
:param metainfo: 标题解析元数据
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
@@ -790,7 +824,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = metainfo.title
|
||||
share_meta = deepcopy(metainfo)
|
||||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||||
is_music = isinstance(metainfo, MetaMusic)
|
||||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||||
is_recognized = (
|
||||
(lambda result: bool(result and result.source)) if is_music else None
|
||||
)
|
||||
@@ -799,6 +833,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""使用请求级数据源执行原生识别。"""
|
||||
return self.recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
share_meta=share_meta,
|
||||
episode_group=episode_group,
|
||||
@@ -806,6 +841,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
def plugin_recognize() -> Optional[MediaInfo]:
|
||||
"""执行辅助识别并保持请求级数据源约束。"""
|
||||
if is_music and not isinstance(metainfo, MetaMusic):
|
||||
return None
|
||||
return self.recognize_help(
|
||||
title=title,
|
||||
org_meta=metainfo,
|
||||
@@ -1009,11 +1046,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
|
||||
file_path = Path(path)
|
||||
if file_path.exists() and file_path.is_file():
|
||||
meta = AudioMetadataHelper.read(file_path)
|
||||
else:
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
return AudioMetadataHelper.read(file_path)
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
|
||||
return meta.apply_path_context(file_path)
|
||||
|
||||
@@ -1070,7 +1106,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
def recognize_music_by_path(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
source: str = "musicbrainz",
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[MetaMusic, MusicInfo]:
|
||||
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
|
||||
meta = self.read_path_meta(path)
|
||||
@@ -1079,7 +1115,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
result = self._merge_music_audio_quality(
|
||||
info or self._music_info_from_path_meta(meta), meta
|
||||
)
|
||||
if not result.source:
|
||||
if not result.source and source in (None, "musicbrainz"):
|
||||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||||
matched = self._music_album_dir_fallback(path)
|
||||
if matched:
|
||||
@@ -1089,7 +1125,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
async def async_recognize_music_by_path(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
source: str = "musicbrainz",
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[MetaMusic, MusicInfo]:
|
||||
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
|
||||
# Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。
|
||||
@@ -1099,7 +1135,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
result = self._merge_music_audio_quality(
|
||||
info or self._music_info_from_path_meta(meta), meta
|
||||
)
|
||||
if not result.source:
|
||||
if not result.source and source in (None, "musicbrainz"):
|
||||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||||
matched = await run_in_threadpool(self._music_album_dir_fallback, path)
|
||||
if matched:
|
||||
@@ -1108,7 +1144,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
def _is_music_path_request(self, path: str, source: Optional[str]) -> bool:
|
||||
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
|
||||
return self.is_audio_path(path) or source == "musicbrainz"
|
||||
return self.is_audio_path(path) or is_music_media_source(source)
|
||||
|
||||
def recognize_by_path(
|
||||
self,
|
||||
@@ -1130,7 +1166,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||||
if self._is_music_path_request(path, source):
|
||||
music_meta, music_info = self.recognize_music_by_path(
|
||||
path, source=source or "musicbrainz"
|
||||
path, source=source
|
||||
)
|
||||
return Context(meta_info=music_meta, media_info=music_info)
|
||||
file_path = Path(path)
|
||||
@@ -1655,6 +1691,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
fileitem: schemas.FileItem,
|
||||
mediainfo: Optional[MusicInfo] = None,
|
||||
overwrite: bool = True,
|
||||
source: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
|
||||
|
||||
@@ -1668,7 +1705,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
and len(files) > 1
|
||||
and mediainfo.music_type != MUSIC_ENTITY_ALBUM
|
||||
):
|
||||
return False, "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
|
||||
# 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。
|
||||
nfo_option = self.scraping_policies.option("music", "nfo")
|
||||
@@ -1726,6 +1763,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=overwrite or lyrics_option.is_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
if not result.metadata_success:
|
||||
failures.append(
|
||||
@@ -1806,6 +1844,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite: bool = False,
|
||||
music_chain: Optional["MusicChain"] = None,
|
||||
album_info: Optional[MusicAlbumInfo] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。"""
|
||||
storage = self.storagechain
|
||||
@@ -1834,6 +1873,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
|
||||
@@ -1853,6 +1893,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def _apply_music_file_scrape(
|
||||
@@ -1869,9 +1910,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite: bool,
|
||||
music_chain: Optional["MusicChain"],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
source: Optional[str],
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, source=source)
|
||||
metadata_requested = write_tags or with_cover
|
||||
metadata_success = True
|
||||
if metadata_requested:
|
||||
@@ -1884,6 +1926,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover_overwrite=cover_overwrite,
|
||||
cover=cover,
|
||||
scrape_info=scrape_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
lyrics_status = self._scrape_music_lyrics(
|
||||
@@ -1990,6 +2033,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cls,
|
||||
local_path: Path,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[MetaMusic | MusicInfo]:
|
||||
"""在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。"""
|
||||
if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM:
|
||||
@@ -2002,7 +2046,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if mediainfo:
|
||||
return None
|
||||
|
||||
_, recognized = cls.recognize_music_by_path(local_path, source="musicbrainz")
|
||||
_, recognized = cls.recognize_music_by_path(local_path, source=source)
|
||||
return recognized
|
||||
|
||||
def _write_music_metadata(
|
||||
@@ -2015,9 +2059,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover_overwrite: bool,
|
||||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||||
scrape_info: Optional[MetaMusic | MusicInfo] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""解析单个本地音轨并按独立策略写入标签和封面。"""
|
||||
scrape_info = scrape_info or self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
scrape_info = scrape_info or self._resolve_music_scrape_info(
|
||||
local_path,
|
||||
mediainfo,
|
||||
source=source,
|
||||
)
|
||||
if not scrape_info or not scrape_info.title:
|
||||
logger.warning(f"无法识别音乐信息:{local_path}")
|
||||
return False
|
||||
@@ -2492,6 +2541,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息(异步版本)
|
||||
@@ -2500,10 +2550,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
mediainfo = await self._async_recognize_with_fallback_by_meta(
|
||||
metainfo=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
episode_group=episode_group,
|
||||
obtain_images=obtain_images,
|
||||
@@ -2515,6 +2567,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
async def _async_recognize_with_fallback_by_meta(
|
||||
self,
|
||||
metainfo: MetaBase,
|
||||
mtype: Optional[MediaType] = None,
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
@@ -2523,6 +2576,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
异步根据标题识别媒体信息,必要时回退到辅助识别。
|
||||
|
||||
:param metainfo: 标题解析元数据
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
@@ -2533,7 +2587,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = metainfo.title
|
||||
share_meta = deepcopy(metainfo)
|
||||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||||
is_music = isinstance(metainfo, MetaMusic)
|
||||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||||
is_recognized = (
|
||||
(lambda result: bool(result and result.source)) if is_music else None
|
||||
)
|
||||
@@ -2542,6 +2596,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""异步使用请求级数据源执行原生识别。"""
|
||||
return await self.async_recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
share_meta=share_meta,
|
||||
episode_group=episode_group,
|
||||
@@ -2549,6 +2604,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
async def plugin_recognize() -> Optional[MediaInfo]:
|
||||
"""异步执行辅助识别并保持请求级数据源约束。"""
|
||||
if is_music and not isinstance(metainfo, MetaMusic):
|
||||
return None
|
||||
return await self.async_recognize_help(
|
||||
title=title,
|
||||
org_meta=metainfo,
|
||||
@@ -2727,7 +2784,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||||
if self._is_music_path_request(path, source):
|
||||
music_meta, music_info = await self.async_recognize_music_by_path(
|
||||
path, source=source or "musicbrainz"
|
||||
path, source=source
|
||||
)
|
||||
return Context(meta_info=music_meta, media_info=music_info)
|
||||
file_path = Path(path)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.cache import async_fresh, fresh
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
@@ -17,6 +20,13 @@ from app.core.context import (
|
||||
from app.core.meta import MetaMusic
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.media import (
|
||||
MUSIC_MEDIA_SOURCE_ORDER,
|
||||
is_music_media_source,
|
||||
normalize_media_source,
|
||||
)
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
class MusicChain(ChainBase):
|
||||
@@ -27,6 +37,9 @@ class MusicChain(ChainBase):
|
||||
_album_dir_cache_max = 128
|
||||
# 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理
|
||||
_album_match_min_files = 2
|
||||
# 自动识别会比较全部来源;该顺序仅用于同分时的确定性选择。
|
||||
_recognize_source_order = MUSIC_MEDIA_SOURCE_ORDER
|
||||
_recognize_min_score = 45.0
|
||||
|
||||
@classmethod
|
||||
def parse_query(cls, query: str) -> MetaMusic:
|
||||
@@ -110,16 +123,112 @@ class MusicChain(ChainBase):
|
||||
break
|
||||
return results
|
||||
|
||||
def search(self, query: str, limit: int = 20) -> list[MusicInfo]:
|
||||
"""调用已启用的音乐元数据模块搜索候选。"""
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按请求来源调用音乐元数据模块搜索候选,未指定时默认使用 MusicBrainz。"""
|
||||
meta = self.parse_query(query)
|
||||
candidates = self.run_module("search_music", meta=meta, limit=limit)
|
||||
candidates = self.run_module(
|
||||
"search_music",
|
||||
meta=meta,
|
||||
limit=limit,
|
||||
source=source or "musicbrainz",
|
||||
)
|
||||
return self.normalize_candidates(candidates, limit=limit)
|
||||
|
||||
async def async_search(self, query: str, limit: int = 20) -> list[MusicInfo]:
|
||||
"""异步调用已启用的音乐元数据模块搜索候选。"""
|
||||
def recognize_best(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""依次查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
|
||||
candidates: list[MusicInfo] = []
|
||||
offline_fallback: Optional[MusicInfo] = None
|
||||
with fresh(not cache):
|
||||
for source in self._recognize_source_order:
|
||||
result = self._recognize_from_source(meta, source, cache)
|
||||
candidate = self._normalize_recognize_result(result, source)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
elif isinstance(result, MusicInfo) and not result.source:
|
||||
# MusicBrainz 会返回无远端身份的离线结果,全部来源失败时仍需保留。
|
||||
offline_fallback = offline_fallback or result
|
||||
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
|
||||
|
||||
async def async_recognize_best(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""并发查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
|
||||
async with async_fresh(not cache):
|
||||
results = await asyncio.gather(*(
|
||||
self._async_recognize_from_source(meta, source, cache)
|
||||
for source in self._recognize_source_order
|
||||
))
|
||||
candidates: list[MusicInfo] = []
|
||||
offline_fallback: Optional[MusicInfo] = None
|
||||
for source, result in zip(self._recognize_source_order, results):
|
||||
candidate = self._normalize_recognize_result(result, source)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
elif isinstance(result, MusicInfo) and not result.source:
|
||||
offline_fallback = offline_fallback or result
|
||||
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
|
||||
|
||||
def recognize_from_source(
|
||||
self,
|
||||
source: str,
|
||||
meta: Optional[MetaMusic] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
|
||||
normalized_source = normalize_media_source(source)
|
||||
if not is_music_media_source(normalized_source):
|
||||
return None
|
||||
return self._recognize_from_source(
|
||||
meta=meta,
|
||||
source=normalized_source,
|
||||
cache=cache,
|
||||
mediaid=mediaid,
|
||||
)
|
||||
|
||||
async def async_recognize_from_source(
|
||||
self,
|
||||
source: str,
|
||||
meta: Optional[MetaMusic] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
|
||||
normalized_source = normalize_media_source(source)
|
||||
if not is_music_media_source(normalized_source):
|
||||
return None
|
||||
return await self._async_recognize_from_source(
|
||||
meta=meta,
|
||||
source=normalized_source,
|
||||
cache=cache,
|
||||
mediaid=mediaid,
|
||||
)
|
||||
|
||||
async def async_search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""异步按请求来源搜索音乐候选,未指定时默认使用 MusicBrainz。"""
|
||||
meta = self.parse_query(query)
|
||||
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
|
||||
candidates = await self.async_run_module(
|
||||
"search_music",
|
||||
meta=meta,
|
||||
limit=limit,
|
||||
source=source or "musicbrainz",
|
||||
)
|
||||
return self.normalize_candidates(candidates, limit=limit)
|
||||
|
||||
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
|
||||
@@ -305,6 +414,200 @@ class MusicChain(ChainBase):
|
||||
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
|
||||
return MetaMusic.compact_text(value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recognize_result(
|
||||
result: Any,
|
||||
source: str,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""标准化单个来源结果,并拒绝插件或模块返回的跨来源身份。"""
|
||||
if isinstance(result, dict):
|
||||
try:
|
||||
result = MusicInfo.from_dict(result)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(result, MusicInfo):
|
||||
return None
|
||||
if not result.source or not result.media_id or result.source != source:
|
||||
return None
|
||||
if result.music_type != MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
return result
|
||||
|
||||
def _recognize_from_source(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: str,
|
||||
cache: bool,
|
||||
mediaid: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。"""
|
||||
module = self._music_recognize_module(source)
|
||||
if not module:
|
||||
return None
|
||||
try:
|
||||
return module.recognize_media(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"{source} 音乐自动识别失败:{err}")
|
||||
return None
|
||||
|
||||
async def _async_recognize_from_source(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: str,
|
||||
cache: bool,
|
||||
mediaid: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步调用指定音乐来源模块,单个来源失败不影响其它候选。"""
|
||||
module = self._music_recognize_module(source)
|
||||
if not module:
|
||||
return None
|
||||
try:
|
||||
async_method = getattr(module, "async_recognize_media", None)
|
||||
if async_method:
|
||||
return await async_method(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
return await run_in_threadpool(
|
||||
module.recognize_media,
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"{source} 音乐自动识别失败:{err}")
|
||||
return None
|
||||
|
||||
def _music_recognize_module(self, source: str) -> Optional[Any]:
|
||||
"""枚举运行中的系统模块并返回声明了指定音乐来源的实现。"""
|
||||
for module in self.modulemanager.get_running_modules("recognize_media"):
|
||||
get_music_source = getattr(module, "get_music_source", None)
|
||||
if get_music_source and get_music_source() == source:
|
||||
return module
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_best_recognize_candidate(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: Iterable[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按统一证据评分选择最佳音轨,同分时使用默认来源顺序。"""
|
||||
source_order = {
|
||||
source: index for index, source in enumerate(cls._recognize_source_order)
|
||||
}
|
||||
ranked: list[tuple[float, int, MusicInfo]] = []
|
||||
for candidate in candidates:
|
||||
score = cls._recognize_candidate_score(meta, candidate)
|
||||
if score is None or score < cls._recognize_min_score:
|
||||
continue
|
||||
logger.debug(
|
||||
f"音乐自动识别候选:{candidate.source} {candidate.title},评分 {score:.1f}"
|
||||
)
|
||||
ranked.append((
|
||||
score,
|
||||
-source_order.get(candidate.source or "", len(source_order)),
|
||||
candidate,
|
||||
))
|
||||
if not ranked:
|
||||
return None
|
||||
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
best_score, _, best = ranked[0]
|
||||
logger.info(
|
||||
f"音乐自动识别采用 {best.source}:{best.title},匹配评分 {best_score:.1f}"
|
||||
)
|
||||
return best
|
||||
|
||||
@classmethod
|
||||
def _recognize_candidate_score(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidate: MusicInfo,
|
||||
) -> Optional[float]:
|
||||
"""综合曲名、艺术家、专辑、ISRC、时长、曲序和年份计算匹配分。"""
|
||||
if candidate.music_type != MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
isrc_match = bool(
|
||||
meta.isrc
|
||||
and candidate.isrc
|
||||
and cls._match_similarity(meta.isrc, candidate.isrc) == 1.0
|
||||
)
|
||||
if meta.isrc and candidate.isrc and not isrc_match:
|
||||
return None
|
||||
|
||||
title_similarity = cls._match_similarity(meta.title, candidate.title)
|
||||
if not isrc_match and (not meta.title or title_similarity < 0.7):
|
||||
return None
|
||||
score = title_similarity * 50
|
||||
|
||||
expected_artists = cls._unique_texts([
|
||||
*(meta.artists or []),
|
||||
meta.album_artist,
|
||||
])
|
||||
candidate_artists = cls._unique_texts([
|
||||
*(candidate.artists or []),
|
||||
candidate.album_artist,
|
||||
])
|
||||
if expected_artists:
|
||||
artist_similarity = max(
|
||||
(
|
||||
cls._match_similarity(expected, actual)
|
||||
for expected in expected_artists
|
||||
for actual in candidate_artists
|
||||
),
|
||||
default=0.0,
|
||||
)
|
||||
if not isrc_match and artist_similarity < 0.6:
|
||||
return None
|
||||
score += artist_similarity * 25
|
||||
|
||||
if meta.album and candidate.album:
|
||||
score += cls._match_similarity(meta.album, candidate.album) * 12
|
||||
if meta.duration and candidate.duration:
|
||||
duration_delta = abs(meta.duration - candidate.duration) / max(
|
||||
meta.duration, candidate.duration
|
||||
)
|
||||
if duration_delta <= 0.02:
|
||||
score += 8
|
||||
elif duration_delta <= 0.05:
|
||||
score += 6
|
||||
elif duration_delta <= 0.1:
|
||||
score += 3
|
||||
elif duration_delta > 0.2:
|
||||
score -= 8
|
||||
if meta.track_number and candidate.track_number:
|
||||
score += 3 if meta.track_number == candidate.track_number else -1
|
||||
if meta.year and candidate.year:
|
||||
year_delta = abs(int(meta.year) - int(candidate.year))
|
||||
score += 2 if year_delta == 0 else 1 if year_delta == 1 else 0
|
||||
if isrc_match:
|
||||
score += 50
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _match_similarity(left: Optional[str], right: Optional[str]) -> float:
|
||||
"""忽略繁简、大小写和标点后计算两段音乐文本的相似度。"""
|
||||
normalized_left = MetaMusic.compact_text(
|
||||
zhconv_convert(str(left or ""), "zh-hans")
|
||||
)
|
||||
normalized_right = MetaMusic.compact_text(
|
||||
zhconv_convert(str(right or ""), "zh-hans")
|
||||
)
|
||||
if not normalized_left or not normalized_right:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, normalized_left, normalized_right).ratio()
|
||||
|
||||
def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
|
||||
"""按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。
|
||||
|
||||
@@ -364,11 +667,10 @@ class MusicChain(ChainBase):
|
||||
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
|
||||
file_path = Path(path)
|
||||
if file_path.exists() and file_path.is_file():
|
||||
meta = AudioMetadataHelper.read(file_path)
|
||||
else:
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
return AudioMetadataHelper.read(file_path)
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
|
||||
return meta.apply_path_context(file_path)
|
||||
|
||||
|
||||
@@ -525,9 +525,10 @@ class JobManager:
|
||||
self._job_view.pop(mediaid)
|
||||
# 移除季集信息
|
||||
if mediaid in self._season_episodes:
|
||||
episodes = getattr(task.meta, "episode_list", None) or []
|
||||
self._season_episodes[mediaid] = list(
|
||||
set(self._season_episodes[mediaid])
|
||||
- set(task.meta.episode_list)
|
||||
- set(episodes)
|
||||
)
|
||||
return task, mediaid
|
||||
return None, None
|
||||
@@ -2022,7 +2023,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
):
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: Optional[MediaInfo] = self.recognize_media(
|
||||
mtype=MediaType(download_history.type),
|
||||
mtype=task.mtype or MediaType(download_history.type),
|
||||
tmdbid=download_history.tmdbid,
|
||||
doubanid=download_history.doubanid,
|
||||
bangumiid=download_history.bangumiid,
|
||||
@@ -2045,6 +2046,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
recognize_kwargs = {"obtain_images": True}
|
||||
if task.media_source:
|
||||
recognize_kwargs["source"] = task.media_source
|
||||
if task.mtype:
|
||||
recognize_kwargs["mtype"] = task.mtype
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
task.meta, **recognize_kwargs
|
||||
)
|
||||
@@ -2055,10 +2058,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
recognize_kwargs = {"obtain_images": True}
|
||||
if task.media_source:
|
||||
recognize_kwargs["source"] = task.media_source
|
||||
if task.mtype:
|
||||
recognize_kwargs["mtype"] = task.mtype
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
task.meta, **recognize_kwargs
|
||||
)
|
||||
|
||||
# 音乐必须先经过音乐元数据模块识别;远端不可用时再保留本地标签结果,
|
||||
# 避免因离线兜底提前赋值而跳过音乐识别链。
|
||||
if not mediainfo and isinstance(task.meta, MetaMusic):
|
||||
mediainfo = self._music_info_from_meta(task.meta)
|
||||
|
||||
# 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。
|
||||
if mediainfo and need_obtain_images:
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
@@ -3745,8 +3755,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
file_meta, task_mediainfo = self._match_music_album_context(
|
||||
file_item, file_path, file_meta
|
||||
)
|
||||
if not task_mediainfo:
|
||||
task_mediainfo = self._music_info_from_meta(file_meta)
|
||||
if (
|
||||
not manual
|
||||
and self._is_movie_year_conflict(file_meta, task_mediainfo)
|
||||
@@ -3759,6 +3767,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
meta=file_meta,
|
||||
mediainfo=task_mediainfo,
|
||||
media_source=media_source,
|
||||
mtype=mtype,
|
||||
target_directory=target_directory,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
|
||||
@@ -44,6 +44,8 @@ class SystemConfModel(BaseModel):
|
||||
fanart: int = 0
|
||||
# MusicBrainz请求缓存数量
|
||||
musicbrainz: int = 0
|
||||
# TheAudioDB请求缓存数量
|
||||
theaudiodb: int = 0
|
||||
# ListenBrainz请求缓存数量
|
||||
listenbrainz: int = 0
|
||||
# 元数据缓存过期时间(秒)
|
||||
@@ -205,11 +207,11 @@ class ConfigModel(BaseModel):
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
|
||||
# ==================== 媒体元数据配置 ====================
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb"
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
@@ -229,6 +231,8 @@ class ConfigModel(BaseModel):
|
||||
# ==================== 音乐配置 ====================
|
||||
# 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)
|
||||
MUSIC_COVER_PROXY: str = ""
|
||||
# TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖
|
||||
THEAUDIODB_API_KEY: str = "123"
|
||||
|
||||
# ==================== TVDB配置 ====================
|
||||
# TVDB API Key
|
||||
@@ -527,6 +531,7 @@ class ConfigModel(BaseModel):
|
||||
"anilist.co",
|
||||
"coverartarchive.org",
|
||||
"archive.org",
|
||||
"theaudiodb.com",
|
||||
"commons.wikimedia.org",
|
||||
"upload.wikimedia.org",
|
||||
]
|
||||
@@ -999,6 +1004,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
bangumi=512,
|
||||
fanart=512,
|
||||
musicbrainz=512,
|
||||
theaudiodb=512,
|
||||
listenbrainz=256,
|
||||
meta=(self.META_CACHE_EXPIRE or 72) * 3600,
|
||||
scheduler=100,
|
||||
@@ -1012,6 +1018,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
bangumi=256,
|
||||
fanart=128,
|
||||
musicbrainz=256,
|
||||
theaudiodb=256,
|
||||
listenbrainz=128,
|
||||
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
scheduler=50,
|
||||
|
||||
@@ -212,9 +212,14 @@ class MusicInfo:
|
||||
return None
|
||||
|
||||
@property
|
||||
def douban_id(self) -> None:
|
||||
"""音乐不使用豆瓣 ID,兼容现有下载历史字段。"""
|
||||
return None
|
||||
def douban_id(self) -> str | None:
|
||||
"""豆瓣音乐来源返回原生条目 ID,其它音乐源保持兼容空值。"""
|
||||
return self.media_id if self.source == "doubanmusic" else None
|
||||
|
||||
@property
|
||||
def theaudiodb_id(self) -> str | None:
|
||||
"""TheAudioDB 来源返回原生条目 ID,供通用身份解析复用。"""
|
||||
return self.media_id if self.source == "theaudiodb" else None
|
||||
|
||||
@property
|
||||
def bangumi_id(self) -> None:
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from .metabase import MetaBase
|
||||
from .metavideo import MetaVideo
|
||||
from .metaanime import MetaAnime
|
||||
from .metamusic import MetaMusic
|
||||
from .metamusic import (
|
||||
MetaMusic,
|
||||
MusicNameContext,
|
||||
MusicNameParseResult,
|
||||
MusicNameParser,
|
||||
MusicNamePattern,
|
||||
MusicNamePatternMatch,
|
||||
MusicNameRegistry,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,19 +16,22 @@ class AudioMetadataHelper:
|
||||
|
||||
@classmethod
|
||||
def read(cls, path: Path) -> MetaMusic:
|
||||
"""读取本地音频文件标签;读取失败时返回基于文件名的最小元数据。"""
|
||||
fallback = MetaMusic(
|
||||
org_string=path.name,
|
||||
title=path.stem,
|
||||
audio_format=path.suffix.lstrip(".").upper() or None,
|
||||
)
|
||||
"""读取本地音频标签,并以完整文件名模式和目录线索补充缺失字段。"""
|
||||
def filename_fallback() -> MetaMusic:
|
||||
"""构造无标签结果,完整文件名解析只在确有需要时执行。"""
|
||||
return MetaMusic(
|
||||
org_string=path.name,
|
||||
title=path.stem,
|
||||
audio_format=path.suffix.lstrip(".").upper() or None,
|
||||
).apply_path_context(path)
|
||||
|
||||
try:
|
||||
audio = MutagenFile(path, easy=True)
|
||||
except Exception as err:
|
||||
logger.warning(f"读取音频标签失败:{path} - {err}")
|
||||
return fallback
|
||||
return filename_fallback()
|
||||
if not audio:
|
||||
return fallback
|
||||
return filename_fallback()
|
||||
|
||||
tags = audio.tags or {}
|
||||
track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber"))
|
||||
@@ -52,7 +55,7 @@ class AudioMetadataHelper:
|
||||
bitrate=cls._optional_int(getattr(info, "bitrate", None)),
|
||||
duration=round(info.length) if info and getattr(info, "length", None) else None,
|
||||
isrc=cls._first(tags, "isrc"),
|
||||
)
|
||||
).apply_path_context(path)
|
||||
|
||||
@classmethod
|
||||
def write(
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID",
|
||||
"媒体ID格式无效": "Invalid media ID format",
|
||||
"MusicBrainz 只能用于音乐刮削": "MusicBrainz can only be used for music scraping",
|
||||
"音乐元数据源只能用于音乐刮削": "Music metadata sources can only be used for music scraping",
|
||||
"刮削失败,无法识别音乐信息": "Scraping failed: unable to recognize music information",
|
||||
"刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information",
|
||||
"刮削路径不存在": "Scraping path does not exist",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
|
||||
"媒体ID格式无效": "媒體ID格式無效",
|
||||
"MusicBrainz 只能用于音乐刮削": "MusicBrainz 只能用於音樂刮削",
|
||||
"音乐元数据源只能用于音乐刮削": "音樂中繼資料來源只能用於音樂刮削",
|
||||
"刮削失败,无法识别音乐信息": "刮削失敗,無法識別音樂資訊",
|
||||
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
|
||||
"刮削路径不存在": "刮削路徑不存在",
|
||||
|
||||
@@ -229,6 +229,12 @@ class AniListModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# AniList 只处理动画影视,不能在音乐模块未响应时接管音乐请求。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
|
||||
@@ -258,6 +264,12 @@ class AniListModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 AniList。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = (
|
||||
|
||||
@@ -93,6 +93,12 @@ class BangumiModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# Bangumi 只处理影视,不能在音乐模块未响应时接管音乐请求。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
@@ -129,6 +135,12 @@ class BangumiModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 Bangumi。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import re
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import cn2an
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicAlbumInfo, MusicInfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
@@ -17,11 +17,14 @@ from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.limit import rate_limit_exponential
|
||||
from app.utils.media import is_media_source_enabled
|
||||
from app.utils.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
|
||||
|
||||
_music_source = "doubanmusic"
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -48,6 +51,11 @@ class DoubanModule(_ModuleBase):
|
||||
def get_name() -> str:
|
||||
return "豆瓣"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return DoubanModule._music_source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
@@ -69,6 +77,424 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
return 2
|
||||
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[List[MusicInfo]]:
|
||||
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
|
||||
if not is_media_source_selected(source, self._music_source):
|
||||
return None
|
||||
keyword = meta.album or meta.title
|
||||
if not keyword:
|
||||
return []
|
||||
result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100)))
|
||||
return self._build_music_search_results(result)
|
||||
|
||||
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
|
||||
"""按豆瓣音乐原生 ID 获取专辑或专辑内曲目详情。"""
|
||||
if source != self._music_source or not media_id:
|
||||
return None
|
||||
album_id, separator, track_id = str(media_id).partition(":")
|
||||
album = self.music_album(source, album_id)
|
||||
if not album:
|
||||
return None
|
||||
if separator and track_id:
|
||||
return next(
|
||||
(
|
||||
track for track in album.tracks
|
||||
if track.media_id == media_id or str(track.track_number or "") == track_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return album.to_music_info()
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._music_source or not media_id:
|
||||
return None
|
||||
info = self.doubanapi.music_detail(subject_id=str(media_id))
|
||||
return self._douban_music_to_album(info) if info else None
|
||||
|
||||
def _recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
return self.recognize_music(source, str(resolved_media_id))
|
||||
if not meta:
|
||||
return None
|
||||
candidates = self.search_music(meta=meta, limit=20, source=source) or []
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not self._same_music_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and candidate.artists and not any(
|
||||
self._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
if meta.album and meta.title:
|
||||
album = self.music_album(source, candidate.media_id)
|
||||
matched_track = self._select_douban_music_track(meta, album)
|
||||
if matched_track:
|
||||
return matched_track
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
async def _async_recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
album_id, separator, track_id = str(resolved_media_id).partition(":")
|
||||
info = await self.doubanapi.async_music_detail(subject_id=album_id)
|
||||
album = self._douban_music_to_album(info) if info else None
|
||||
if not album:
|
||||
return None
|
||||
if separator and track_id:
|
||||
return next(
|
||||
(
|
||||
track for track in album.tracks
|
||||
if track.media_id == resolved_media_id
|
||||
or str(track.track_number or "") == track_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return album.to_music_info()
|
||||
if not meta:
|
||||
return None
|
||||
keyword = meta.album or meta.title
|
||||
if not keyword:
|
||||
return None
|
||||
result = await self.doubanapi.async_music_search(keyword=keyword, count=20)
|
||||
candidates = self._build_music_search_results(result)
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not self._same_music_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and candidate.artists and not any(
|
||||
self._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
if meta.album and meta.title:
|
||||
info = await self.doubanapi.async_music_detail(
|
||||
subject_id=str(candidate.media_id)
|
||||
)
|
||||
album = self._douban_music_to_album(info) if info else None
|
||||
matched_track = self._select_douban_music_track(meta, album)
|
||||
if matched_track:
|
||||
return matched_track
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_douban_music_track(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
album: Optional[MusicAlbumInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""从豆瓣专辑曲目中选择与本地曲名、艺术家及曲序最一致的音轨。"""
|
||||
if not album:
|
||||
return None
|
||||
candidates = [
|
||||
track for track in album.tracks
|
||||
if cls._same_music_text(meta.title, track.title)
|
||||
]
|
||||
if meta.artists:
|
||||
candidates = [
|
||||
track for track in candidates
|
||||
if any(
|
||||
cls._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in track.artists
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(
|
||||
key=lambda track: (
|
||||
bool(meta.track_number and track.track_number == meta.track_number),
|
||||
-abs((meta.duration or track.duration or 0) - (track.duration or meta.duration or 0)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
@classmethod
|
||||
def _build_music_search_results(cls, result: Optional[dict]) -> List[MusicInfo]:
|
||||
"""把豆瓣音乐搜索响应转换为专辑候选列表。"""
|
||||
items = (result or {}).get("items") or (result or {}).get("musics") or []
|
||||
candidates = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target_type = str(item.get("target_type") or item.get("type") or "").casefold()
|
||||
target = item.get("target") if isinstance(item.get("target"), dict) else item
|
||||
type_name = str(target.get("type_name") or target.get("subtype") or "")
|
||||
if target_type and target_type not in {"music", "音乐"}:
|
||||
continue
|
||||
if type_name and type_name not in {"音乐", "music"}:
|
||||
continue
|
||||
media_id = cls._douban_music_text(
|
||||
target.get("id") or item.get("target_id") or item.get("id")
|
||||
)
|
||||
title = cls._douban_music_text(target.get("title") or target.get("name"))
|
||||
if not media_id or not title:
|
||||
continue
|
||||
artists = cls._douban_music_artists(target)
|
||||
release_date = cls._douban_music_date(target)
|
||||
cover_url = cls._douban_music_cover(target)
|
||||
candidate = MusicInfo(
|
||||
source=cls._music_source,
|
||||
media_id=media_id,
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title=title,
|
||||
artists=artists,
|
||||
album=title,
|
||||
album_artist=" / ".join(artists) or None,
|
||||
album_id=media_id,
|
||||
year=cls._douban_music_year(target.get("year") or release_date),
|
||||
release_date=release_date,
|
||||
cover_url=cover_url,
|
||||
names=[title],
|
||||
detail_link=f"https://music.douban.com/subject/{media_id}/",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
@classmethod
|
||||
def _douban_music_to_album(cls, info: dict[str, Any]) -> Optional[MusicAlbumInfo]:
|
||||
"""把豆瓣音乐详情转换为标准专辑信息和曲目。"""
|
||||
media_id = cls._douban_music_text(info.get("id") or info.get("subject_id"))
|
||||
title = cls._douban_music_text(info.get("title") or info.get("name"))
|
||||
if not media_id or not title:
|
||||
return None
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
artists = cls._douban_music_artists(info)
|
||||
release_date = cls._douban_music_date(info)
|
||||
tags = [
|
||||
cls._douban_music_text(item.get("name") if isinstance(item, dict) else item)
|
||||
for item in (info.get("tags") or [])
|
||||
]
|
||||
genres = [str(item) for item in info.get("genres") or [] if item]
|
||||
rating = info.get("rating") if isinstance(info.get("rating"), dict) else {}
|
||||
album = MusicAlbumInfo(
|
||||
source=cls._music_source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=artists,
|
||||
album_type=cls._douban_music_first(
|
||||
info.get("media") or attrs.get("media")
|
||||
) or "Album",
|
||||
release_date=release_date,
|
||||
cover_url=cls._douban_music_cover(info),
|
||||
genres=genres,
|
||||
tags=[item for item in tags if item],
|
||||
rating=cls._douban_music_float(rating.get("value") or rating.get("average")),
|
||||
rating_votes=cls._douban_music_int(
|
||||
rating.get("count") or rating.get("numRaters") or info.get("ratings_count")
|
||||
),
|
||||
detail_link=f"https://music.douban.com/subject/{media_id}/",
|
||||
raw_data={
|
||||
"overview": cls._douban_music_text(info.get("intro") or info.get("summary")),
|
||||
"publisher": cls._douban_music_first(
|
||||
info.get("publisher") or attrs.get("publisher")
|
||||
),
|
||||
},
|
||||
)
|
||||
album.tracks = cls._douban_music_tracks(info, album)
|
||||
return album
|
||||
|
||||
@classmethod
|
||||
def _douban_music_tracks(
|
||||
cls,
|
||||
info: dict[str, Any],
|
||||
album: MusicAlbumInfo,
|
||||
) -> List[MusicInfo]:
|
||||
"""从豆瓣新旧响应结构中提取专辑曲目。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
# Frodo 当前音乐详情使用 songs;tracks/attrs.tracks 兼容旧接口响应。
|
||||
tracks = info.get("songs") or info.get("tracks") or attrs.get("tracks") or []
|
||||
if isinstance(tracks, str):
|
||||
tracks = tracks.splitlines()
|
||||
elif not isinstance(tracks, list):
|
||||
tracks = []
|
||||
elif len(tracks) == 1 and isinstance(tracks[0], str) and "\n" in tracks[0]:
|
||||
tracks = tracks[0].splitlines()
|
||||
results = []
|
||||
for index, item in enumerate(tracks, start=1):
|
||||
if isinstance(item, dict):
|
||||
title = cls._douban_music_text(item.get("title") or item.get("name"))
|
||||
track_number = cls._douban_music_int(item.get("track_number") or item.get("position")) or index
|
||||
duration = cls._douban_music_int(item.get("duration"))
|
||||
duration = duration if duration and duration > 0 else None
|
||||
disc_number = cls._douban_music_int(
|
||||
item.get("disc_number") or item.get("disc")
|
||||
)
|
||||
artists = cls._douban_music_artists(item) or list(album.artists)
|
||||
cover_url = cls._douban_music_text(item.get("cover_url")) or album.cover_url
|
||||
raw_data = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"apple_album_id": item.get("apple_album_id"),
|
||||
"apple_track_id": item.get("apple_track_id"),
|
||||
"preview_url": item.get("preview_url"),
|
||||
}.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
else:
|
||||
title = cls._clean_douban_track_title(item)
|
||||
track_number = index
|
||||
duration = None
|
||||
disc_number = None
|
||||
artists = list(album.artists)
|
||||
cover_url = album.cover_url
|
||||
raw_data = {}
|
||||
if not title:
|
||||
continue
|
||||
results.append(MusicInfo(
|
||||
source=cls._music_source,
|
||||
# 豆瓣歌曲没有独立 subject ID,使用专辑内绝对顺序避免多碟曲序重复。
|
||||
media_id=f"{album.media_id}:{index}",
|
||||
title=title,
|
||||
artists=artists,
|
||||
album=album.title,
|
||||
album_artist=album.artist or None,
|
||||
album_id=album.media_id,
|
||||
album_type=album.album_type,
|
||||
year=album.year,
|
||||
release_date=album.release_date,
|
||||
disc_number=disc_number,
|
||||
track_number=track_number,
|
||||
duration=duration,
|
||||
cover_url=cover_url,
|
||||
genres=list(album.genres),
|
||||
names=[title],
|
||||
detail_link=album.detail_link,
|
||||
raw_data=raw_data,
|
||||
))
|
||||
for track in results:
|
||||
track.total_tracks = len(results)
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _douban_music_artists(cls, info: dict[str, Any]) -> List[str]:
|
||||
"""从豆瓣新旧响应结构中提取艺术家名称。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
values = (
|
||||
info.get("artists")
|
||||
or info.get("artist_names")
|
||||
or info.get("author")
|
||||
or info.get("singer")
|
||||
or attrs.get("singer")
|
||||
or []
|
||||
)
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
artists = []
|
||||
seen = set()
|
||||
for item in values:
|
||||
value = item.get("name") if isinstance(item, dict) else item
|
||||
text = cls._douban_music_text(value)
|
||||
identity = MetaMusic.compact_text(text) if text else ""
|
||||
if not text or identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
artists.append(text)
|
||||
return artists
|
||||
|
||||
@classmethod
|
||||
def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]:
|
||||
"""从豆瓣多种图片字段中提取清晰封面。"""
|
||||
pic = info.get("pic") if isinstance(info.get("pic"), dict) else {}
|
||||
cover = info.get("cover") if isinstance(info.get("cover"), dict) else {}
|
||||
cover_img = info.get("cover_img") if isinstance(info.get("cover_img"), dict) else {}
|
||||
return next(
|
||||
(
|
||||
text for value in [
|
||||
pic.get("large"),
|
||||
cover_img.get("url"),
|
||||
cover.get("large"),
|
||||
cover.get("normal"),
|
||||
info.get("cover_url"),
|
||||
info.get("image"),
|
||||
]
|
||||
if (text := cls._douban_music_text(value))
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _douban_music_date(cls, info: dict[str, Any]) -> Optional[str]:
|
||||
"""从豆瓣新旧响应结构中提取首个发行日期。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
return cls._douban_music_first(info.get("pubdate") or attrs.get("pubdate"))
|
||||
|
||||
@staticmethod
|
||||
def _clean_douban_track_title(value: Any) -> Optional[str]:
|
||||
"""清理豆瓣旧接口曲目文本开头的序号。"""
|
||||
text = str(value or "").strip()
|
||||
return re.sub(r"^\s*(?:\d+[\.、)]\s*)", "", text) or None
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_text(value: Any) -> Optional[str]:
|
||||
"""把豆瓣外部响应值转换为去空白文本。"""
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or None
|
||||
|
||||
@classmethod
|
||||
def _douban_music_first(cls, value: Any) -> Optional[str]:
|
||||
"""从豆瓣列表或标量字段中提取首个文本。"""
|
||||
if isinstance(value, list):
|
||||
return next((text for item in value if (text := cls._douban_music_text(item))), None)
|
||||
return cls._douban_music_text(value)
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_int(value: Any) -> Optional[int]:
|
||||
"""将豆瓣外部响应值安全转换为整数。"""
|
||||
try:
|
||||
return int(value) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_float(value: Any) -> float:
|
||||
"""将豆瓣外部评分安全转换为浮点数。"""
|
||||
try:
|
||||
return float(value) if value not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _douban_music_year(cls, value: Any) -> Optional[int]:
|
||||
"""从豆瓣年份或日期文本中提取四位年份。"""
|
||||
text = cls._douban_music_text(value)
|
||||
return int(text[:4]) if text and text[:4].isdigit() else None
|
||||
|
||||
@staticmethod
|
||||
def _same_music_text(left: Optional[str], right: Optional[str]) -> bool:
|
||||
"""使用音乐元数据紧凑文本规则比较豆瓣候选。"""
|
||||
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
|
||||
|
||||
@staticmethod
|
||||
def _prepare_search_names(meta: MetaBase) -> List[str]:
|
||||
"""
|
||||
@@ -258,6 +684,16 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
return self._recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
return self._recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
@@ -278,6 +714,16 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
return await self._async_recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
return await self._async_recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
|
||||
@@ -370,6 +370,31 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
return await self.__async_invoke_search(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def music_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""搜索豆瓣音乐条目。"""
|
||||
return self.__invoke_search(
|
||||
self._urls["search_subject"],
|
||||
type="music",
|
||||
q=keyword,
|
||||
start=start,
|
||||
count=count,
|
||||
_ts=ts,
|
||||
)
|
||||
|
||||
async def async_music_search(self, keyword: str, start: Optional[int] = 0,
|
||||
count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""异步搜索豆瓣音乐条目。"""
|
||||
return await self.__async_invoke_search(
|
||||
self._urls["search_subject"],
|
||||
type="music",
|
||||
q=keyword,
|
||||
start=start,
|
||||
count=count,
|
||||
_ts=ts,
|
||||
)
|
||||
|
||||
def group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
@@ -590,6 +615,14 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
"""
|
||||
return await self.__async_invoke_search(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def music_detail(self, subject_id: str) -> dict:
|
||||
"""获取豆瓣音乐详情。"""
|
||||
return self.__invoke_search(self._urls["music_detail"] + subject_id)
|
||||
|
||||
async def async_music_detail(self, subject_id: str) -> dict:
|
||||
"""异步获取豆瓣音乐详情。"""
|
||||
return await self.__async_invoke_search(self._urls["music_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from requests import Session
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
@@ -22,6 +21,7 @@ from app.modules import _ModuleBase
|
||||
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_selected
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ class MusicBrainzModule(_ModuleBase):
|
||||
"""返回模块展示名称。"""
|
||||
return "MusicBrainz"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return MusicBrainzModule._source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的媒体识别类型。"""
|
||||
@@ -130,8 +135,15 @@ class MusicBrainzModule(_ModuleBase):
|
||||
"""音乐识别在所有 MediaRecognize 模块中最先响应,避免音乐请求被影视模块误识别。"""
|
||||
return 0
|
||||
|
||||
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
recordings = self._search_recordings(meta, limit=normalized_limit)
|
||||
albums = self._search_albums(meta, limit=normalized_limit)
|
||||
@@ -623,6 +635,9 @@ class MusicBrainzModule(_ModuleBase):
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
|
||||
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
|
||||
if source and source != self._source:
|
||||
return None
|
||||
# 非音乐请求交给影视识别模块,不占用识别管线
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
|
||||
return None
|
||||
|
||||
514
app/modules/theaudiodb/__init__.py
Normal file
514
app/modules/theaudiodb/__init__.py
Normal file
@@ -0,0 +1,514 @@
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
)
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_selected
|
||||
|
||||
|
||||
class TheAudioDbModule(_ModuleBase):
|
||||
"""通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。"""
|
||||
|
||||
_source = "theaudiodb"
|
||||
_base_url = "https://www.theaudiodb.com/api/v1/json"
|
||||
_detail_url = "https://www.theaudiodb.com"
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化无状态的 TheAudioDB 模块。"""
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""TheAudioDB 使用环境配置中的 API Key,无独立启用开关。"""
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块;当前实现没有需要释放的持久资源。"""
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 TheAudioDB 艺术家搜索接口连通性。"""
|
||||
result = self._request_json("search.php", {"s": "coldplay"})
|
||||
return (True, "") if result is not None else (False, "TheAudioDB 网络连接失败")
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回模块展示名称。"""
|
||||
return "TheAudioDB"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return TheAudioDbModule._source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的媒体识别类型。"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""返回 TheAudioDB 模块子类型。"""
|
||||
return MediaRecognizeType.TheAudioDB
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回音乐识别优先级,位于默认 MusicBrainz 之后。"""
|
||||
return 1
|
||||
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
tracks = self._search_tracks(meta)
|
||||
albums = self._search_albums(meta)
|
||||
artists = self._search_artists(meta)
|
||||
return self._interleave_results(
|
||||
tracks,
|
||||
albums,
|
||||
artists,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
|
||||
if source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if mtype == MediaType.MUSIC and mediaid:
|
||||
return self.recognize_music(source, str(mediaid))
|
||||
return None
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
if resolved_media_id:
|
||||
return self.recognize_music(source, str(resolved_media_id))
|
||||
matched = self._select_track(meta, self._search_tracks(meta))
|
||||
if matched:
|
||||
return matched
|
||||
album = self._select_album(meta, self._search_albums(meta))
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""在线程池执行 TheAudioDB 同步识别,避免阻塞事件循环。"""
|
||||
return await run_in_threadpool(
|
||||
self.recognize_media,
|
||||
meta,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
|
||||
"""按 TheAudioDB 原生 ID 获取单曲详情,未命中时回退到专辑。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("track.php", {"h": media_id})
|
||||
track = self._first_entity(payload, "track", "tracks")
|
||||
if track:
|
||||
return self._track_to_info(track)
|
||||
album = self.music_album(source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("album.php", {"m": media_id})
|
||||
item = self._first_entity(payload, "album", "albums")
|
||||
if not item:
|
||||
return None
|
||||
album = self._album_to_info(item)
|
||||
tracks_payload = self._request_json("track.php", {"m": media_id})
|
||||
album.tracks = [
|
||||
info
|
||||
for track in self._entities(tracks_payload, "track", "tracks")
|
||||
if (info := self._track_to_info(track, album=album))
|
||||
]
|
||||
return album
|
||||
|
||||
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("artist.php", {"i": media_id})
|
||||
item = self._first_entity(payload, "artists", "artist")
|
||||
return self._artist_to_info(item) if item else None
|
||||
|
||||
def music_artist_albums(
|
||||
self,
|
||||
source: str,
|
||||
media_id: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
album_type: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 分页返回专辑列表。"""
|
||||
if source != self._source or not media_id:
|
||||
return []
|
||||
payload = self._request_json("album.php", {"i": media_id})
|
||||
albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
|
||||
if album_type:
|
||||
normalized_type = album_type.casefold()
|
||||
albums = [
|
||||
album for album in albums
|
||||
if (album.album_type or "").casefold() == normalized_type
|
||||
]
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清除 TheAudioDB 请求缓存。"""
|
||||
self._request_json.cache_clear()
|
||||
|
||||
def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]:
|
||||
"""使用曲名和艺术家搜索 TheAudioDB 单曲。"""
|
||||
title = meta.title
|
||||
if not title:
|
||||
return []
|
||||
params = {"t": title}
|
||||
if meta.artists:
|
||||
params["s"] = meta.artists[0]
|
||||
payload = self._request_json("searchtrack.php", params)
|
||||
return [
|
||||
info
|
||||
for item in self._entities(payload, "track", "tracks")
|
||||
if (info := self._track_to_info(item))
|
||||
]
|
||||
|
||||
def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]:
|
||||
"""使用专辑名和艺术家搜索 TheAudioDB 专辑。"""
|
||||
album_name = meta.album or meta.title
|
||||
if not album_name:
|
||||
return []
|
||||
params = {"a": album_name}
|
||||
if meta.artists:
|
||||
params["s"] = meta.artists[0]
|
||||
payload = self._request_json("searchalbum.php", params)
|
||||
return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
|
||||
|
||||
def _search_artists(self, meta: MetaMusic) -> list[MusicArtistInfo]:
|
||||
"""使用艺术家线索搜索 TheAudioDB 艺术家。"""
|
||||
name = meta.artists[0] if meta.artists else meta.title
|
||||
if not name:
|
||||
return []
|
||||
payload = self._request_json("search.php", {"s": name})
|
||||
return [
|
||||
self._artist_to_info(item)
|
||||
for item in self._entities(payload, "artists", "artist")
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _select_track(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: list[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按曲名和可用艺术家线索选择可信单曲候选。"""
|
||||
for candidate in candidates:
|
||||
if not cls._same_text(meta.title, candidate.title):
|
||||
continue
|
||||
if meta.artists and not any(
|
||||
cls._same_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_album(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: list[MusicAlbumInfo],
|
||||
) -> Optional[MusicAlbumInfo]:
|
||||
"""按专辑名和可用艺术家线索选择可信专辑候选。"""
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not cls._same_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and not any(
|
||||
cls._same_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _track_to_info(
|
||||
cls,
|
||||
item: dict[str, Any],
|
||||
album: Optional[MusicAlbumInfo] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""将 TheAudioDB 单曲响应转换为标准音乐信息。"""
|
||||
media_id = cls._text(item.get("idTrack") or item.get("id"))
|
||||
title = cls._text(item.get("strTrack") or item.get("name"))
|
||||
if not media_id or not title:
|
||||
return None
|
||||
artist = cls._text(item.get("strArtist"))
|
||||
artist_id = cls._text(item.get("idArtist"))
|
||||
duration_ms = cls._optional_int(item.get("intDuration"))
|
||||
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else list(album.artists if album else []),
|
||||
artist_ids=[artist_id] if artist_id else list(album.artist_ids if album else []),
|
||||
album=cls._text(item.get("strAlbum")) or (album.title if album else None),
|
||||
album_artist=artist or (album.artist if album else None),
|
||||
album_id=cls._text(item.get("idAlbum")) or (album.media_id if album else None),
|
||||
year=album.year if album else None,
|
||||
release_date=album.release_date if album else None,
|
||||
disc_number=cls._optional_int(item.get("intCD")),
|
||||
track_number=cls._optional_int(item.get("intTrackNumber")),
|
||||
duration=duration_ms // 1000 if duration_ms else None,
|
||||
isrc=cls._text(item.get("strISRC")),
|
||||
cover_url=cls._first_text(
|
||||
item,
|
||||
"strTrackThumb",
|
||||
"strTrack3DCase",
|
||||
) or (album.cover_url if album else None),
|
||||
lyrics=cls._text(item.get("strTrackLyrics")),
|
||||
category=" / ".join(genres),
|
||||
genres=genres,
|
||||
names=cls._unique_texts([title, item.get("strTrackAlternate")]),
|
||||
detail_link=f"{cls._detail_url}/track/{media_id}",
|
||||
raw_data={
|
||||
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
|
||||
"musicbrainz_album_id": cls._text(item.get("strMusicBrainzAlbumID")),
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _album_to_info(cls, item: dict[str, Any]) -> MusicAlbumInfo:
|
||||
"""将 TheAudioDB 专辑响应转换为标准专辑信息。"""
|
||||
media_id = cls._text(item.get("idAlbum") or item.get("id"))
|
||||
title = cls._text(item.get("strAlbum") or item.get("name"))
|
||||
artist = cls._text(item.get("strArtist"))
|
||||
artist_id = cls._text(item.get("idArtist"))
|
||||
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
|
||||
release_date = cls._text(item.get("strReleaseDate"))
|
||||
if not release_date:
|
||||
release_date = cls._text(item.get("intYearReleased"))
|
||||
return MusicAlbumInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else [],
|
||||
artist_ids=[artist_id] if artist_id else [],
|
||||
album_type=cls._text(item.get("strReleaseFormat")),
|
||||
release_date=release_date,
|
||||
cover_url=cls._first_text(
|
||||
item,
|
||||
"strAlbumThumbHQ",
|
||||
"strAlbumThumb",
|
||||
"strAlbum3DCase",
|
||||
"strAlbumCDart",
|
||||
),
|
||||
genres=genres,
|
||||
tags=cls._unique_texts([item.get("strMood"), item.get("strStyle")]),
|
||||
rating=cls._optional_float(item.get("intScore")),
|
||||
rating_votes=cls._optional_int(item.get("intScoreVotes")),
|
||||
detail_link=f"{cls._detail_url}/album/{media_id}" if media_id else None,
|
||||
raw_data={"description": cls._localized_text(item, "strDescription")},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _artist_to_info(cls, item: dict[str, Any]) -> MusicArtistInfo:
|
||||
"""将 TheAudioDB 艺术家响应转换为标准艺术家信息。"""
|
||||
media_id = cls._text(item.get("idArtist") or item.get("id"))
|
||||
name = cls._text(item.get("strArtist") or item.get("name"))
|
||||
links = {}
|
||||
website = cls._text(item.get("strWebsite"))
|
||||
if website:
|
||||
links["official homepage"] = website
|
||||
return MusicArtistInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
name=name,
|
||||
disambiguation=cls._text(item.get("strArtistAlternate")),
|
||||
artist_type=cls._text(item.get("strStyle")),
|
||||
gender=cls._text(item.get("strGender")),
|
||||
country=cls._text(item.get("strCountry")),
|
||||
begin_date=cls._text(item.get("intFormedYear") or item.get("intBornYear")),
|
||||
end_date=cls._text(item.get("intDiedYear") or item.get("strDisbanded")),
|
||||
ended=bool(item.get("intDiedYear") or item.get("strDisbanded")),
|
||||
genres=cls._unique_texts([item.get("strGenre"), item.get("strStyle")]),
|
||||
aliases=cls._split_text(item.get("strArtistAlternate")),
|
||||
image_url=cls._first_text(
|
||||
item,
|
||||
"strArtistThumb",
|
||||
"strArtistFanart",
|
||||
"strArtistWideThumb",
|
||||
"strArtistCutout",
|
||||
),
|
||||
detail_link=f"{cls._detail_url}/artist/{media_id}" if media_id else None,
|
||||
external_links=links,
|
||||
raw_data={
|
||||
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
|
||||
"biography": cls._localized_text(item, "strBiography"),
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interleave_results(
|
||||
tracks: list[MusicInfo],
|
||||
albums: list[MusicAlbumInfo],
|
||||
artists: list[MusicArtistInfo],
|
||||
limit: int,
|
||||
) -> list[MusicInfo]:
|
||||
"""交错合并三类搜索结果,避免单一实体占满候选列表。"""
|
||||
groups = [tracks, [item.to_music_info() for item in albums], [item.to_music_info() for item in artists]]
|
||||
results: list[MusicInfo] = []
|
||||
for index in range(max((len(group) for group in groups), default=0)):
|
||||
for group in groups:
|
||||
if index < len(group):
|
||||
results.append(group[index])
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True)
|
||||
def _request_json(
|
||||
cls,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""请求 TheAudioDB V1 JSON 接口并统一处理错误响应。"""
|
||||
api_key = str(settings.THEAUDIODB_API_KEY or "").strip()
|
||||
if not api_key:
|
||||
logger.warning("TheAudioDB API Key 未配置,跳过请求")
|
||||
return None
|
||||
response = RequestUtils(
|
||||
ua=settings.USER_AGENT,
|
||||
proxies=settings.PROXY,
|
||||
timeout=30,
|
||||
).get_res(
|
||||
url=f"{cls._base_url}/{api_key}/{endpoint}",
|
||||
params=params or {},
|
||||
)
|
||||
if not response or response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as err:
|
||||
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _entities(
|
||||
payload: Optional[dict[str, Any]],
|
||||
*keys: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""从兼容 V1/V2 命名的响应字段中提取实体列表。"""
|
||||
if not payload:
|
||||
return []
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _first_entity(
|
||||
cls,
|
||||
payload: Optional[dict[str, Any]],
|
||||
*keys: str,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""返回响应中的首个实体。"""
|
||||
entities = cls._entities(payload, *keys)
|
||||
return entities[0] if entities else None
|
||||
|
||||
@staticmethod
|
||||
def _text(value: Any) -> Optional[str]:
|
||||
"""把外部响应值转换为去空白文本。"""
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or None
|
||||
|
||||
@classmethod
|
||||
def _first_text(cls, item: dict[str, Any], *keys: str) -> Optional[str]:
|
||||
"""按优先级返回外部响应中的首个非空文本。"""
|
||||
return next((text for key in keys if (text := cls._text(item.get(key)))), None)
|
||||
|
||||
@classmethod
|
||||
def _localized_text(cls, item: dict[str, Any], prefix: str) -> Optional[str]:
|
||||
"""优先返回中文说明,不存在时回退到英文说明。"""
|
||||
return cls._first_text(item, f"{prefix}CN", f"{prefix}EN")
|
||||
|
||||
@staticmethod
|
||||
def _optional_int(value: Any) -> Optional[int]:
|
||||
"""将外部响应值安全转换为整数。"""
|
||||
try:
|
||||
return int(value) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _optional_float(value: Any) -> float:
|
||||
"""将外部评分安全转换为浮点数。"""
|
||||
try:
|
||||
return float(value) if value not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _split_text(cls, value: Any) -> list[str]:
|
||||
"""把分号或斜线分隔的外部文本转换为去重列表。"""
|
||||
text = cls._text(value)
|
||||
if not text:
|
||||
return []
|
||||
return cls._unique_texts(text.replace("/", ";").split(";"))
|
||||
|
||||
@classmethod
|
||||
def _unique_texts(cls, values: list[Any]) -> list[str]:
|
||||
"""过滤空值并按大小写无关方式去重。"""
|
||||
results = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
text = cls._text(value)
|
||||
identity = text.casefold() if text else ""
|
||||
if not text or identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(text)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _same_text(left: Optional[str], right: Optional[str]) -> bool:
|
||||
"""使用音乐元数据紧凑文本规则比较标题和艺术家。"""
|
||||
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
|
||||
@@ -476,6 +476,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
@@ -562,6 +565,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class DownloaderTorrent(BaseModel):
|
||||
@@ -63,6 +64,7 @@ class TransferTask(BaseModel):
|
||||
meta: Optional[Any] = None
|
||||
mediainfo: Optional[Any] = None
|
||||
media_source: Optional[str] = None
|
||||
mtype: Optional[MediaType] = None
|
||||
target_directory: Optional[TransferDirectoryConf] = None
|
||||
target_storage: Optional[str] = None
|
||||
target_path: Optional[Path] = None
|
||||
|
||||
@@ -423,6 +423,8 @@ class MediaRecognizeType(Enum):
|
||||
AniList = "AniList"
|
||||
# MusicBrainz
|
||||
MusicBrainz = "MusicBrainz"
|
||||
# TheAudioDB
|
||||
TheAudioDB = "TheAudioDB"
|
||||
|
||||
|
||||
# 用户配置Key字典
|
||||
|
||||
@@ -9,6 +9,11 @@ MEDIA_SOURCE_ALIASES = {
|
||||
"douban": "douban",
|
||||
"bangumi": "bangumi",
|
||||
"anilist": "anilist",
|
||||
"musicbrainz": "musicbrainz",
|
||||
"theaudiodb": "theaudiodb",
|
||||
"audio_db": "theaudiodb",
|
||||
"doubanmusic": "doubanmusic",
|
||||
"douban_music": "doubanmusic",
|
||||
}
|
||||
|
||||
MEDIA_SOURCE_PREFIXES = {
|
||||
@@ -16,6 +21,9 @@ MEDIA_SOURCE_PREFIXES = {
|
||||
"douban": "douban",
|
||||
"bangumi": "bangumi",
|
||||
"anilist": "anilist",
|
||||
"musicbrainz": "musicbrainz",
|
||||
"theaudiodb": "theaudiodb",
|
||||
"doubanmusic": "doubanmusic",
|
||||
}
|
||||
|
||||
MEDIA_SOURCE_ID_FIELDS = {
|
||||
@@ -23,8 +31,19 @@ MEDIA_SOURCE_ID_FIELDS = {
|
||||
"douban": ("douban_id", "doubanid"),
|
||||
"bangumi": ("bangumi_id", "bangumiid"),
|
||||
"anilist": ("anilist_id", "anilistid"),
|
||||
"musicbrainz": ("media_id",),
|
||||
"theaudiodb": ("media_id", "theaudiodb_id"),
|
||||
"doubanmusic": ("media_id", "douban_id", "doubanid"),
|
||||
}
|
||||
|
||||
MUSIC_MEDIA_SOURCE_ORDER = ("musicbrainz", "theaudiodb", "doubanmusic")
|
||||
MUSIC_MEDIA_SOURCES = frozenset(MUSIC_MEDIA_SOURCE_ORDER)
|
||||
|
||||
|
||||
def is_music_media_source(source: Optional[str]) -> bool:
|
||||
"""判断单个请求级来源是否为内置音乐元数据源。"""
|
||||
return normalize_media_source(source) in MUSIC_MEDIA_SOURCES
|
||||
|
||||
|
||||
def normalize_media_source(source: Optional[str]) -> Optional[str]:
|
||||
"""规范化媒体数据源名称,兼容外部使用的 ``tmdb`` 前缀。"""
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool
|
||||
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
|
||||
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
|
||||
from app.agent.tools.impl.recognize_media import RecognizeMediaTool
|
||||
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
|
||||
from app.agent.tools.impl.search_media import SearchMediaTool
|
||||
from app.core.context import (
|
||||
@@ -60,6 +61,33 @@ def _album() -> MusicInfo:
|
||||
)
|
||||
|
||||
|
||||
def test_recognize_music_title_uses_media_chain_automatic_sources():
|
||||
"""Agent 音乐标题识别应进入 MediaChain 自动多源流程,不再固定 MusicBrainz。"""
|
||||
expected = _recording()
|
||||
recognize = AsyncMock(return_value=expected)
|
||||
tool = RecognizeMediaTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.recognize_media.MediaChain.async_recognize_by_meta",
|
||||
new=recognize,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
title="晴天",
|
||||
media_type="music",
|
||||
artist="周杰伦",
|
||||
album="叶惠美",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["media_info"]["media_source"] == "musicbrainz"
|
||||
recognized_meta = recognize.await_args.args[0]
|
||||
assert recognized_meta.artists == ["周杰伦"]
|
||||
assert recognized_meta.album == "叶惠美"
|
||||
assert "source" not in recognize.await_args.kwargs
|
||||
|
||||
|
||||
def test_search_media_filters_music_entities_and_returns_stable_identity():
|
||||
"""音乐搜索应区分单曲和专辑,并返回后续工具可复用的来源 ID。"""
|
||||
async_search = AsyncMock(return_value=[_recording(), _album()])
|
||||
|
||||
@@ -112,6 +112,49 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
|
||||
assert meta.audio_format == "MP3"
|
||||
|
||||
|
||||
def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, monkeypatch):
|
||||
"""标签不可读时应直接使用完整动态模式解析复杂音乐文件名。"""
|
||||
audio_path = tmp_path / (
|
||||
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
|
||||
)
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
|
||||
|
||||
meta = AudioMetadataHelper.read(audio_path)
|
||||
|
||||
assert meta.artists == ["S.H.E"]
|
||||
assert meta.title == "S.H.E十七音乐会"
|
||||
assert meta.year == 2018
|
||||
assert meta.audio_format == "FLAC"
|
||||
|
||||
|
||||
def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""真实标签优先,缺失的艺术家和年份由完整文件名解析补齐。"""
|
||||
audio_path = tmp_path / "Daft Punk - Get Lucky 2013 FLAC.flac"
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
audio = SimpleNamespace(
|
||||
tags={"title": ["Tagged Title"]},
|
||||
info=SimpleNamespace(
|
||||
length=369.4,
|
||||
bitrate=1411200,
|
||||
bits_per_sample=16,
|
||||
sample_rate=44100,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
|
||||
meta = AudioMetadataHelper.read(audio_path)
|
||||
|
||||
assert meta.title == "Tagged Title"
|
||||
assert meta.artists == ["Daft Punk"]
|
||||
assert meta.year == 2013
|
||||
assert meta.duration == 369
|
||||
assert meta.sample_rate == 44100
|
||||
|
||||
|
||||
def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
|
||||
"""音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。"""
|
||||
class FakeAudio:
|
||||
|
||||
@@ -515,7 +515,7 @@ def test_chain_recognize_media_reports_music_share_result():
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003)
|
||||
music = _music_info()
|
||||
|
||||
with patch.object(chain, "run_module", return_value=music), patch(
|
||||
with patch("app.chain.music.MusicChain.recognize_best", return_value=music), patch(
|
||||
"app.chain.MoviePilotServerHelper.report_recognize_share",
|
||||
return_value=True,
|
||||
) as report_mock, patch(
|
||||
@@ -534,11 +534,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
music = _music_info()
|
||||
|
||||
with patch.object(
|
||||
chain,
|
||||
"run_module",
|
||||
side_effect=[None, music],
|
||||
) as run_module, patch(
|
||||
with patch(
|
||||
"app.chain.music.MusicChain.recognize_best",
|
||||
return_value=None,
|
||||
) as recognize_best, patch(
|
||||
"app.chain.music.MusicChain.recognize_from_source",
|
||||
return_value=music,
|
||||
) as recognize_source, patch(
|
||||
"app.chain.MoviePilotServerHelper.query_recognize_share",
|
||||
return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"},
|
||||
), patch(
|
||||
@@ -563,11 +565,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
|
||||
result = chain.recognize_media(meta=meta, cache=False)
|
||||
|
||||
assert result is music
|
||||
assert run_module.call_count == 2
|
||||
second_call = run_module.call_args_list[1]
|
||||
assert second_call.kwargs["mtype"] == MediaType.MUSIC
|
||||
assert second_call.kwargs["source"] == "musicbrainz"
|
||||
assert second_call.kwargs["mediaid"] == "recording-1"
|
||||
recognize_best.assert_called_once_with(meta=meta, cache=False)
|
||||
recognize_source.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
meta=meta,
|
||||
mediaid="recording-1",
|
||||
cache=False,
|
||||
)
|
||||
|
||||
|
||||
def test_chain_recognize_media_skips_music_report_for_fallback_result():
|
||||
@@ -576,7 +580,7 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result():
|
||||
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
|
||||
fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"])
|
||||
|
||||
with patch.object(chain, "run_module", return_value=fallback), patch(
|
||||
with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), patch(
|
||||
"app.chain.MoviePilotServerHelper.recognize_report"
|
||||
) as report_mock, patch(
|
||||
"app.chain.settings.MEDIA_RECOGNIZE_SHARE", True
|
||||
|
||||
@@ -146,4 +146,28 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
|
||||
fileitem=fileitem,
|
||||
mediainfo=info,
|
||||
overwrite=True,
|
||||
source="musicbrainz",
|
||||
)
|
||||
|
||||
|
||||
def test_scrape_music_without_source_keeps_automatic_recognition() -> None:
|
||||
"""未选择音乐源时刮削入口应传递空来源,让底层比较全部识别源。"""
|
||||
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
|
||||
media_chain = Mock()
|
||||
media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
|
||||
|
||||
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain):
|
||||
result = scrape(
|
||||
fileitem=fileitem,
|
||||
storage="local",
|
||||
type_name=MediaType.MUSIC,
|
||||
_=Mock(),
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
media_chain.scrape_music_metadata.assert_called_once_with(
|
||||
fileitem=fileitem,
|
||||
mediainfo=None,
|
||||
overwrite=True,
|
||||
source=None,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from app.core.meta import MetaMusic
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.meta import (
|
||||
MetaMusic,
|
||||
MusicNameContext,
|
||||
MusicNameParseResult,
|
||||
MusicNameParser,
|
||||
MusicNamePattern,
|
||||
MusicNameRegistry,
|
||||
)
|
||||
|
||||
|
||||
def parse_title(title: str) -> MetaMusic:
|
||||
@@ -6,6 +17,279 @@ def parse_title(title: str) -> MetaMusic:
|
||||
return MetaMusic(org_string=title, title=title, parse_title=True)
|
||||
|
||||
|
||||
def test_music_name_registry_supports_dynamic_pattern_and_parser():
|
||||
"""外部程序可独立注册命名模式和解析器,并在使用后完整注销。"""
|
||||
pattern_name = "test_program"
|
||||
parser_name = "test_program_parser"
|
||||
|
||||
def match_program(context: MusicNameContext):
|
||||
"""匹配测试程序的双冒号命名。"""
|
||||
if not context.text.startswith("PROGRAM::"):
|
||||
return None
|
||||
parts = context.text.split("::")
|
||||
return parts if len(parts) == 3 else None
|
||||
|
||||
def parse_program(context, matched):
|
||||
"""把测试程序命名解析为艺术家和标题。"""
|
||||
_prefix, artist, title = matched.payload
|
||||
return MusicNameParseResult(
|
||||
title=title,
|
||||
artists=[artist],
|
||||
year=context.year,
|
||||
)
|
||||
|
||||
MusicNameRegistry.register_pattern(
|
||||
MusicNamePattern(pattern_name, match_program, priority=1000)
|
||||
)
|
||||
MusicNameRegistry.register_parser(
|
||||
MusicNameParser(parser_name, (pattern_name,), parse_program, priority=1000)
|
||||
)
|
||||
try:
|
||||
context = MusicNameContext(
|
||||
raw="PROGRAM::周杰伦::晴天",
|
||||
normalized="PROGRAM::周杰伦::晴天",
|
||||
text="PROGRAM::周杰伦::晴天",
|
||||
artists=(),
|
||||
)
|
||||
matched = MusicNameRegistry.match_pattern(context)
|
||||
parser = MusicNameRegistry.match_parser(matched)
|
||||
|
||||
assert matched.pattern_name == pattern_name
|
||||
assert parser.name == parser_name
|
||||
|
||||
# FLAC 由公共层剔除,扩展解析器只需处理自身命名结构。
|
||||
meta = parse_title("PROGRAM::周杰伦::晴天 FLAC")
|
||||
assert meta.artists == ["周杰伦"]
|
||||
assert meta.title == "晴天"
|
||||
assert meta.audio_format == "FLAC"
|
||||
finally:
|
||||
MusicNameRegistry.unregister_parser(parser_name)
|
||||
MusicNameRegistry.unregister_pattern(pattern_name)
|
||||
|
||||
assert parser_name not in {parser.name for parser in MusicNameRegistry.get_parsers()}
|
||||
assert pattern_name not in {pattern.name for pattern in MusicNameRegistry.get_patterns()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "artists", "title", "year", "audio_format"),
|
||||
[
|
||||
(
|
||||
"The Beatles - Vinyl Collection【2020】【CD】【FLAC分轨】",
|
||||
["The Beatles"],
|
||||
"Vinyl Collection",
|
||||
2020,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"Primeval - Forged In Earth【2026】【WEB】【FLAC分轨】(24/48bit)",
|
||||
["Primeval"],
|
||||
"Forged In Earth",
|
||||
2026,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"Professor Green - Alive Till I'm Dead 2010-FLAC 分轨-nbarock",
|
||||
["Professor Green"],
|
||||
"Alive Till I'm Dead",
|
||||
2010,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"Togenashi Togeari 5th One Man Live Moments of Sound 2025 "
|
||||
"1080p BluRay x265 10bit FLAC 2.0-ADE",
|
||||
[],
|
||||
"Togenashi Togeari 5th One Man Live Moments of Sound",
|
||||
2025,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"田震 - 田震 (1996) FLAC {HRS-004-2}",
|
||||
["田震"],
|
||||
"田震",
|
||||
1996,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"[2022.02.23] 中恵光城 - SELENiTE -Mitsuki Nakae Works Best Album- "
|
||||
"[CD][FLAC+CUE+LOG+BK][KDSD-01049]",
|
||||
["中恵光城"],
|
||||
"SELENiTE -Mitsuki Nakae Works Best Album",
|
||||
None,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"[260123] 映画「超かぐや姫!」劇中曲「超かぐや姫! 」 "
|
||||
"[48kHz/24bit][FLAC]",
|
||||
[],
|
||||
"映画「超かぐや姫!」劇中曲「超かぐや姫! 」",
|
||||
None,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"[Audio-4U] 茶太 — Chata 1.0 (flac)",
|
||||
["茶太"],
|
||||
"Chata 1.0",
|
||||
None,
|
||||
"FLAC",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_apply_title_real_site_music_samples(
|
||||
raw: str,
|
||||
artists: list[str],
|
||||
title: str,
|
||||
year: Optional[int],
|
||||
audio_format: str,
|
||||
):
|
||||
"""真实站点音乐种子标题应剔除公共干扰并保留有效命名字段。"""
|
||||
meta = parse_title(raw)
|
||||
|
||||
assert meta.artists == artists
|
||||
assert meta.title == title
|
||||
assert meta.year == year
|
||||
assert meta.audio_format == audio_format
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "artists", "title", "year"),
|
||||
[
|
||||
(
|
||||
"Aimer-Aimer Hall Tour 2022 ''Walpurgisnacht'' Live at "
|
||||
"TOKYO GARDEN THEATER Blu-ray 1080p AVC LPCM 2.0",
|
||||
[],
|
||||
"Aimer-Aimer Hall Tour ''Walpurgisnacht'' Live at TOKYO GARDEN THEATER",
|
||||
2022,
|
||||
),
|
||||
(
|
||||
"MANATSU NO ZENKOKU TOUR 2021 FINAL! IN TOKYO DOME "
|
||||
"Blu-ray 1080p AVC LPCM 2.0",
|
||||
[],
|
||||
"MANATSU NO ZENKOKU TOUR FINAL! IN TOKYO DOME",
|
||||
2021,
|
||||
),
|
||||
(
|
||||
"Rainie Yang - Ban Shu Xuan Yan 2008 DVD 480i MPEG-2 MPEG-2",
|
||||
["Rainie Yang"],
|
||||
"Ban Shu Xuan Yan",
|
||||
2008,
|
||||
),
|
||||
(
|
||||
"SARD UNDERGROUND LIVE TOUR 2025 FANTASY "
|
||||
"Blu-ray 1080p AVC LPCM2.0",
|
||||
[],
|
||||
"SARD UNDERGROUND LIVE TOUR FANTASY",
|
||||
2025,
|
||||
),
|
||||
(
|
||||
"Kylie: Tension Tour Live 2026 2160p NF WEB-DL "
|
||||
"DDP 5.1 H.265-CHORTLE",
|
||||
[],
|
||||
"Kylie: Tension Tour Live",
|
||||
2026,
|
||||
),
|
||||
(
|
||||
"Nogizaka46 2021 'Kimi ni Shikarareta' Type-A, B, C, D,"
|
||||
"Blu-ray 1080p AVC",
|
||||
[],
|
||||
"Nogizaka46 'Kimi ni Shikarareta' Type-A, B, C, D",
|
||||
2021,
|
||||
),
|
||||
(
|
||||
"SBS Korea Pop Music Festival in Summer 2026 "
|
||||
"1080p AAC 2.0 x264@JJL",
|
||||
[],
|
||||
"SBS Korea Pop Music Festival in Summer",
|
||||
2026,
|
||||
),
|
||||
(
|
||||
"RTHK31 China Philharmonic Orchestra Concert Series - "
|
||||
"23rd Anniversary Concert 260704 1080i HDTV H264-NGBRTHK31",
|
||||
["RTHK31 China Philharmonic Orchestra Concert Series"],
|
||||
"23rd Anniversary Concert",
|
||||
2026,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_music_video_scene_pattern_uses_music_specific_token_parser(
|
||||
raw: str,
|
||||
artists: list[str],
|
||||
title: str,
|
||||
year: int,
|
||||
):
|
||||
"""影视式音乐资源应按音乐 token 语义清理,且保留年份后的演出名称。"""
|
||||
context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None)
|
||||
matched = MusicNameRegistry.match_pattern(context)
|
||||
meta = parse_title(raw)
|
||||
|
||||
assert matched.pattern_name == "music_video_scene"
|
||||
assert meta.artists == artists
|
||||
assert meta.title == title
|
||||
assert meta.year == year
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"Daft Punk - Random Access Memories 2013 FLAC",
|
||||
"[Audio-4U] 茶太 — Chata 1.0 (flac)",
|
||||
],
|
||||
)
|
||||
def test_music_video_scene_pattern_requires_combined_video_signature(raw: str):
|
||||
"""普通音频标题只有格式或版本数字时,不得误入音乐视频场景模式。"""
|
||||
context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None)
|
||||
matched = MusicNameRegistry.match_pattern(context)
|
||||
|
||||
assert matched.pattern_name != "music_video_scene"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "artists", "title", "year", "audio_format"),
|
||||
[
|
||||
(
|
||||
"The Bug Club - On the Intricate Inner Workings of the System "
|
||||
"2025-FLAC 分軌-Redacted",
|
||||
["The Bug Club"],
|
||||
"On the Intricate Inner Workings of the System",
|
||||
2025,
|
||||
"FLAC",
|
||||
),
|
||||
(
|
||||
"李宇春 - 皇后与梦想 - 2006-FLAC分轨-OpenCD-九月萌",
|
||||
["李宇春"],
|
||||
"皇后与梦想",
|
||||
2006,
|
||||
"FLAC",
|
||||
),
|
||||
("西班牙幻想曲SACD", [], "西班牙幻想曲", None, "DSD"),
|
||||
("無字天碟 Indefinable(WAV+CUE原抓)", [], "無字天碟 Indefinable", None, "WAV"),
|
||||
("刘星-无所事事(WAV+CUE原抓)", ["刘星"], "无所事事", None, "WAV"),
|
||||
("喜多郎-古事记SACD", ["喜多郎"], "古事记", None, "DSD"),
|
||||
("巫启贤太傻(黄金版)WAV分轨原抓", [], "巫启贤太傻(黄金版)", None, "WAV"),
|
||||
(
|
||||
"王若琳 - The Adult Storybook 2009 SACD",
|
||||
["王若琳"],
|
||||
"The Adult Storybook",
|
||||
2009,
|
||||
"DSD",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_common_audio_release_noise_is_removed(
|
||||
raw: str,
|
||||
artists: list[str],
|
||||
title: str,
|
||||
year: Optional[int],
|
||||
audio_format: str,
|
||||
):
|
||||
"""真实音频发布尾链只提供格式和年份,不应污染艺术家或标题。"""
|
||||
meta = parse_title(raw)
|
||||
|
||||
assert meta.artists == artists
|
||||
assert meta.title == title
|
||||
assert meta.year == year
|
||||
assert meta.audio_format == audio_format
|
||||
|
||||
|
||||
def test_strip_track_prefix_handles_dot_separator():
|
||||
"""曲序前缀 01. 应剥离并返回曲名。"""
|
||||
track, disc, title = MetaMusic.split_track_prefix("01.晴天")
|
||||
@@ -135,6 +419,48 @@ def test_apply_path_context_keeps_existing_tags(tmp_path):
|
||||
assert meta.year == 2004
|
||||
|
||||
|
||||
def test_apply_path_context_uses_full_dynamic_filename_parser(tmp_path):
|
||||
"""无标签文件名应进入完整动态模式,清理音乐视频场景规格。"""
|
||||
audio_file = tmp_path / (
|
||||
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
|
||||
)
|
||||
audio_file.write_bytes(b"fake-flac")
|
||||
meta = MetaMusic(
|
||||
org_string=audio_file.name,
|
||||
title=audio_file.stem,
|
||||
audio_format="FLAC",
|
||||
)
|
||||
|
||||
meta.apply_path_context(audio_file)
|
||||
|
||||
assert meta.artists == ["S.H.E"]
|
||||
assert meta.title == "S.H.E十七音乐会"
|
||||
assert meta.year == 2018
|
||||
assert meta.audio_format == "FLAC"
|
||||
|
||||
|
||||
def test_apply_path_context_only_fills_missing_tag_fields(tmp_path):
|
||||
"""部分标签存在时保留标签值,只从完整文件名解析补充空字段。"""
|
||||
audio_file = tmp_path / "周杰伦 - 文件名曲目 2018 FLAC.flac"
|
||||
audio_file.write_bytes(b"fake-flac")
|
||||
meta = MetaMusic(
|
||||
org_string=audio_file.name,
|
||||
title="标签曲名",
|
||||
artists=[],
|
||||
album="标签专辑",
|
||||
year=2020,
|
||||
)
|
||||
|
||||
meta.apply_path_context(audio_file)
|
||||
|
||||
assert meta.title == "标签曲名"
|
||||
assert meta.artists == ["周杰伦"]
|
||||
assert meta.album == "标签专辑"
|
||||
assert meta.year == 2020
|
||||
assert meta.audio_format == "FLAC"
|
||||
assert meta.audio_lossless is True
|
||||
|
||||
|
||||
def test_apply_title_splits_artist_and_track():
|
||||
"""标准「歌手 - 曲名」种子标题应拆分艺术家与曲名。"""
|
||||
meta = parse_title("周杰伦 - 晴天")
|
||||
@@ -180,6 +506,10 @@ def test_apply_title_splits_latin_hyphen_artist_album():
|
||||
assert meta.year == 1971
|
||||
# 左侧单词(Heize-Undo)与右侧发布组标签不触发拆分
|
||||
assert parse_title("Heize-Undo.2022.FLAC").artists == []
|
||||
# 全大写复合词属于艺术家名本身,不能从 KUNG-FU 中间拆开。
|
||||
compound = parse_title("ASIAN KUNG-FU GENERATION Discography (2003-2026) [FLAC]")
|
||||
assert compound.artists == []
|
||||
assert compound.title == "ASIAN KUNG-FU GENERATION Discography (2003-2026)"
|
||||
|
||||
|
||||
def test_apply_title_splits_various_artists_prefix():
|
||||
@@ -348,6 +678,19 @@ def test_apply_title_album_marker():
|
||||
assert meta.year == 2006
|
||||
|
||||
|
||||
def test_apply_title_bilingual_album_marker_prefix():
|
||||
"""双语原声命名应保留英文艺术家/标题,不把整段前缀当成艺术家。"""
|
||||
meta = parse_title(
|
||||
"Max Richter - Ad Astra Original Motion Picture Soundtrack "
|
||||
"马克斯·里希特 - 《星际探索》电影原声带 2019 FLAC-SeedPool"
|
||||
)
|
||||
|
||||
assert meta.artists == ["Max Richter"]
|
||||
assert meta.title == "Ad Astra Original Motion Picture Soundtrack"
|
||||
assert meta.album == "星际探索"
|
||||
assert meta.year == 2019
|
||||
|
||||
|
||||
def test_apply_title_strips_cue_and_plus():
|
||||
"""APE+CUE 类格式联合写法应剔除,残留加号不阻断标题提取。"""
|
||||
meta = parse_title("世界著名古典大师名版收藏(15)RCA发烧古典系列-2007-FLAC-APE+CUE")
|
||||
@@ -365,6 +708,17 @@ def test_apply_title_cjk_hyphen_artist_suffix():
|
||||
assert meta.title == "因为有你"
|
||||
|
||||
|
||||
def test_apply_title_does_not_split_ascii_hyphen_inside_cjk_title():
|
||||
"""CJK 标题中的 A-on 等拉丁复合词不能生成虚假的艺术家。"""
|
||||
meta = parse_title(
|
||||
"[250226] 重戦機エルガイム A-on STORE 連動特典"
|
||||
"「重戦機エルガイム(カセット版復刻CD)」 [FLAC+CUE]"
|
||||
)
|
||||
|
||||
assert meta.artists == []
|
||||
assert meta.title.startswith("重戦機エルガイム A-on STORE")
|
||||
|
||||
|
||||
def test_apply_title_double_em_dash_split():
|
||||
"""双破折号分隔的「主题——歌手」写法应拆分艺术家。"""
|
||||
meta = parse_title("为你盛开——许巍 无尽光芒巡回演唱会 2025")
|
||||
@@ -455,3 +809,11 @@ def test_apply_title_collection_with_space_sample_rate():
|
||||
assert meta.title is None
|
||||
assert meta.year == 2022
|
||||
assert meta.audio_format == "FLAC"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", ["孙楠 - 楠得精选 2001", "[合集] 缘之空音乐合集 [FLAC]"])
|
||||
def test_apply_title_keeps_collection_words_inside_work_name(title: str):
|
||||
"""合集/精选嵌在作品名中时是有效文字,只清理独立发行标签。"""
|
||||
meta = parse_title(title)
|
||||
|
||||
assert "精选" in (meta.title or "") or "合集" in (meta.title or "")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
@@ -6,6 +6,7 @@ from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
from app.modules.musicbrainz import MusicBrainzModule
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def test_parse_query_supports_artist_title_format():
|
||||
@@ -321,7 +322,167 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
|
||||
|
||||
assert recognized_meta is meta
|
||||
assert recognized_info is info
|
||||
recognize.assert_awaited_once_with(meta=meta, source="musicbrainz")
|
||||
recognize.assert_awaited_once_with(meta=meta, source=None)
|
||||
|
||||
|
||||
def test_recognize_best_compares_all_sources_and_prefers_stronger_evidence(monkeypatch):
|
||||
"""自动识别应查询全部来源,并让专辑、时长和曲序证据更完整的候选胜出。"""
|
||||
chain = MusicChain()
|
||||
meta = MetaMusic(
|
||||
title="Yellow",
|
||||
artists=["Coldplay"],
|
||||
album="Parachutes",
|
||||
duration=269,
|
||||
track_number=5,
|
||||
)
|
||||
candidates = {
|
||||
"musicbrainz": MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="mb-1",
|
||||
title="Yellow",
|
||||
artists=["Coldplay"],
|
||||
album="Greatest Hits",
|
||||
duration=240,
|
||||
track_number=1,
|
||||
),
|
||||
"theaudiodb": MusicInfo(
|
||||
source="theaudiodb",
|
||||
media_id="adb-1",
|
||||
title="Yellow",
|
||||
artists=["Coldplay"],
|
||||
album="Parachutes",
|
||||
duration=269,
|
||||
track_number=5,
|
||||
),
|
||||
"doubanmusic": MusicInfo(
|
||||
source="doubanmusic",
|
||||
media_id="db-1:5",
|
||||
title="Yellow",
|
||||
artists=["Coldplay"],
|
||||
album="Parachutes",
|
||||
),
|
||||
}
|
||||
requested = []
|
||||
|
||||
def fake_recognize_source(_meta, source, _cache):
|
||||
"""按来源返回候选并记录实际查询顺序。"""
|
||||
requested.append(source)
|
||||
return candidates[source]
|
||||
|
||||
monkeypatch.setattr(chain, "_recognize_from_source", fake_recognize_source)
|
||||
|
||||
result = chain.recognize_best(meta)
|
||||
|
||||
assert requested == ["musicbrainz", "theaudiodb", "doubanmusic"]
|
||||
assert result is candidates["theaudiodb"]
|
||||
|
||||
|
||||
def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
|
||||
"""单源识别只允许调用声明该音乐来源的模块,忽略同接口影视模块。"""
|
||||
chain = MusicChain()
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
expected = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
)
|
||||
video_module = Mock(spec=["recognize_media"])
|
||||
music_module = Mock(spec=["get_music_source", "recognize_media"])
|
||||
music_module.get_music_source.return_value = "musicbrainz"
|
||||
music_module.recognize_media.return_value = expected
|
||||
monkeypatch.setattr(
|
||||
chain.modulemanager,
|
||||
"get_running_modules",
|
||||
Mock(return_value=[video_module, music_module]),
|
||||
)
|
||||
|
||||
result = chain.recognize_from_source(
|
||||
source="musicbrainz",
|
||||
meta=meta,
|
||||
cache=True,
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
video_module.recognize_media.assert_not_called()
|
||||
music_module.recognize_media.assert_called_once_with(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source="musicbrainz",
|
||||
mediaid=None,
|
||||
cache=True,
|
||||
)
|
||||
|
||||
|
||||
def test_recognize_best_uses_source_order_only_for_equal_scores(monkeypatch):
|
||||
"""候选证据完全相同时应按 MusicBrainz、TheAudioDB、豆瓣音乐顺序稳定选择。"""
|
||||
chain = MusicChain()
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
candidates = {
|
||||
source: MusicInfo(
|
||||
source=source,
|
||||
media_id=f"{source}-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
)
|
||||
for source in ("musicbrainz", "theaudiodb", "doubanmusic")
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_recognize_from_source",
|
||||
lambda _meta, source, _cache: candidates[source],
|
||||
)
|
||||
|
||||
assert chain.recognize_best(meta) is candidates["musicbrainz"]
|
||||
|
||||
|
||||
def test_async_recognize_best_queries_sources_concurrently(monkeypatch):
|
||||
"""异步自动识别应并发查询各来源,而不是串行等待三个远端请求。"""
|
||||
import asyncio
|
||||
|
||||
chain = MusicChain()
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
async def fake_async_recognize_source(_meta, source, _cache):
|
||||
"""记录同时执行的来源数并返回同分候选。"""
|
||||
nonlocal active, max_active
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
await asyncio.sleep(0.01)
|
||||
active -= 1
|
||||
return MusicInfo(
|
||||
source=source,
|
||||
media_id=f"{source}-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_async_recognize_from_source",
|
||||
fake_async_recognize_source,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
chain.async_recognize_best(MetaMusic(title="晴天", artists=["周杰伦"]))
|
||||
)
|
||||
|
||||
assert max_active == 3
|
||||
assert result and result.source == "musicbrainz"
|
||||
|
||||
|
||||
def test_recognize_candidate_rejects_wrong_artist_even_when_title_matches():
|
||||
"""已知艺术家时,同名异人的候选不能仅凭曲名命中。"""
|
||||
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
||||
candidate = MusicInfo(
|
||||
source="theaudiodb",
|
||||
media_id="wrong-1",
|
||||
title="晴天",
|
||||
artists=["其他歌手"],
|
||||
)
|
||||
|
||||
assert MusicChain._recognize_candidate_score(meta, candidate) is None
|
||||
|
||||
|
||||
def test_async_chart_forwards_album_entity(monkeypatch):
|
||||
|
||||
@@ -80,6 +80,33 @@ def test_media_search_routes_music_queries_with_query_kwarg():
|
||||
media_chain.return_value.async_search.assert_not_called()
|
||||
|
||||
|
||||
def test_media_search_forwards_explicit_music_source():
|
||||
"""统一音乐搜索应把显式选择的可扩展音乐源转发给 MusicChain。"""
|
||||
chain = Mock()
|
||||
chain.async_search = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch("app.api.endpoints.media.MusicChain", return_value=chain),
|
||||
patch.object(media_endpoints, "MediaChain"),
|
||||
):
|
||||
result = asyncio.run(
|
||||
media_endpoints.search(
|
||||
title="Coldplay",
|
||||
type="music",
|
||||
count=20,
|
||||
source="theaudiodb",
|
||||
_=Mock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
chain.async_search.assert_awaited_once_with(
|
||||
query="Coldplay",
|
||||
limit=20,
|
||||
source="theaudiodb",
|
||||
)
|
||||
|
||||
|
||||
def test_recognize_music_returns_detail():
|
||||
"""音乐识别接口应按来源和 ID 经统一识别入口返回详情。"""
|
||||
from app.chain.media import MediaChain
|
||||
|
||||
278
tests/test_music_metadata_sources.py
Normal file
278
tests/test_music_metadata_sources.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.modules.theaudiodb import TheAudioDbModule
|
||||
from app.schemas.types import MediaRecognizeType, MediaType
|
||||
|
||||
|
||||
def test_theaudiodb_module_maps_track_and_album(monkeypatch):
|
||||
"""TheAudioDB 原生响应应保留来源 ID,并换算毫秒时长。"""
|
||||
module = TheAudioDbModule()
|
||||
request = Mock(side_effect=[
|
||||
{
|
||||
"track": [{
|
||||
"idTrack": "32793500",
|
||||
"strTrack": "Yellow",
|
||||
"strArtist": "Coldplay",
|
||||
"idArtist": "111239",
|
||||
"idAlbum": "2109619",
|
||||
"strAlbum": "Parachutes",
|
||||
"intTrackNumber": "5",
|
||||
"intDuration": "269000",
|
||||
"strTrackThumb": "https://www.theaudiodb.com/images/track.jpg",
|
||||
}]
|
||||
},
|
||||
{"album": []},
|
||||
{"artists": []},
|
||||
])
|
||||
monkeypatch.setattr(module, "_request_json", request)
|
||||
|
||||
results = module.search_music(
|
||||
MetaMusic(title="Yellow", artists=["Coldplay"]),
|
||||
source="theaudiodb",
|
||||
)
|
||||
|
||||
assert results and len(results) == 1
|
||||
assert results[0].source == "theaudiodb"
|
||||
assert results[0].media_id == "32793500"
|
||||
assert results[0].album_id == "2109619"
|
||||
assert results[0].duration == 269
|
||||
assert module.get_subtype() == MediaRecognizeType.TheAudioDB
|
||||
|
||||
|
||||
def test_theaudiodb_module_ignores_other_sources(monkeypatch):
|
||||
"""显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。"""
|
||||
module = TheAudioDbModule()
|
||||
request = Mock()
|
||||
monkeypatch.setattr(module, "_request_json", request)
|
||||
|
||||
searched = module.search_music(MetaMusic(title="Yellow"), source="musicbrainz")
|
||||
recognized = module.recognize_media(
|
||||
meta=MetaMusic(title="Yellow"),
|
||||
source="doubanmusic",
|
||||
)
|
||||
|
||||
assert searched is None
|
||||
assert recognized is None
|
||||
request.assert_not_called()
|
||||
|
||||
|
||||
def test_douban_music_search_and_album_mapping(monkeypatch):
|
||||
"""豆瓣模块应把音乐条目映射为专辑,并生成可用于曲目识别的复合 ID。"""
|
||||
module = DoubanModule()
|
||||
module.doubanapi = Mock()
|
||||
module.doubanapi.music_search.return_value = {
|
||||
"items": [{
|
||||
"target_type": "music",
|
||||
"target": {
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"artists": [{"name": "周杰伦"}],
|
||||
"year": "2001",
|
||||
},
|
||||
}]
|
||||
}
|
||||
module.doubanapi.music_detail.return_value = {
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"singer": [{"id": "1050015", "name": "周杰伦"}],
|
||||
"pubdate": ["2001-09-14"],
|
||||
"media": ["CD"],
|
||||
"publisher": ["阿尔发音乐"],
|
||||
"songs": [
|
||||
{
|
||||
"title": "爱在西元前",
|
||||
"track_number": 1,
|
||||
"artist_names": ["周杰伦"],
|
||||
"duration": 221,
|
||||
},
|
||||
{
|
||||
"title": "爸我回来了",
|
||||
"track_number": 2,
|
||||
"artist_names": ["周杰伦"],
|
||||
"cover_url": "https://img.example/track.jpg",
|
||||
},
|
||||
],
|
||||
"rating": {"average": "9.4", "numRaters": "12345"},
|
||||
}
|
||||
|
||||
results = module.search_music(
|
||||
MetaMusic(title="范特西", artists=["周杰伦"]),
|
||||
source="doubanmusic",
|
||||
)
|
||||
album = module.music_album("doubanmusic", "1401853")
|
||||
|
||||
assert results and results[0].source == "doubanmusic"
|
||||
assert results[0].music_type == MUSIC_ENTITY_ALBUM
|
||||
assert album and album.source == "doubanmusic"
|
||||
assert album.year == 2001
|
||||
assert album.artists == ["周杰伦"]
|
||||
assert album.album_type == "CD"
|
||||
assert [track.media_id for track in album.tracks] == ["1401853:1", "1401853:2"]
|
||||
assert album.tracks[0].title == "爱在西元前"
|
||||
assert album.tracks[0].duration == 221
|
||||
assert album.tracks[1].cover_url == "https://img.example/track.jpg"
|
||||
|
||||
|
||||
def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
|
||||
"""自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。"""
|
||||
module = DoubanModule()
|
||||
module.doubanapi = Mock()
|
||||
module.doubanapi.music_search.return_value = {
|
||||
"items": [{
|
||||
"target_type": "music",
|
||||
"target": {
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"artists": [{"name": "周杰伦"}],
|
||||
},
|
||||
}]
|
||||
}
|
||||
module.doubanapi.music_detail.return_value = {
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"singer": [{"name": "周杰伦"}],
|
||||
"songs": [
|
||||
{"title": "爱在西元前", "track_number": 1},
|
||||
{"title": "爸我回来了", "track_number": 2},
|
||||
],
|
||||
}
|
||||
|
||||
result = module.recognize_media(
|
||||
meta=MetaMusic(
|
||||
title="爸我回来了",
|
||||
artists=["周杰伦"],
|
||||
album="范特西",
|
||||
track_number=2,
|
||||
),
|
||||
source="doubanmusic",
|
||||
)
|
||||
|
||||
assert result and result.music_type == "recording"
|
||||
assert result.media_id == "1401853:2"
|
||||
assert result.album == "范特西"
|
||||
|
||||
|
||||
def test_douban_music_mapping_keeps_legacy_attrs_tracks():
|
||||
"""豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。"""
|
||||
album = DoubanModule._douban_music_to_album({
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"attrs": {
|
||||
"singer": ["周杰伦"],
|
||||
"tracks": ["01. 爱在西元前", "02. 爸我回来了"],
|
||||
},
|
||||
})
|
||||
|
||||
assert album and album.artists == ["周杰伦"]
|
||||
assert [track.title for track in album.tracks] == ["爱在西元前", "爸我回来了"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_douban_music_async_recognize_maps_real_songs(monkeypatch):
|
||||
"""异步豆瓣自动识别应从真实 songs 字段返回具体音轨。"""
|
||||
module = DoubanModule()
|
||||
module.doubanapi = Mock()
|
||||
module.doubanapi.async_music_search = AsyncMock(return_value={
|
||||
"items": [{
|
||||
"target_type": "music",
|
||||
"target": {"id": "1401853", "title": "范特西"},
|
||||
}],
|
||||
})
|
||||
module.doubanapi.async_music_detail = AsyncMock(return_value={
|
||||
"id": "1401853",
|
||||
"title": "范特西",
|
||||
"singer": [{"name": "周杰伦"}],
|
||||
"songs": [
|
||||
{"title": "爱在西元前", "track_number": 1},
|
||||
{"title": "爸我回来了", "track_number": 2},
|
||||
],
|
||||
})
|
||||
|
||||
result = await module.async_recognize_media(
|
||||
meta=MetaMusic(
|
||||
title="爱在西元前",
|
||||
artists=["周杰伦"],
|
||||
album="范特西",
|
||||
),
|
||||
source="doubanmusic",
|
||||
)
|
||||
|
||||
assert result and result.media_id == "1401853:1"
|
||||
assert result.title == "爱在西元前"
|
||||
assert result.artists == ["周杰伦"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_douban_recognize_media_routes_only_douban_music(monkeypatch):
|
||||
"""豆瓣音乐使用独立 source,不能与影视豆瓣入口或其它音乐源串线。"""
|
||||
module = DoubanModule()
|
||||
expected = MusicInfo(source="doubanmusic", media_id="1401853", title="范特西")
|
||||
recognize_music = Mock(return_value=expected)
|
||||
recognize_video = Mock()
|
||||
async_recognize_music = AsyncMock(return_value=expected)
|
||||
monkeypatch.setattr(module, "_recognize_music_media", recognize_music)
|
||||
monkeypatch.setattr(module, "_recognize_media_core", recognize_video)
|
||||
monkeypatch.setattr(module, "_async_recognize_music_media", async_recognize_music)
|
||||
|
||||
recognized = module.recognize_media(
|
||||
meta=MetaMusic(title="范特西"),
|
||||
source="doubanmusic",
|
||||
mediaid="1401853",
|
||||
)
|
||||
ignored = module.recognize_media(
|
||||
meta=MetaMusic(title="范特西"),
|
||||
source="theaudiodb",
|
||||
)
|
||||
async_recognized = await module.async_recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="doubanmusic",
|
||||
mediaid="1401853",
|
||||
)
|
||||
|
||||
assert recognized is expected
|
||||
assert ignored is None
|
||||
assert async_recognized is expected
|
||||
recognize_video.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch):
|
||||
"""音乐搜索默认 MusicBrainz,手动选择时原样转发其它音乐源。"""
|
||||
chain = MusicChain()
|
||||
run_module = Mock(return_value=[])
|
||||
async_run_module = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(chain, "run_module", run_module)
|
||||
monkeypatch.setattr(chain, "async_run_module", async_run_module)
|
||||
|
||||
chain.search("Yellow")
|
||||
await chain.async_search("范特西", source="doubanmusic")
|
||||
|
||||
assert run_module.call_args.kwargs["source"] == "musicbrainz"
|
||||
assert async_run_module.await_args.kwargs["source"] == "doubanmusic"
|
||||
|
||||
|
||||
def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch):
|
||||
"""无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。"""
|
||||
path = tmp_path / "Yellow.flac"
|
||||
path.write_bytes(b"audio")
|
||||
expected = MusicInfo(source="theaudiodb", media_id="32793500", title="Yellow")
|
||||
recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected))
|
||||
monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize)
|
||||
|
||||
result = MediaChain._resolve_music_scrape_info(
|
||||
path,
|
||||
mediainfo=None,
|
||||
source="theaudiodb",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
recognize.assert_called_once_with(path, source="theaudiodb")
|
||||
@@ -292,7 +292,7 @@ def test_chain_recognize_media_music_plugin_supplement():
|
||||
)
|
||||
event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()})
|
||||
|
||||
with patch.object(chain, "run_module", return_value=fallback), \
|
||||
with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), \
|
||||
patch.object(chain.eventmanager, "check", return_value=True), \
|
||||
patch.object(chain.eventmanager, "send_event", return_value=event), \
|
||||
patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock:
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.context import MusicInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.modules.anilist import AniListModule
|
||||
from app.modules.bangumi import BangumiModule
|
||||
from app.modules.musicbrainz import MusicBrainzModule
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -35,12 +39,15 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch):
|
||||
chain = MediaChain()
|
||||
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected))
|
||||
|
||||
result = chain.recognize_by_meta(meta, source="musicbrainz")
|
||||
result = chain.recognize_by_meta(
|
||||
meta, source="musicbrainz", mtype=MediaType.MUSIC
|
||||
)
|
||||
|
||||
# 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数
|
||||
chain.recognize_media.assert_called_once()
|
||||
call_kwargs = chain.recognize_media.call_args.kwargs
|
||||
assert call_kwargs["meta"] is meta
|
||||
assert call_kwargs["mtype"] == MediaType.MUSIC
|
||||
assert call_kwargs["source"] == "musicbrainz"
|
||||
assert result is expected
|
||||
|
||||
@@ -53,12 +60,15 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa
|
||||
monkeypatch.setattr(chain, "async_recognize_media", AsyncMock(return_value=expected))
|
||||
|
||||
async def runner():
|
||||
return await chain.async_recognize_by_meta(meta, source="musicbrainz")
|
||||
return await chain.async_recognize_by_meta(
|
||||
meta, source="musicbrainz", mtype=MediaType.MUSIC
|
||||
)
|
||||
|
||||
result = asyncio.run(runner())
|
||||
chain.async_recognize_media.assert_awaited_once()
|
||||
call_kwargs = chain.async_recognize_media.await_args.kwargs
|
||||
assert call_kwargs["meta"] is meta
|
||||
assert call_kwargs["mtype"] == MediaType.MUSIC
|
||||
assert call_kwargs["source"] == "musicbrainz"
|
||||
assert result is expected
|
||||
|
||||
@@ -119,7 +129,7 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa
|
||||
|
||||
assert recognized_meta is meta
|
||||
assert recognized_info is info
|
||||
recognize.assert_awaited_once_with(meta=meta, source="musicbrainz")
|
||||
recognize.assert_awaited_once_with(meta=meta, source=None)
|
||||
|
||||
|
||||
def test_musicbrainz_module_recognize_media_ignores_non_music():
|
||||
@@ -130,6 +140,133 @@ def test_musicbrainz_module_recognize_media_ignores_non_music():
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatch):
|
||||
"""显式音乐类型和来源应只调用对应音乐模块,不遍历通用影视模块。"""
|
||||
expected = _music_info()
|
||||
chain = ChainBase()
|
||||
recognize_source = Mock(return_value=expected)
|
||||
generic_dispatch = Mock()
|
||||
monkeypatch.setattr(MusicChain, "recognize_from_source", recognize_source)
|
||||
monkeypatch.setattr(chain, "run_module", generic_dispatch)
|
||||
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
|
||||
|
||||
with patch(
|
||||
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
|
||||
):
|
||||
result = chain.recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="musicbrainz",
|
||||
mediaid="recording-1",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
recognize_source.assert_called_once_with(
|
||||
source="musicbrainz",
|
||||
meta=None,
|
||||
mediaid="recording-1",
|
||||
cache=True,
|
||||
)
|
||||
generic_dispatch.assert_not_called()
|
||||
|
||||
|
||||
def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatch):
|
||||
"""音乐状态即使携带错误影视来源,也不得调用 TMDB 等通用识别模块。"""
|
||||
chain = ChainBase()
|
||||
generic_dispatch = Mock()
|
||||
async_generic_dispatch = AsyncMock()
|
||||
monkeypatch.setattr(chain, "run_module", generic_dispatch)
|
||||
monkeypatch.setattr(chain, "async_run_module", async_generic_dispatch)
|
||||
monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False))
|
||||
|
||||
sync_result = chain.recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="themoviedb",
|
||||
mediaid="123",
|
||||
)
|
||||
async_result = asyncio.run(chain.async_recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
source="themoviedb",
|
||||
mediaid="123",
|
||||
))
|
||||
|
||||
assert sync_result is None
|
||||
assert async_result is None
|
||||
generic_dispatch.assert_not_called()
|
||||
async_generic_dispatch.assert_not_awaited()
|
||||
|
||||
|
||||
def test_themoviedb_module_recognize_media_ignores_music(monkeypatch):
|
||||
"""音乐模块未响应时 TMDB 的同步和异步入口均不得接管音乐请求。"""
|
||||
module = TheMovieDbModule()
|
||||
tmdb = Mock()
|
||||
monkeypatch.setattr(module, "tmdb", tmdb)
|
||||
|
||||
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"))
|
||||
by_type = module.recognize_media(mtype=MediaType.MUSIC, tmdbid=123)
|
||||
async_result = asyncio.run(
|
||||
module.async_recognize_media(meta=MetaMusic(title="晴天"))
|
||||
)
|
||||
|
||||
assert by_meta is None
|
||||
assert by_type is None
|
||||
assert async_result is None
|
||||
assert tmdb.mock_calls == []
|
||||
|
||||
|
||||
def test_bangumi_module_recognize_media_ignores_music(monkeypatch):
|
||||
"""Bangumi 的同步和异步入口不得把音乐请求识别为动画影视。"""
|
||||
module = BangumiModule()
|
||||
api = Mock()
|
||||
monkeypatch.setattr(module, "bangumiapi", api)
|
||||
|
||||
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="bangumi")
|
||||
by_type = module.recognize_media(mtype=MediaType.MUSIC, bangumiid=123)
|
||||
async_result = asyncio.run(
|
||||
module.async_recognize_media(meta=MetaMusic(title="晴天"), source="bangumi")
|
||||
)
|
||||
|
||||
assert by_meta is None
|
||||
assert by_type is None
|
||||
assert async_result is None
|
||||
assert api.mock_calls == []
|
||||
|
||||
|
||||
def test_anilist_module_recognize_media_ignores_music(monkeypatch):
|
||||
"""AniList 的同步和异步入口不得把音乐请求识别为动画影视。"""
|
||||
module = AniListModule()
|
||||
api = Mock()
|
||||
monkeypatch.setattr(module, "anilist_api", api)
|
||||
|
||||
by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="anilist")
|
||||
by_type = module.recognize_media(mtype=MediaType.MUSIC, anilistid=123)
|
||||
async_result = asyncio.run(
|
||||
module.async_recognize_media(meta=MetaMusic(title="晴天"), source="anilist")
|
||||
)
|
||||
|
||||
assert by_meta is None
|
||||
assert by_type is None
|
||||
assert async_result is None
|
||||
assert api.mock_calls == []
|
||||
|
||||
|
||||
def test_chain_obtain_images_skips_music_modules(monkeypatch):
|
||||
"""音乐封面来自音乐元数据链,同步和异步补图入口均不得调用影视模块。"""
|
||||
chain = MediaChain()
|
||||
run_module = Mock()
|
||||
async_run_module = AsyncMock()
|
||||
monkeypatch.setattr(chain, "run_module", run_module)
|
||||
monkeypatch.setattr(chain, "async_run_module", async_run_module)
|
||||
music = _music_info()
|
||||
|
||||
result = chain.obtain_images(music)
|
||||
async_result = asyncio.run(chain.async_obtain_images(music))
|
||||
|
||||
assert result is music
|
||||
assert async_result is music
|
||||
run_module.assert_not_called()
|
||||
async_run_module.assert_not_awaited()
|
||||
|
||||
|
||||
def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(monkeypatch):
|
||||
"""meta 携带 source+media_id 时应走详情分支,不再触发搜索。"""
|
||||
module = MusicBrainzModule()
|
||||
@@ -202,12 +339,11 @@ def test_musicbrainz_module_async_recognize_media(monkeypatch):
|
||||
|
||||
|
||||
def test_chain_recognize_media_returns_musicinfo_and_reports_share():
|
||||
"""ChainBase.recognize_media 收到 MusicInfo 结果应与影视统一上报共享识别。"""
|
||||
"""自动多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
|
||||
expected = _music_info()
|
||||
chain = MediaChain()
|
||||
chain.run_module = Mock(return_value=expected)
|
||||
with patch(
|
||||
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
|
||||
with patch.object(MusicChain, "recognize_best", return_value=expected), patch(
|
||||
"app.helper.server.MoviePilotServerHelper.report_recognize_share"
|
||||
) as report_mock:
|
||||
result = chain.recognize_media(meta=MetaMusic(title="晴天"))
|
||||
|
||||
@@ -217,11 +353,14 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share():
|
||||
|
||||
|
||||
def test_chain_async_recognize_media_returns_musicinfo_and_reports_share():
|
||||
"""异步 ChainBase 收到 MusicInfo 结果应与影视统一上报共享识别。"""
|
||||
"""异步多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
|
||||
expected = _music_info()
|
||||
chain = MediaChain()
|
||||
chain.async_run_module = AsyncMock(return_value=expected)
|
||||
with patch(
|
||||
with patch.object(
|
||||
MusicChain,
|
||||
"async_recognize_best",
|
||||
AsyncMock(return_value=expected),
|
||||
), patch(
|
||||
"app.helper.server.MoviePilotServerHelper.async_report_recognize_share",
|
||||
AsyncMock(),
|
||||
) as report_mock:
|
||||
|
||||
@@ -132,7 +132,7 @@ def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert message == "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
assert message == "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
|
||||
|
||||
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.transfer import JobManager, TransferChain
|
||||
from app.core.config import settings
|
||||
@@ -10,7 +12,8 @@ from app.core.meta import MetaMusic
|
||||
from app.core.context import MusicInfo
|
||||
from app.helper.message import TemplateHelper
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.transfer import TransferTask
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.transfer import TransferInfo, TransferTask, TransferTorrent
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -277,6 +280,7 @@ def test_job_manager_serializes_music_queue_models():
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=info,
|
||||
mtype=MediaType.MUSIC,
|
||||
)
|
||||
manager = JobManager()
|
||||
|
||||
@@ -285,3 +289,154 @@ def test_job_manager_serializes_music_queue_models():
|
||||
assert job.media.type == "音乐"
|
||||
assert job.media.album == "Random Access Memories"
|
||||
assert job.tasks[0].meta.type == "音乐"
|
||||
assert task.mtype == MediaType.MUSIC
|
||||
|
||||
|
||||
def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
|
||||
"""无下载身份的音频应先走音乐识别,远端失败后再使用本地标签兜底。"""
|
||||
audio_path = tmp_path / "周杰伦 - 晴天.flac"
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
source_item = FileItem(
|
||||
storage="local",
|
||||
path=audio_path.as_posix(),
|
||||
name=audio_path.name,
|
||||
basename=audio_path.stem,
|
||||
type="file",
|
||||
extension="flac",
|
||||
size=audio_path.stat().st_size,
|
||||
)
|
||||
target_item = FileItem(
|
||||
storage="local",
|
||||
path=(tmp_path / "library" / audio_path.name).as_posix(),
|
||||
name=audio_path.name,
|
||||
basename=audio_path.stem,
|
||||
type="file",
|
||||
extension="flac",
|
||||
)
|
||||
recognized = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
year=2003,
|
||||
)
|
||||
recognize = Mock(return_value=recognized)
|
||||
chain = TransferChain()
|
||||
monkeypatch.setattr(MediaChain, "recognize_by_meta", recognize)
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_TransferChain__get_trans_fileitems",
|
||||
Mock(side_effect=lambda *args, **kwargs: [(source_item, False)]),
|
||||
)
|
||||
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"transfer",
|
||||
Mock(
|
||||
return_value=TransferInfo(
|
||||
success=True,
|
||||
fileitem=source_item,
|
||||
target_item=target_item,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
state, preview = chain.do_transfer(
|
||||
fileitem=source_item,
|
||||
target_directory=TransferDirectoryConf(
|
||||
library_path=(tmp_path / "library").as_posix(),
|
||||
library_storage="local",
|
||||
),
|
||||
mtype=MediaType.MUSIC,
|
||||
force=True,
|
||||
preview=True,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
recognize.assert_called_once()
|
||||
assert isinstance(recognize.call_args.args[0], MetaMusic)
|
||||
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
|
||||
assert preview["items"][0]["type"] == MediaType.MUSIC.value
|
||||
|
||||
recognize.reset_mock()
|
||||
recognize.return_value = None
|
||||
state, preview = chain.do_transfer(
|
||||
fileitem=source_item,
|
||||
target_directory=TransferDirectoryConf(
|
||||
library_path=(tmp_path / "library").as_posix(),
|
||||
library_storage="local",
|
||||
),
|
||||
mtype=MediaType.MUSIC,
|
||||
force=True,
|
||||
preview=True,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
recognize.assert_called_once()
|
||||
assert isinstance(recognize.call_args.args[0], MetaMusic)
|
||||
assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC
|
||||
assert preview["items"][0]["type"] == MediaType.MUSIC.value
|
||||
|
||||
|
||||
def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
|
||||
"""下载器自动整理应把下载历史中的音乐类型传入文件规划,且不调用影视补图模块。"""
|
||||
audio_path = tmp_path / "晴天.flac"
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
recognized = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
)
|
||||
history = SimpleNamespace(
|
||||
type=MediaType.MUSIC.value,
|
||||
tmdbid=None,
|
||||
doubanid=None,
|
||||
bangumiid=None,
|
||||
anilistid=None,
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
episode_group=None,
|
||||
media_category=None,
|
||||
)
|
||||
chain = TransferChain()
|
||||
run_module = Mock()
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.DirectoryHelper.get_download_dirs",
|
||||
lambda _: [
|
||||
SimpleNamespace(
|
||||
monitor_type="downloader",
|
||||
storage="local",
|
||||
download_path=tmp_path.as_posix(),
|
||||
)
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.DownloadHistoryOper.get_by_hash",
|
||||
lambda _, download_hash: history,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"list_torrents",
|
||||
Mock(
|
||||
return_value=[
|
||||
TransferTorrent(
|
||||
downloader="qbittorrent",
|
||||
hash="hash-1",
|
||||
path=audio_path,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=recognized))
|
||||
monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, "")))
|
||||
monkeypatch.setattr(chain, "run_module", run_module)
|
||||
|
||||
state = chain.process()
|
||||
|
||||
assert state is True
|
||||
assert chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC
|
||||
assert chain.do_transfer.call_args.kwargs["mtype"] == MediaType.MUSIC
|
||||
assert chain.do_transfer.call_args.kwargs["mediainfo"] is recognized
|
||||
run_module.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user