feat(media): refine primary-source recognition

This commit is contained in:
jxxghp
2026-08-12 08:33:10 +08:00
parent 97dba2a8ae
commit a1dd259143
20 changed files with 1899 additions and 862 deletions

View File

@@ -1,10 +1,7 @@
import asyncio
import os
import re
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from threading import Lock
@@ -37,7 +34,6 @@ from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
ChainEventType,
EventType,
MediaRecognizeType,
MediaType,
ScrapingTarget,
ScrapingMetadata,
@@ -48,12 +44,10 @@ from app.utils.http import RequestUtils
from app.utils.media import (
is_music_media_source,
normalize_media_source,
resolve_media_identity,
)
from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.zhconv import convert as zhconv_convert
recognize_lock = Lock()
scraping_lock = Lock()
@@ -214,15 +208,6 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
flags=re.IGNORECASE,
)
_video_primary_source = "themoviedb"
_video_fallback_source_order = ("douban", "bangumi", "anilist")
_video_source_subtypes = {
"themoviedb": MediaRecognizeType.TMDB,
"douban": MediaRecognizeType.Douban,
"bangumi": MediaRecognizeType.Bangumi,
"anilist": MediaRecognizeType.AniList,
}
_video_title_min_similarity = 0.72
_video_recognize_min_score = 65.0
def __init__(self):
super().__init__()
@@ -234,7 +219,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""统一同步媒体识别路由,音乐请求只进入音乐数据源。"""
"""统一同步媒体识别路由,未指定来源时影视和音乐只使用各自主数据源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
@@ -259,7 +244,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return music_chain.recognize_best(meta=meta, cache=cache)
return None
if not source and isinstance(meta, MetaBase):
return self._recognize_video_best(module_kwargs, cache)
module_kwargs = {**module_kwargs, "source": self._video_primary_source}
return super()._run_native_media_recognize(module_kwargs, cache)
async def _async_run_native_media_recognize(
@@ -267,7 +252,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""统一异步媒体识别路由,音乐请求只进入音乐数据源。"""
"""统一异步媒体识别路由,未指定来源时影视和音乐只使用各自主数据源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
@@ -294,327 +279,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return await music_chain.async_recognize_best(meta=meta, cache=cache)
return None
if not source and isinstance(meta, MetaBase):
return await self._async_recognize_video_best(module_kwargs, cache)
module_kwargs = {**module_kwargs, "source": self._video_primary_source}
return await super()._async_run_native_media_recognize(module_kwargs, cache)
def _recognize_video_best(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""先验证 TMDB 主结果,仅在未可靠命中时并发比较内置影视副源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
primary_result = self._recognize_video_from_source(
module_kwargs=module_kwargs,
source=self._video_primary_source,
cache=cache,
)
primary = self._select_best_video_candidate(
meta=meta,
mtype=mtype,
candidates=[(self._video_primary_source, primary_result)],
source_order=(self._video_primary_source,),
)
if primary:
return primary
logger.info(f"{meta.name} 未可靠命中 TMDB开始并发查询影视辅助数据源 ...")
with ThreadPoolExecutor(
max_workers=len(self._video_fallback_source_order),
thread_name_prefix="video-recognize",
) as executor:
futures = {
source: executor.submit(
self._recognize_video_from_source,
module_kwargs,
source,
cache,
)
for source in self._video_fallback_source_order
}
candidates = [
(source, futures[source].result())
for source in self._video_fallback_source_order
]
return self._select_best_video_candidate(
meta=meta,
mtype=mtype,
candidates=candidates,
source_order=self._video_fallback_source_order,
)
async def _async_recognize_video_best(
self,
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""异步先验证 TMDB 主结果,仅在未可靠命中时并发比较影视副源。"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
primary_result = await self._async_recognize_video_from_source(
module_kwargs=module_kwargs,
source=self._video_primary_source,
cache=cache,
)
primary = self._select_best_video_candidate(
meta=meta,
mtype=mtype,
candidates=[(self._video_primary_source, primary_result)],
source_order=(self._video_primary_source,),
)
if primary:
return primary
logger.info(f"{meta.name} 未可靠命中 TMDB开始并发查询影视辅助数据源 ...")
results = await asyncio.gather(*(
self._async_recognize_video_from_source(
module_kwargs=module_kwargs,
source=source,
cache=cache,
)
for source in self._video_fallback_source_order
))
return self._select_best_video_candidate(
meta=meta,
mtype=mtype,
candidates=zip(self._video_fallback_source_order, results),
source_order=self._video_fallback_source_order,
)
def _video_recognize_module(self, source: str) -> Optional[Any]:
"""按内置影视来源枚举对应的运行中识别模块。"""
subtype = self._video_source_subtypes.get(normalize_media_source(source))
if not subtype:
return None
return next(self.modulemanager.get_running_subtype_module(subtype), None)
@staticmethod
def _video_source_kwargs(module_kwargs: dict, source: str) -> dict:
"""复制单源识别参数和元数据,避免并发模块互相修改解析状态。"""
source_kwargs = dict(module_kwargs)
if source_kwargs.get("meta"):
source_kwargs["meta"] = deepcopy(source_kwargs["meta"])
source_kwargs["source"] = source
return source_kwargs
def _recognize_video_from_source(
self,
module_kwargs: dict,
source: str,
cache: bool,
) -> Optional[MediaInfo]:
"""同步调用指定内置影视源,隔离单个来源的查询异常。"""
module = self._video_recognize_module(source)
if not module:
return None
try:
with fresh(not cache):
return module.recognize_media(
**self._video_source_kwargs(module_kwargs, source)
)
except Exception as err:
logger.warning(f"{source} 影视自动识别失败:{err}")
return None
async def _async_recognize_video_from_source(
self,
module_kwargs: dict,
source: str,
cache: bool,
) -> Optional[MediaInfo]:
"""异步调用指定内置影视源,隔离单个来源的查询异常。"""
module = self._video_recognize_module(source)
if not module:
return None
source_kwargs = self._video_source_kwargs(module_kwargs, source)
try:
async with async_fresh(not cache):
async_method = getattr(module, "async_recognize_media", None)
if async_method:
return await async_method(**source_kwargs)
return await run_in_threadpool(module.recognize_media, **source_kwargs)
except Exception as err:
logger.warning(f"{source} 影视自动识别失败:{err}")
return None
@staticmethod
def _normalize_video_candidate(
result: Any,
source: str,
) -> Optional[MediaInfo]:
"""校验单源影视结果的领域、来源和原生身份。"""
if not isinstance(result, MediaInfo):
return None
normalized_source = normalize_media_source(source)
if normalize_media_source(result.source) != normalized_source:
return None
identity_source, media_id = resolve_media_identity(media=result)
if identity_source != normalized_source or not media_id:
return None
if result.type not in {MediaType.MOVIE, MediaType.TV}:
return None
return result
@staticmethod
def _normalize_video_match_name(value: Any) -> str:
"""统一影视标题的繁简、大小写、空白和标点差异。"""
if not isinstance(value, str) or not value.strip():
return ""
return StringUtils.clear_upper(zhconv_convert(value, "zh-hans"))
@classmethod
def _normalized_video_names(cls, values: Iterable[Any]) -> set[str]:
"""生成原始标题及去除季集标记后的标准标题集合。"""
names: set[str] = set()
for value in values:
normalized = cls._normalize_video_match_name(value)
if normalized:
names.add(normalized)
if not isinstance(value, str) or not value.strip():
continue
try:
parsed_name = MetaInfo(value).name
except Exception:
parsed_name = None
normalized_parsed = cls._normalize_video_match_name(parsed_name)
if normalized_parsed:
names.add(normalized_parsed)
return names
@classmethod
def _video_title_similarity(
cls,
meta: MetaBase,
candidate: MediaInfo,
) -> float:
"""计算解析标题与候选全部标题及别名之间的最大相似度。"""
expected_names = cls._normalized_video_names([
getattr(meta, "name", None),
getattr(meta, "cn_name", None),
getattr(meta, "en_name", None),
])
candidate_names = cls._normalized_video_names([
candidate.title,
candidate.en_title,
candidate.original_title,
candidate.original_name,
*(candidate.names or []),
])
return max(
(
SequenceMatcher(None, expected, actual).ratio()
for expected in expected_names
for actual in candidate_names
),
default=0.0,
)
@staticmethod
def _video_year(value: Any) -> Optional[int]:
"""从年份或日期字段中提取四位年份。"""
match = re.search(r"\d{4}", str(value or ""))
return int(match.group()) if match else None
@classmethod
def _video_candidate_year(
cls,
meta: MetaBase,
candidate: MediaInfo,
) -> Optional[int]:
"""电视剧优先使用请求季年份,电影和整剧使用作品年份。"""
season = getattr(meta, "begin_season", None)
if candidate.type != MediaType.TV or season is None:
return cls._video_year(candidate.year)
season_years = candidate.season_years or {}
season_year = (
season_years.get(season)
or season_years.get(str(season))
)
if season_year:
return cls._video_year(season_year)
if candidate.season == season or not candidate.seasons:
return cls._video_year(candidate.year)
return None
@classmethod
def _video_candidate_score(
cls,
meta: MetaBase,
candidate: MediaInfo,
mtype: Optional[MediaType] = None,
) -> Optional[float]:
"""按标题、类型、年份和季信息计算跨影视源可比较的证据分。"""
expected_type = mtype if mtype in {MediaType.MOVIE, MediaType.TV} else meta.type
if expected_type in {MediaType.MOVIE, MediaType.TV} and candidate.type != expected_type:
return None
title_similarity = cls._video_title_similarity(meta, candidate)
if title_similarity < cls._video_title_min_similarity:
return None
score = title_similarity * 70
expected_year = cls._video_year(getattr(meta, "year", None))
candidate_year = cls._video_candidate_year(meta, candidate)
if expected_year and candidate_year:
year_delta = abs(expected_year - candidate_year)
if year_delta > 1:
return None
score += 20 if year_delta == 0 else 8
requested_season = getattr(meta, "begin_season", None)
if requested_season is not None:
if candidate.type != MediaType.TV:
return None
available_seasons = {
int(season)
for season in (candidate.seasons or {})
if str(season).isdigit()
}
if (
available_seasons
and requested_season not in available_seasons
and candidate.season != requested_season
):
return None
if (
requested_season in available_seasons
or candidate.season == requested_season
):
score += 10
return score
@classmethod
def _select_best_video_candidate(
cls,
meta: MetaBase,
mtype: Optional[MediaType],
candidates: Iterable[tuple[str, Any]],
source_order: Iterable[str],
) -> Optional[MediaInfo]:
"""按统一证据分选择影视候选,同分时使用确定的数据源顺序。"""
order = {source: index for index, source in enumerate(source_order)}
ranked: list[tuple[float, int, MediaInfo]] = []
for source, result in candidates:
candidate = cls._normalize_video_candidate(result, source)
if not candidate:
continue
score = cls._video_candidate_score(meta, candidate, mtype)
if score is None or score < cls._video_recognize_min_score:
continue
logger.debug(
f"影视自动识别候选:{source} {candidate.title_year},评分 {score:.1f}"
)
ranked.append((score, -order.get(source, len(order)), candidate))
if not ranked:
return None
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
score, _, best = ranked[0]
logger.info(
f"影视自动识别采用 {best.source}{best.title_year},匹配评分 {score:.1f}"
)
return best
def on_config_changed(self):
self.scraping_policies = ScrapingConfig.from_system_config()
@@ -1477,6 +1144,117 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
setattr(info, key, value)
return info
@staticmethod
def _clear_music_identity(meta: MetaMusic) -> MetaMusic:
"""复制音乐元数据并清除远程身份,供直查失败后按要素重新匹配。"""
clean_meta = MetaMusic.from_dict(meta.to_dict())
clean_meta.media_source = None
clean_meta.media_id = None
return clean_meta
@staticmethod
def _is_remote_music_info(info: Optional[MusicInfo]) -> bool:
"""判断音乐识别结果是否携带可复用的远程身份。"""
return bool(info and info.source and info.media_id)
@staticmethod
def _recognize_musicbrainz_recording(
meta: MetaMusic,
recording_id: str,
) -> Optional[MusicInfo]:
"""按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
identity_meta = MetaMusic.from_dict(meta.to_dict())
identity_meta.media_source = "musicbrainz"
identity_meta.media_id = recording_id
return MusicChain().recognize_from_source(
source="musicbrainz",
meta=identity_meta,
mediaid=recording_id,
music_type=MUSIC_ENTITY_RECORDING,
)
@staticmethod
async def _async_recognize_musicbrainz_recording(
meta: MetaMusic,
recording_id: str,
) -> Optional[MusicInfo]:
"""异步按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
identity_meta = MetaMusic.from_dict(meta.to_dict())
identity_meta.media_source = "musicbrainz"
identity_meta.media_id = recording_id
return await MusicChain().async_recognize_from_source(
source="musicbrainz",
meta=identity_meta,
mediaid=recording_id,
music_type=MUSIC_ENTITY_RECORDING,
)
def _recognize_music_meta_tier(
self,
meta: Optional[MetaMusic],
source: Optional[str],
tier_name: str,
) -> Optional[MusicInfo]:
"""识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(source)
search_meta = meta
if meta.media_source == "musicbrainz" and meta.media_id:
if normalized_source in (None, "musicbrainz"):
direct = self._recognize_musicbrainz_recording(
meta=meta,
recording_id=str(meta.media_id),
)
if self._is_remote_music_info(direct):
logger.info(f"音乐识别命中{tier_name}层 MusicBrainz ID 直查")
return direct
search_meta = self._clear_music_identity(meta)
if not search_meta.title:
return None
result = self.recognize_media(
meta=search_meta,
source=source,
music_type=MUSIC_ENTITY_RECORDING,
)
if self._is_remote_music_info(result):
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
return result
return None
async def _async_recognize_music_meta_tier(
self,
meta: Optional[MetaMusic],
source: Optional[str],
tier_name: str,
) -> Optional[MusicInfo]:
"""异步识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(source)
search_meta = meta
if meta.media_source == "musicbrainz" and meta.media_id:
if normalized_source in (None, "musicbrainz"):
direct = await self._async_recognize_musicbrainz_recording(
meta=meta,
recording_id=str(meta.media_id),
)
if self._is_remote_music_info(direct):
logger.info(f"音乐识别命中{tier_name}层 MusicBrainz ID 直查")
return direct
search_meta = self._clear_music_identity(meta)
if not search_meta.title:
return None
result = await self.async_recognize_media(
meta=search_meta,
source=source,
music_type=MUSIC_ENTITY_RECORDING,
)
if self._is_remote_music_info(result):
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
return result
return None
@staticmethod
def _music_album_dir_fallback(path: Union[str, Path]) -> Optional[MusicInfo]:
"""单曲识别无远端身份时,查找所在目录专辑匹配中属于当前文件的结果。"""
@@ -1490,15 +1268,50 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return None
return matched.get(str(file_path.resolve()))
@staticmethod
async def _async_music_album_dir_fallback(
path: Union[str, Path],
) -> Optional[MusicInfo]:
"""异步查找所在目录专辑匹配中属于当前文件的结果。"""
file_path = Path(path)
if not file_path.exists() or not file_path.is_file():
return None
try:
matched = await MusicChain().async_recognize_album_directory(
file_path.parent
)
except Exception as err:
logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}")
return None
return matched.get(str(file_path.resolve()))
def recognize_music_by_path(
self,
path: Union[str, Path],
source: Optional[str] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""同步根据音频标签文件名识别音乐,并保留离线最小结果"""
meta = self.read_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底
info = self.recognize_media(meta=meta, source=source)
"""按指纹、文件标签文件名三级顺序识别本地音乐"""
meta, tag_meta, filename_meta = MusicChain.read_path_evidence(path)
info = None
normalized_source = normalize_media_source(source)
if normalized_source in (None, "musicbrainz"):
recording_id = MusicChain().identify_by_fingerprint(path)
if recording_id:
info = self._recognize_musicbrainz_recording(meta, recording_id)
if self._is_remote_music_info(info):
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
if not self._is_remote_music_info(info):
info = self._recognize_music_meta_tier(
meta=tag_meta,
source=source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = self._recognize_music_meta_tier(
meta=filename_meta,
source=source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
@@ -1514,17 +1327,40 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
path: Union[str, Path],
source: Optional[str] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""根据音频标签文件名识别音乐,远端不可用时仍返回最小音乐信息"""
# Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。
meta = await run_in_threadpool(self.read_path_meta, path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底
info = await self.async_recognize_media(meta=meta, source=source)
"""异步按指纹、文件标签文件名三级顺序识别本地音乐"""
meta, tag_meta, filename_meta = await run_in_threadpool(
MusicChain.read_path_evidence,
path,
)
info = None
normalized_source = normalize_media_source(source)
if normalized_source in (None, "musicbrainz"):
recording_id = await MusicChain().async_identify_by_fingerprint(path)
if recording_id:
info = await self._async_recognize_musicbrainz_recording(
meta,
recording_id,
)
if self._is_remote_music_info(info):
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
if not self._is_remote_music_info(info):
info = await self._async_recognize_music_meta_tier(
meta=tag_meta,
source=source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = await self._async_recognize_music_meta_tier(
meta=filename_meta,
source=source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
if not result.source and source in (None, "musicbrainz"):
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
matched = await run_in_threadpool(self._music_album_dir_fallback, path)
matched = await self._async_music_album_dir_fallback(path)
if matched:
result = self._merge_music_audio_quality(matched, meta)
return meta, result

View File

@@ -1,6 +1,4 @@
import asyncio
import re
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Iterable, Optional, Union
@@ -20,12 +18,10 @@ from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
from app.utils.media import (
MUSIC_MEDIA_SOURCE_ORDER,
is_music_media_source,
normalize_media_source,
normalize_music_type,
)
from app.utils.zhconv import convert as zhconv_convert
class MusicChain(ChainBase):
@@ -39,9 +35,8 @@ 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
# 自动识别只使用 MusicBrainz其它来源仅响应显式来源请求
_primary_recognize_source = "musicbrainz"
@classmethod
def parse_query(cls, query: str) -> MetaMusic:
@@ -152,40 +147,28 @@ class MusicChain(ChainBase):
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""依次查询全部内置音乐源,统一评分后返回最可信的自动识别结果"""
candidates: list[MusicInfo] = []
offline_fallback: Optional[MusicInfo] = None
"""执行自动音乐识别,仅调用 MusicBrainz 主数据源"""
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
return self.recognize_from_source(
source=self._primary_recognize_source,
meta=meta,
cache=cache,
music_type=MUSIC_ENTITY_RECORDING,
)
async def async_recognize_best(
self,
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""并发查询全部内置音乐源,统一评分后返回最可信的自动识别结果"""
"""异步执行自动音乐识别,仅调用 MusicBrainz 主数据源"""
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
return await self.async_recognize_from_source(
source=self._primary_recognize_source,
meta=meta,
cache=cache,
music_type=MUSIC_ENTITY_RECORDING,
)
def recognize_from_source(
self,
@@ -448,25 +431,6 @@ 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
@staticmethod
def _validate_source_recognize_result(
result: Optional[MusicInfo],
@@ -540,13 +504,7 @@ class MusicChain(ChainBase):
}
if music_type is not None:
recognize_kwargs["music_type"] = music_type
async_method = getattr(module, "async_recognize_media", None)
if async_method:
return await async_method(**recognize_kwargs)
return await run_in_threadpool(
module.recognize_media,
**recognize_kwargs,
)
return await module.async_recognize_media(**recognize_kwargs)
except Exception as err:
logger.warning(f"{source} 音乐自动识别失败:{err}")
return None
@@ -559,117 +517,6 @@ class MusicChain(ChainBase):
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]:
"""按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。
@@ -695,8 +542,23 @@ class MusicChain(ChainBase):
return matched
async def async_recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
"""目录级批量识别的异步版本,本地文件读取移出事件循环"""
return await run_in_threadpool(self.recognize_album_directory, path)
"""异步按目录级线索批量识别整目录音频"""
dir_path = Path(path)
if not dir_path.is_dir():
return {}
files = await run_in_threadpool(self._directory_audio_files, dir_path)
if len(files) < self._album_match_min_files:
return {}
cache_key = str(dir_path)
signature = self._album_directory_signature(dir_path, files)
cached = self._album_dir_cache.get(cache_key)
if cached and cached[0] == signature:
return cached[1]
matched = await self._async_match_album_directory(dir_path, files)
if len(self._album_dir_cache) >= self._album_dir_cache_max:
self._album_dir_cache.clear()
self._album_dir_cache[cache_key] = (signature, matched)
return matched
@classmethod
def _directory_audio_files(cls, dir_path: Path) -> list[Path]:
@@ -739,13 +601,42 @@ class MusicChain(ChainBase):
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
meta = MetaMusic(
org_string=file_path.name,
title=file_path.stem,
audio_format=file_path.suffix.lstrip(".").upper() or None,
return AudioMetadataHelper.read_filename(file_path)
@classmethod
def read_path_evidence(
cls,
path: Union[str, Path],
) -> tuple[MetaMusic, Optional[MetaMusic], MetaMusic]:
"""分别返回合并元数据、纯标签元数据和纯文件名元数据。"""
file_path = Path(path)
filename_meta = AudioMetadataHelper.read_filename(file_path)
tag_meta = None
if file_path.exists() and file_path.is_file():
tag_meta = AudioMetadataHelper.read_tags(file_path)
if not tag_meta:
return filename_meta, None, filename_meta
merged_meta = MetaMusic.from_dict(tag_meta.to_dict()).apply_path_context(file_path)
return merged_meta, tag_meta, filename_meta
def identify_by_fingerprint(self, path: Union[str, Path]) -> Optional[str]:
"""调用音频指纹模块识别 MusicBrainz Recording ID。"""
result = self.run_module(
"identify_music_by_fingerprint",
path=Path(path),
)
# apply_path_context 会先剥离曲序再解析文件名,不能在构造阶段提前解析一次。
return meta.apply_path_context(file_path)
return str(result) if result else None
async def async_identify_by_fingerprint(
self,
path: Union[str, Path],
) -> Optional[str]:
"""异步调用音频指纹模块识别 MusicBrainz Recording ID。"""
result = await self.async_run_module(
"async_identify_music_by_fingerprint",
path=Path(path),
)
return str(result) if result else None
def _match_album_directory(
self,
@@ -760,8 +651,9 @@ class MusicChain(ChainBase):
logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}")
return {}
candidates = self.run_module("match_music_album", meta=album_meta, tracks=metas)
candidate_items = candidates if isinstance(candidates, list) else [candidates]
album = next(
(item for item in candidates or [] if isinstance(item, MusicAlbumInfo) and item.tracks),
(item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks),
None,
)
if not album:
@@ -772,6 +664,40 @@ class MusicChain(ChainBase):
matched[str(file.resolve())] = info
return matched
async def _async_match_album_directory(
self,
dir_path: Path,
files: list[Path],
) -> dict[str, MusicInfo]:
"""异步执行目录级专辑匹配,本地标签读取保持在线程池中。"""
metas = await run_in_threadpool(self._read_album_path_metas, files)
album_meta = self._album_meta_from_context(dir_path, metas)
if not (album_meta.album or album_meta.title or album_meta.artists):
logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}")
return {}
candidates = await self.async_run_module(
"async_match_music_album",
meta=album_meta,
tracks=metas,
)
candidate_items = candidates if isinstance(candidates, list) else [candidates]
album = next(
(item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks),
None,
)
if not album:
return {}
logger.info(f"目录 {dir_path.name} 匹配到专辑:{album.title_year}{album.source}")
matched: dict[str, MusicInfo] = {}
for file, info in self._align_album_tracks(files, metas, album.tracks).items():
matched[str(file.resolve())] = info
return matched
@classmethod
def _read_album_path_metas(cls, files: list[Path]) -> list[MetaMusic]:
"""批量读取专辑目录中的本地音频元数据。"""
return [cls.read_path_meta(file) for file in files]
@classmethod
def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic:
"""汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。"""

View File

@@ -231,6 +231,8 @@ class ConfigModel(BaseModel):
# ==================== 音乐配置 ====================
# 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)
MUSIC_COVER_PROXY: str = ""
# AcoustID 应用 API Key用于查询本地音频的 Chromaprint 指纹
ACOUSTID_API_KEY: str = "b1auxfOzAg"
# TheAudioDB API Key默认使用官方公开的免费 V1 Key可通过环境变量覆盖
THEAUDIODB_API_KEY: str = "123"

View File

@@ -1,5 +1,6 @@
from pathlib import Path
from typing import Any, Optional, Union
from uuid import UUID
from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
@@ -9,6 +10,7 @@ from mutagen.mp4 import MP4, MP4Cover
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING
class AudioMetadataHelper:
@@ -17,29 +19,36 @@ class AudioMetadataHelper:
@classmethod
def read(cls, path: Path) -> MetaMusic:
"""读取本地音频标签,并以完整文件名模式和目录线索补充缺失字段。"""
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)
tag_meta = cls.read_tags(path)
if tag_meta:
return tag_meta.apply_path_context(path)
return cls.read_filename(path)
@classmethod
def read_tags(cls, path: Path) -> Optional[MetaMusic]:
"""只读取本地音频标签和流参数,不使用文件名或目录补齐。"""
try:
audio = MutagenFile(path, easy=True)
except Exception as err:
logger.warning(f"读取音频标签失败:{path} - {err}")
return filename_fallback()
return None
if not audio:
return filename_fallback()
return None
tags = audio.tags or {}
track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber"))
disc_number, total_discs = cls._number_pair(cls._first(tags, "discnumber"))
musicbrainz_id = cls._normalize_musicbrainz_id(
cls._first_of(
tags,
"musicbrainz_trackid",
"musicbrainz_recordingid",
)
)
info = getattr(audio, "info", None)
return MetaMusic(
org_string=path.name,
title=cls._first(tags, "title") or path.stem,
title=cls._first(tags, "title"),
artists=cls._values(tags, "artist"),
album=cls._first(tags, "album"),
album_artist=cls._first(tags, "albumartist"),
@@ -55,6 +64,17 @@ 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"),
media_source="musicbrainz" if musicbrainz_id else None,
media_id=musicbrainz_id,
)
@staticmethod
def read_filename(path: Path) -> MetaMusic:
"""只从文件名和目录结构解析音乐元数据。"""
return MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=path.suffix.lstrip(".").upper() or None,
).apply_path_context(path)
@classmethod
@@ -123,8 +143,26 @@ class AudioMetadataHelper:
"tracknumber": track_number,
"discnumber": disc_number,
"isrc": getattr(music, "isrc", None),
"musicbrainz_trackid": cls._musicbrainz_recording_id(music),
}
@staticmethod
def _musicbrainz_recording_id(
music: Union[MetaMusic, MusicInfo],
) -> Optional[str]:
"""仅将 MusicBrainz 单曲身份写入 recording 标签,避免误写专辑 ID。"""
if getattr(music, "media_source", None) == "musicbrainz":
media_id = getattr(music, "media_id", None)
return str(media_id) if media_id else None
if (
getattr(music, "source", None) == "musicbrainz"
and getattr(music, "music_type", MUSIC_ENTITY_RECORDING)
== MUSIC_ENTITY_RECORDING
):
media_id = getattr(music, "media_id", None)
return str(media_id) if media_id else None
return None
@staticmethod
def _number_text(current: Optional[int], total: Optional[int]) -> Optional[str]:
"""把曲序或碟号转换为常见的 current/total 标签文本。"""
@@ -200,6 +238,22 @@ class AudioMetadataHelper:
values = cls._values(tags, key)
return values[0] if values else None
@classmethod
def _first_of(cls, tags: Any, *keys: str) -> Optional[str]:
"""按顺序返回多个音频标签中的第一个非空值。"""
for key in keys:
if value := cls._first(tags, key):
return value
return None
@staticmethod
def _normalize_musicbrainz_id(value: Optional[str]) -> Optional[str]:
"""校验并规范化音频标签中的 MusicBrainz UUID。"""
try:
return str(UUID(str(value)))
except (AttributeError, TypeError, ValueError):
return None
@staticmethod
def _number_pair(value: Optional[str]) -> tuple[Optional[int], Optional[int]]:
"""解析 track/disc 标签中的当前编号和总数。"""

View File

@@ -0,0 +1,389 @@
import asyncio
import json
import shutil
import subprocess
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any, Optional, Tuple, Union
from uuid import UUID
from app.core.config import settings
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
from app.utils.http import AsyncRequestUtils, RequestUtils
class AcoustIdModule(_ModuleBase):
"""通过 Chromaprint 本地指纹和 AcoustID API 识别 MusicBrainz Recording ID。"""
_base_url = "https://api.acoustid.org/v2/lookup"
_minimum_score = 0.9
_request_interval = 0.34
_fingerprint_timeout = 60
_cache_max = 1024
_request_lock = threading.Lock()
_last_request_at = 0.0
def __init__(self) -> None:
"""初始化 fpcalc 路径和进程内文件指纹识别缓存。"""
super().__init__()
self._fpcalc_path: Optional[str] = None
self._cache: OrderedDict[tuple[str, int, int], Optional[str]] = OrderedDict()
self._cache_lock = threading.Lock()
def init_module(self) -> None:
"""定位 fpcalc 工具并清空可能过期的文件识别缓存。"""
self._fpcalc_path = shutil.which("fpcalc")
with self._cache_lock:
self._cache.clear()
if not self._fpcalc_path:
logger.warning("AcoustID 已配置,但未找到 fpcalc音频指纹识别将跳过")
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""仅在配置 AcoustID 应用 API Key 后启用模块。"""
return "ACOUSTID_API_KEY", True
def stop(self) -> None:
"""停止模块并释放进程内文件识别缓存。"""
with self._cache_lock:
self._cache.clear()
def test(self) -> Tuple[bool, str]:
"""检查 API Key、fpcalc 和 AcoustID API 的基础连通性。"""
if not str(settings.ACOUSTID_API_KEY or "").strip():
return False, "AcoustID API Key 未配置"
if not self._fpcalc_path:
return False, "未找到 fpcalc请先安装 Chromaprint"
response = RequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=15,
).get_res(
url=self._base_url,
params={"client": settings.ACOUSTID_API_KEY, "format": "json"},
)
if response is None:
return False, "AcoustID 网络连接失败"
try:
if response.status_code >= 500:
return False, f"AcoustID 服务异常:{response.status_code}"
return True, ""
finally:
response.close()
@staticmethod
def get_name() -> str:
"""返回模块展示名称。"""
return "AcoustID"
@staticmethod
def get_type() -> ModuleType:
"""返回模块所属的其它音乐能力类型。"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""返回 AcoustID 模块子类型。"""
return OtherModulesType.AcoustId
@staticmethod
def get_priority() -> int:
"""返回音频指纹识别模块执行优先级。"""
return 0
def identify_music_by_fingerprint(self, path: Path) -> Optional[str]:
"""读取本地音频指纹并返回高置信匹配的 MusicBrainz Recording ID。"""
file_path = Path(path)
if not self._fpcalc_path or not file_path.is_file():
return None
cache_key = self._file_cache_key(file_path)
if cache_key:
found, cached_id = self._get_cached(cache_key)
if found:
return cached_id
fingerprint = self._generate_fingerprint(file_path)
recording_id = (
self._lookup_recording_id(*fingerprint) if fingerprint else None
)
if cache_key:
self._set_cached(cache_key, recording_id)
return recording_id
async def async_identify_music_by_fingerprint(
self,
path: Path,
) -> Optional[str]:
"""异步读取音频指纹并返回高置信匹配的 MusicBrainz Recording ID。"""
file_path = Path(path)
if not self._fpcalc_path or not file_path.is_file():
return None
cache_key = self._file_cache_key(file_path)
if cache_key:
found, cached_id = self._get_cached(cache_key)
if found:
return cached_id
fingerprint = await self._async_generate_fingerprint(file_path)
recording_id = (
await self._async_lookup_recording_id(*fingerprint)
if fingerprint
else None
)
if cache_key:
self._set_cached(cache_key, recording_id)
return recording_id
@staticmethod
def _file_cache_key(path: Path) -> Optional[tuple[str, int, int]]:
"""按规范路径、文件大小和修改时间构造可自动失效的缓存键。"""
try:
stat = path.stat()
return str(path.resolve()), stat.st_size, stat.st_mtime_ns
except OSError:
return None
def _get_cached(
self,
cache_key: tuple[str, int, int],
) -> tuple[bool, Optional[str]]:
"""读取并触摸文件识别 LRU 缓存,区分未缓存与已缓存未命中。"""
with self._cache_lock:
if cache_key not in self._cache:
return False, None
value = self._cache.pop(cache_key)
self._cache[cache_key] = value
return True, value
def _set_cached(
self,
cache_key: tuple[str, int, int],
recording_id: Optional[str],
) -> None:
"""写入文件识别 LRU 缓存并淘汰最早使用的条目。"""
with self._cache_lock:
self._cache.pop(cache_key, None)
self._cache[cache_key] = recording_id
while len(self._cache) > self._cache_max:
self._cache.popitem(last=False)
def _generate_fingerprint(self, path: Path) -> Optional[tuple[int, str]]:
"""调用 fpcalc 生成 AcoustID 查询所需的完整时长和压缩指纹。"""
try:
result = subprocess.run(
[self._fpcalc_path, "-json", str(path)],
capture_output=True,
text=True,
check=False,
timeout=self._fingerprint_timeout,
)
except (OSError, subprocess.TimeoutExpired) as err:
logger.warning(f"生成音频指纹失败:{path} - {err}")
return None
if result.returncode != 0:
logger.warning(
f"生成音频指纹失败:{path} - fpcalc 退出码 {result.returncode}"
)
return None
return self._parse_fingerprint_output(path, result.stdout)
async def _async_generate_fingerprint(
self,
path: Path,
) -> Optional[tuple[int, str]]:
"""异步调用 fpcalc 生成 AcoustID 查询所需的指纹。"""
process = None
try:
process = await asyncio.create_subprocess_exec(
self._fpcalc_path,
"-json",
str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
process.communicate(),
timeout=self._fingerprint_timeout,
)
except asyncio.TimeoutError:
if process and process.returncode is None:
process.kill()
await process.communicate()
logger.warning(f"生成音频指纹超时:{path}")
return None
except asyncio.CancelledError:
if process and process.returncode is None:
process.kill()
await process.communicate()
raise
except OSError as err:
logger.warning(f"生成音频指纹失败:{path} - {err}")
return None
if process.returncode != 0:
logger.warning(
f"生成音频指纹失败:{path} - fpcalc 退出码 {process.returncode}"
)
return None
return self._parse_fingerprint_output(
path,
stdout.decode("utf-8", errors="replace"),
)
@staticmethod
def _parse_fingerprint_output(
path: Path,
output: str,
) -> Optional[tuple[int, str]]:
"""解析 fpcalc JSON 输出中的音频时长和压缩指纹。"""
try:
payload = json.loads(output)
duration = round(float(payload.get("duration") or 0))
fingerprint = str(payload.get("fingerprint") or "").strip()
except (AttributeError, TypeError, ValueError) as err:
logger.warning(f"fpcalc 输出解析失败:{path} - {err}")
return None
if duration <= 0 or not fingerprint:
logger.warning(f"fpcalc 未返回有效音频指纹:{path}")
return None
return duration, fingerprint
@classmethod
def _reserve_request_delay(cls) -> float:
"""为同步和异步 AcoustID 请求统一预留下一个发送时间。"""
with cls._request_lock:
now = time.monotonic()
request_at = max(now, cls._last_request_at + cls._request_interval)
cls._last_request_at = request_at
return max(0.0, request_at - now)
@classmethod
def _wait_for_rate_limit(cls) -> None:
"""同步等待 AcoustID 公共接口的已预留请求时间。"""
if delay := cls._reserve_request_delay():
time.sleep(delay)
@classmethod
async def _async_wait_for_rate_limit(cls) -> None:
"""异步等待 AcoustID 公共接口的已预留请求时间。"""
if delay := cls._reserve_request_delay():
await asyncio.sleep(delay)
def _lookup_recording_id(
self,
duration: int,
fingerprint: str,
) -> Optional[str]:
"""查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。"""
api_key = str(settings.ACOUSTID_API_KEY or "").strip()
if not api_key:
return None
self._wait_for_rate_limit()
response = RequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
).post_res(
url=self._base_url,
data={
"client": api_key,
"duration": duration,
"fingerprint": fingerprint,
"meta": "recordingids",
"format": "json",
},
)
if response is None:
logger.warning("AcoustID 指纹查询失败:无响应")
return None
try:
if response.status_code != 200:
logger.warning(f"AcoustID 指纹查询失败HTTP {response.status_code}")
return None
payload = response.json()
return self._select_recording_id(payload)
except (TypeError, ValueError) as err:
logger.warning(f"AcoustID 响应解析失败:{err}")
return None
finally:
response.close()
async def _async_lookup_recording_id(
self,
duration: int,
fingerprint: str,
) -> Optional[str]:
"""异步查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。"""
api_key = str(settings.ACOUSTID_API_KEY or "").strip()
if not api_key:
return None
await self._async_wait_for_rate_limit()
response = await AsyncRequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
).post_res(
url=self._base_url,
data={
"client": api_key,
"duration": duration,
"fingerprint": fingerprint,
"meta": "recordingids",
"format": "json",
},
)
if response is None:
logger.warning("AcoustID 指纹查询失败:无响应")
return None
try:
if response.status_code != 200:
logger.warning(f"AcoustID 指纹查询失败HTTP {response.status_code}")
return None
return self._select_recording_id(response.json())
except (TypeError, ValueError) as err:
logger.warning(f"AcoustID 响应解析失败:{err}")
return None
finally:
await response.aclose()
@classmethod
def _select_recording_id(cls, payload: Any) -> Optional[str]:
"""按匹配分从 AcoustID 响应中选择首个有效 MusicBrainz Recording ID。"""
if not isinstance(payload, dict) or payload.get("status") != "ok":
return None
results = payload.get("results") or []
ranked = sorted(
(item for item in results if isinstance(item, dict)),
key=lambda item: cls._score(item.get("score")),
reverse=True,
)
for item in ranked:
score = cls._score(item.get("score"))
if score < cls._minimum_score:
break
for recording in item.get("recordings") or []:
recording_id = cls._normalize_recording_id(
recording.get("id") if isinstance(recording, dict) else None
)
if recording_id:
logger.info(
f"AcoustID 指纹命中 MusicBrainz{recording_id},匹配度 {score:.3f}"
)
return recording_id
return None
@staticmethod
def _score(value: Any) -> float:
"""将 AcoustID 匹配分安全转换为零到一之间的浮点数。"""
try:
return max(0.0, min(float(value), 1.0))
except (TypeError, ValueError):
return 0.0
@staticmethod
def _normalize_recording_id(value: Any) -> Optional[str]:
"""校验并规范化 MusicBrainz UUID拒绝异常外部响应进入详情路径。"""
try:
return str(UUID(str(value)))
except (AttributeError, TypeError, ValueError):
return None

View File

@@ -63,7 +63,7 @@ class DoubanModule(_ModuleBase):
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
"""返回音乐识别使用的数据源标识。"""
return DoubanModule._music_source
@staticmethod

View File

@@ -1,10 +1,10 @@
import asyncio
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Any, Iterable, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from requests import Session
from app.core.cache import cached
@@ -26,7 +26,7 @@ from app.schemas.types import (
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.media import is_media_source_selected
from app.utils.zhconv import convert as zhconv_convert
@@ -123,7 +123,7 @@ class MusicBrainzModule(_ModuleBase):
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
"""返回音乐识别使用的数据源标识。"""
return MusicBrainzModule._source
@staticmethod
@@ -177,6 +177,30 @@ class MusicBrainzModule(_ModuleBase):
return results
return []
async def _async_search_recordings(
self,
meta: MetaMusic,
limit: int,
) -> list[MusicInfo]:
"""异步按音频标签条件搜索 Recording 候选。"""
for query in self._recording_queries(meta):
payload = await self._async_request_json(
"/recording",
params={
"query": query,
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
)
results = [
info
for item in (payload or {}).get("recordings") or []
if (info := self._recording_to_info(item))
]
if results:
return results
return []
@classmethod
def _recording_queries(cls, meta: MetaMusic) -> list[str]:
"""构造 Recording 检索式阶梯,由严到宽逐级放宽避免零命中。
@@ -312,6 +336,30 @@ class MusicBrainzModule(_ModuleBase):
return results
return []
async def _async_search_albums(
self,
meta: MetaMusic,
limit: int,
) -> list[MusicInfo]:
"""异步按标题和可选艺术家搜索 Release Group 专辑候选。"""
for query in self._album_queries(meta):
payload = await self._async_request_json(
"/release-group",
params={
"query": query,
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
)
results = [
album.to_music_info()
for item in (payload or {}).get("release-groups") or []
if (album := self._release_group_to_album(item))
]
if results:
return results
return []
@classmethod
def _album_queries(cls, meta: MetaMusic) -> list[str]:
"""构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。"""
@@ -413,6 +461,41 @@ class MusicBrainzModule(_ModuleBase):
return None
return best_album
async def async_match_music_album(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int = 5,
) -> Optional[MusicAlbumInfo]:
"""异步按目录线索和曲目特征匹配 MusicBrainz 发行版本。"""
if not tracks:
return None
best_album: Optional[MusicAlbumInfo] = None
best_score = 0.0
releases = await self._async_search_release_candidates(
meta,
tracks,
limit=limit,
)
for release in releases:
release_id = release.get("id")
if not release_id:
continue
detail = await self._async_request_json(
f"/release/{release_id}",
params={"inc": "recordings+media+artist-credits", "fmt": "json"},
)
if not detail:
continue
summary = self._release_track_summary(detail)
score = self._score_release(meta, tracks, detail, summary)
if score > best_score:
best_score = score
best_album = self._release_to_album(detail)
if best_score < self._album_match_threshold:
return None
return best_album
_album_match_threshold = 60.0
def _search_release_candidates(
@@ -438,6 +521,33 @@ class MusicBrainzModule(_ModuleBase):
break
return releases[:limit]
async def _async_search_release_candidates(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int,
) -> list[dict[str, Any]]:
"""异步按专辑名和曲名线索搜索并去重候选发行版本。"""
releases: list[dict[str, Any]] = []
seen: set[str] = set()
for query in self._release_queries(meta, tracks):
payload = await self._async_request_json(
"/release",
params={
"query": query,
"limit": max(1, min(limit, 25)),
"fmt": "json",
},
)
for item in (payload or {}).get("releases") or []:
release_id = item.get("id")
if release_id and release_id not in seen:
seen.add(release_id)
releases.append(item)
if len(releases) >= limit:
break
return releases[:limit]
@classmethod
def _release_queries(cls, meta: MetaMusic, tracks: list[MetaMusic]) -> list[str]:
"""构造专辑搜索表达式:优先专辑名+歌手,无专辑线索时用曲名兜底。"""
@@ -739,15 +849,56 @@ class MusicBrainzModule(_ModuleBase):
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""同步分发到音乐识别的异步版本,避免阻塞共享事件循环"""
return await run_in_threadpool(
self.recognize_media,
"""异步识别 MusicBrainz 音乐详情或按元数据匹配单曲"""
music_type = kwargs.get("music_type")
if source and source != self._source:
return None
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
return None
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
return await self.async_recognize_music(
source,
str(mediaid),
music_type=music_type,
)
return None
resolved_source = source or meta.media_source
resolved_media_id = mediaid or meta.media_id
if resolved_source and resolved_media_id:
info = await self.async_recognize_music(
resolved_source,
str(resolved_media_id),
music_type=music_type,
)
if info:
self._update_recognize_cache(meta, info)
return info
if music_type == MUSIC_ENTITY_ALBUM:
albums = await self._async_search_albums(meta, limit=10)
return self._select_album_candidate(meta, albums)
cache_enabled = bool(kwargs.get("cache", True))
if cache_enabled and self.cache:
cached_info = self.cache.get(meta)
if cached_info:
if cached_info.media_id:
logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}")
else:
logger.info(f"{meta.title} 使用音乐识别缓存:无法识别")
cached_info.recognize_cache_hit = True
return cached_info
candidates = await self._async_search_recordings(meta, limit=10)
matched = self._select_candidate(
meta,
mtype=mtype,
source=source,
mediaid=mediaid,
**kwargs,
candidates,
source=resolved_source or self._source,
)
if not matched and meta.artists and music_type != MUSIC_ENTITY_RECORDING:
albums = await self._async_search_albums(meta, limit=10)
matched = self._select_album_candidate(meta, albums)
result = matched or self._info_from_meta(meta)
self._update_recognize_cache(meta, result)
return result
@classmethod
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
@@ -1015,6 +1166,57 @@ class MusicBrainzModule(_ModuleBase):
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
async def async_recognize_music(
self,
source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按 MusicBrainz 标准 ID 和实体类型获取详情。"""
if source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = await self._async_request_json(
f"/recording/{media_id}",
params={
"inc": "artists+releases+release-groups+isrcs+genres",
"fmt": "json",
},
)
if payload:
return self._recording_to_info(payload)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = await self._async_music_album(source, media_id)
return album.to_music_info() if album else None
async def _async_music_album(
self,
source: str,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。"""
if source != self._source or not media_id:
return None
payload = await self._async_request_json(
f"/release-group/{media_id}",
params={
"inc": "artists+releases+media+genres+tags+ratings",
"fmt": "json",
},
)
if not payload:
return None
album = self._release_group_to_album(payload)
if not album:
return None
album.releases = self._release_variants(payload.get("releases") or [])
album.tracks = await self._async_album_tracks(
album,
payload.get("releases") or [],
)
return album
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。"""
if source != self._source or not media_id:
@@ -1308,6 +1510,28 @@ class MusicBrainzModule(_ModuleBase):
tracks.append(info)
return tracks
@classmethod
async def _async_album_tracks(
cls,
album: MusicAlbumInfo,
releases: list[dict[str, Any]],
) -> list[MusicInfo]:
"""异步读取专辑代表性发行版本的曲目。"""
release = cls._select_track_release(releases)
if not release.get("id"):
return []
payload = await cls._async_request_json(
f"/release/{release['id']}",
params={"inc": "recordings+artist-credits", "fmt": "json"},
)
tracks: list[MusicInfo] = []
for medium in (payload or {}).get("media") or []:
for track in medium.get("tracks") or []:
info = cls._track_to_info(album, medium, track)
if info:
tracks.append(info)
return tracks
@classmethod
def _track_to_info(
cls,
@@ -1513,14 +1737,25 @@ class MusicBrainzModule(_ModuleBase):
return cls._session
@classmethod
def _wait_for_rate_limit(cls) -> None:
"""串行控制 MusicBrainz 公共接口的最小请求间隔"""
def _reserve_request_delay(cls) -> float:
"""为同步和异步 MusicBrainz 请求统一预留发送时间"""
with cls._request_lock:
now = time.monotonic()
remaining = cls._request_interval - (now - cls._last_request_at)
if remaining > 0:
time.sleep(remaining)
cls._last_request_at = time.monotonic()
request_at = max(now, cls._last_request_at + cls._request_interval)
cls._last_request_at = request_at
return max(0.0, request_at - now)
@classmethod
def _wait_for_rate_limit(cls) -> None:
"""同步等待 MusicBrainz 公共接口的已预留请求时间。"""
if delay := cls._reserve_request_delay():
time.sleep(delay)
@classmethod
async def _async_wait_for_rate_limit(cls) -> None:
"""异步等待 MusicBrainz 公共接口的已预留请求时间。"""
if delay := cls._reserve_request_delay():
await asyncio.sleep(delay)
@classmethod
@cached(maxsize=settings.CONF.musicbrainz, ttl=settings.CONF.meta, skip_none=True)
@@ -1575,3 +1810,56 @@ class MusicBrainzModule(_ModuleBase):
finally:
response.close()
return None
@classmethod
@cached(
maxsize=settings.CONF.musicbrainz,
ttl=settings.CONF.meta,
skip_none=True,
shared_key="_request_json",
)
async def _async_request_json(
cls,
path: str,
params: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
"""异步请求 MusicBrainz JSON 接口并统一处理限流与响应错误。"""
attempts = cls._busy_retries + 1
for attempt in range(attempts):
await cls._async_wait_for_rate_limit()
response = await AsyncRequestUtils(
headers={
"User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)",
"Accept": "application/json",
},
proxies=settings.PROXY,
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
if response is None:
return None
status_code = response.status_code
try:
if status_code == 404:
logger.debug(f"MusicBrainz 资源不存在:{path}")
return {}
if status_code == 429 or status_code >= 500:
logger.warning(
f"MusicBrainz 服务繁忙:{status_code} {response.text[:200]}"
)
if attempt < attempts - 1:
await asyncio.sleep(cls._busy_backoff * (2 ** attempt))
continue
return None
if status_code != 200:
logger.warning(
f"MusicBrainz 请求失败:{status_code} {response.text[:200]}"
)
return None
payload = response.json()
return payload if isinstance(payload, dict) else None
except (TypeError, ValueError) as err:
logger.warning(f"MusicBrainz 响应解析失败:{err}")
return None
finally:
await response.aclose()
return None

View File

@@ -1,7 +1,5 @@
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 (
@@ -19,7 +17,7 @@ from app.schemas.types import (
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.media import is_media_source_selected
@@ -52,7 +50,7 @@ class TheAudioDbModule(_ModuleBase):
@staticmethod
def get_music_source() -> str:
"""返回多源音乐识别使用的数据源标识。"""
"""返回音乐识别使用的数据源标识。"""
return TheAudioDbModule._source
@staticmethod
@@ -136,15 +134,39 @@ class TheAudioDbModule(_ModuleBase):
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""在线程池执行 TheAudioDB 同步识别,避免阻塞事件循环"""
return await run_in_threadpool(
self.recognize_media,
"""异步识别 TheAudioDB 音乐详情或按元数据匹配单曲"""
music_type = kwargs.get("music_type")
if source != self._source:
return None
if not isinstance(meta, MetaMusic):
if mtype == MediaType.MUSIC and mediaid:
return await self.async_recognize_music(
source,
str(mediaid),
music_type=music_type,
)
return None
resolved_media_id = mediaid or meta.media_id
if resolved_media_id:
return await self.async_recognize_music(
source,
str(resolved_media_id),
music_type=music_type,
)
if music_type != MUSIC_ENTITY_ALBUM:
matched = self._select_track(
meta,
await self._async_search_tracks(meta),
)
if matched:
return matched
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = self._select_album(
meta,
mtype=mtype,
source=source,
mediaid=mediaid,
**kwargs,
await self._async_search_albums(meta),
)
return album.to_music_info() if album else None
def recognize_music(
self,
@@ -165,6 +187,46 @@ class TheAudioDbModule(_ModuleBase):
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
async def async_recognize_music(
self,
source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按 TheAudioDB 原生 ID 和实体类型获取详情。"""
if source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = await self._async_request_json("track.php", {"h": media_id})
track = self._first_entity(payload, "track", "tracks")
if track:
return self._track_to_info(track)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = await self._async_music_album(source, media_id)
return album.to_music_info() if album else None
async def _async_music_album(
self,
source: str,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._source or not media_id:
return None
payload = await self._async_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 = await self._async_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_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._source or not media_id:
@@ -231,6 +293,21 @@ class TheAudioDbModule(_ModuleBase):
if (info := self._track_to_info(item))
]
async def _async_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 = await self._async_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
@@ -242,6 +319,23 @@ class TheAudioDbModule(_ModuleBase):
payload = self._request_json("searchalbum.php", params)
return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
async def _async_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 = await self._async_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
@@ -456,6 +550,44 @@ class TheAudioDbModule(_ModuleBase):
return None
return payload if isinstance(payload, dict) else None
@classmethod
@cached(
maxsize=settings.CONF.theaudiodb,
ttl=settings.CONF.meta,
skip_none=True,
shared_key="_request_json",
)
async def _async_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 = await AsyncRequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
).get_res(
url=f"{cls._base_url}/{api_key}/{endpoint}",
params=params or {},
)
if response is None:
return None
try:
if response.status_code != 200:
return None
payload = response.json()
except ValueError as err:
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
return None
finally:
await response.aclose()
return payload if isinstance(payload, dict) else None
@staticmethod
def _entities(
payload: Optional[dict[str, Any]],

View File

@@ -498,6 +498,8 @@ class OtherModulesType(Enum):
ListenBrainz = "ListenBrainz"
# LRCLIB 歌词
Lrclib = "LRCLIB"
# AcoustID 音频指纹
AcoustId = "AcoustID"
class NameValueEnum(Enum):

View File

@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
lsof \
nano \
unar \
libchromaprint-tools \
libjemalloc2 \
&& dpkg-reconfigure --frontend noninteractive tzdata \
&& curl https://rclone.org/install.sh | bash \

View File

@@ -441,6 +441,7 @@ moviepilot config get PORT
moviepilot config set PORT 3001
moviepilot config set NGINX_PORT 3000
moviepilot config set API_TOKEN your-token-here
moviepilot config set ACOUSTID_API_KEY your-acoustid-client-key
```
查看所有可配置项:
@@ -458,6 +459,7 @@ moviepilot config describe API_TOKEN --show-secrets
- `config list` 显示当前配置值
- `config keys` 显示配置项名称、类型和默认值
- `ACOUSTID_API_KEY` 内置可用默认值,也可在前端“高级设置 - 媒体”或配置命令中覆盖;本地安装需要系统可执行路径中存在 Chromaprint `fpcalc`,官方 Docker 镜像已内置
- `config describe` 显示单个配置项的类型、默认值和当前值
## Tool 命令

View File

@@ -132,7 +132,7 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
#### 媒体识别 / 整理
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时优先使用 TMDBTMDB 未可靠命中后才并发查询其它内置来源,并按标题、类型、年份和季信息选择最佳兜底结果。音乐自动识别会并发比较全部内置音乐来源。手动操作通过请求级 `source``media_source` + `media_id` 严格指定单一来源,不修改系统默认值,也不会跨来源兜底。
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种影视数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `source``media_source` + `media_id` 明确指定时使用,不修改系统默认值,也不会跨来源兜底。
涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid``doubanid``bangumiid``anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退。
@@ -190,7 +190,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
音乐识别结果同时提供 `audio_format``audio_lossless``audio_quality``bit_depth``sample_rate``bitrate``audio_specs``audio_quality_score`。本地文件识别读取实际音频流参数,站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
音乐识别结果同时提供 `audio_format``audio_lossless``audio_quality``bit_depth``sample_rate``bitrate``audio_specs``audio_quality_score`。本地文件识别读取实际音频流参数,并使用 Chromaprint 的 `fpcalc` 在本地生成指纹后查询 AcoustID音频文件本身不会上传。站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |

View File

@@ -111,7 +111,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
### Media Search (13 endpoints)
When a video recognition request omits `source`, MoviePilot validates TMDB first and only then queries `douban`, `bangumi`, and `anilist` concurrently to score a fallback. Providing `source` or a source-native ID keeps recognition strict to that source. Music recognition without `source` compares all built-in music sources concurrently.
When recognition omits `source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `source` or a source-native ID keeps recognition strict to that manually selected source.
| Method | Path | Description |
|--------|------|-------------|

View File

@@ -0,0 +1,186 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.core.config import ConfigModel
from app.modules.acoustid import AcoustIdModule
from app.utils.http import AsyncRequestUtils, RequestUtils
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
class FakeResponse:
"""提供 AcoustID 模块测试所需的最小 HTTP 响应接口。"""
def __init__(self, payload: dict, status_code: int = 200) -> None:
"""保存响应数据并记录资源是否被关闭。"""
self.payload = payload
self.status_code = status_code
self.closed = False
def json(self) -> dict:
"""返回预设 JSON 响应。"""
return self.payload
def close(self) -> None:
"""记录响应资源已释放。"""
self.closed = True
class FakeAsyncResponse(FakeResponse):
"""提供异步 AcoustID 查询所需的响应关闭接口。"""
async def aclose(self) -> None:
"""记录异步响应资源已释放。"""
self.closed = True
def test_acoustid_api_key_has_built_in_default():
"""系统应内置可用 AcoustID 应用 Key同时允许运行配置覆盖。"""
assert ConfigModel.model_fields["ACOUSTID_API_KEY"].default == "b1auxfOzAg"
def test_identify_music_by_fingerprint_queries_acoustid_and_caches_result(
tmp_path,
monkeypatch,
):
"""有效指纹应请求 recordingids并按文件状态缓存 MusicBrainz ID。"""
audio_path = tmp_path / "track.flac"
audio_path.write_bytes(b"audio")
module = AcoustIdModule()
module._fpcalc_path = "/usr/bin/fpcalc"
fpcalc = Mock(return_value=SimpleNamespace(
returncode=0,
stdout=json.dumps({"duration": 243.4, "fingerprint": "AQADtM..."}),
))
response = FakeResponse({
"status": "ok",
"results": [{
"score": 0.98,
"recordings": [{"id": RECORDING_ID}],
}],
})
post_res = Mock(return_value=response)
monkeypatch.setattr("app.modules.acoustid.subprocess.run", fpcalc)
monkeypatch.setattr(RequestUtils, "post_res", post_res)
monkeypatch.setattr(module, "_wait_for_rate_limit", lambda: None)
monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key")
first = module.identify_music_by_fingerprint(audio_path)
second = module.identify_music_by_fingerprint(audio_path)
assert first == RECORDING_ID
assert second == RECORDING_ID
fpcalc.assert_called_once_with(
["/usr/bin/fpcalc", "-json", str(audio_path)],
capture_output=True,
text=True,
check=False,
timeout=60,
)
post_res.assert_called_once()
assert post_res.call_args.kwargs["url"] == "https://api.acoustid.org/v2/lookup"
assert post_res.call_args.kwargs["data"] == {
"client": "client-key",
"duration": 243,
"fingerprint": "AQADtM...",
"meta": "recordingids",
"format": "json",
}
assert response.closed is True
def test_select_recording_id_requires_high_score_and_valid_uuid():
"""低置信结果和异常外部 ID 不得进入 MusicBrainz 详情查询。"""
payload = {
"status": "ok",
"results": [
{"score": 0.99, "recordings": [{"id": "not-a-uuid"}]},
{"score": 0.91, "recordings": [{"id": RECORDING_ID}]},
{
"score": 0.89,
"recordings": [{"id": "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}],
},
],
}
assert AcoustIdModule._select_recording_id(payload) == RECORDING_ID
assert AcoustIdModule._select_recording_id({
"status": "ok",
"results": [{
"score": 0.89,
"recordings": [{"id": RECORDING_ID}],
}],
}) is None
def test_identify_music_by_fingerprint_skips_missing_fpcalc(tmp_path, monkeypatch):
"""缺少 fpcalc 时应静默跳过指纹层,不发起 AcoustID 请求。"""
audio_path = tmp_path / "track.mp3"
audio_path.write_bytes(b"audio")
module = AcoustIdModule()
post_res = Mock()
monkeypatch.setattr(RequestUtils, "post_res", post_res)
assert module.identify_music_by_fingerprint(Path(audio_path)) is None
post_res.assert_not_called()
def test_async_identify_music_by_fingerprint_uses_async_process_and_http(
tmp_path,
monkeypatch,
):
"""异步接口应异步执行 fpcalc 与 HTTP 查询,并复用同一响应筛选规则。"""
audio_path = tmp_path / "track.flac"
audio_path.write_bytes(b"audio")
module = AcoustIdModule()
module._fpcalc_path = "/usr/bin/fpcalc"
class FakeProcess:
"""模拟已成功执行的异步 fpcalc 子进程。"""
returncode = 0
async def communicate(self):
"""返回 fpcalc JSON 标准输出和空错误输出。"""
payload = json.dumps({
"duration": 243.4,
"fingerprint": "AQADtM...",
})
return payload.encode(), b""
create_process = AsyncMock(return_value=FakeProcess())
response = FakeAsyncResponse({
"status": "ok",
"results": [{
"score": 0.98,
"recordings": [{"id": RECORDING_ID}],
}],
})
post_res = AsyncMock(return_value=response)
wait_rate_limit = AsyncMock()
monkeypatch.setattr(
"app.modules.acoustid.asyncio.create_subprocess_exec",
create_process,
)
monkeypatch.setattr(AsyncRequestUtils, "post_res", post_res)
monkeypatch.setattr(module, "_async_wait_for_rate_limit", wait_rate_limit)
monkeypatch.setattr("app.modules.acoustid.settings.ACOUSTID_API_KEY", "client-key")
result = asyncio.run(module.async_identify_music_by_fingerprint(audio_path))
assert result == RECORDING_ID
create_process.assert_awaited_once_with(
"/usr/bin/fpcalc",
"-json",
str(audio_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
wait_rate_limit.assert_awaited_once()
post_res.assert_awaited_once()
assert post_res.await_args.kwargs["data"]["meta"] == "recordingids"
assert response.closed is True

View File

@@ -87,8 +87,8 @@ def _album() -> MusicInfo:
)
def test_recognize_music_title_uses_media_chain_automatic_sources():
"""Agent 音乐标题识别应进入 MediaChain 自动多源流程,不再固定 MusicBrainz。"""
def test_recognize_music_title_uses_media_chain_primary_source():
"""Agent 音乐标题识别应 MediaChain 自动选择 MusicBrainz 主数据源"""
expected = _recording()
recognize = AsyncMock(return_value=expected)
tool = RecognizeMediaTool(session_id="session-1", user_id="10001")

View File

@@ -14,6 +14,9 @@ from app.core.meta.metamusic import (
from app.helper.audio import AudioMetadataHelper
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
def test_read_audio_metadata_maps_easy_tags(monkeypatch):
"""音频标签和技术参数应映射为 MetaMusic。"""
audio = SimpleNamespace(
@@ -26,6 +29,7 @@ def test_read_audio_metadata_maps_easy_tags(monkeypatch):
"tracknumber": ["8/13"],
"discnumber": ["1/1"],
"isrc": ["USQX91300105"],
"musicbrainz_trackid": [RECORDING_ID],
},
info=SimpleNamespace(
length=369.4,
@@ -49,6 +53,8 @@ def test_read_audio_metadata_maps_easy_tags(monkeypatch):
assert meta.audio_lossless is True
assert meta.audio_quality == "lossless"
assert meta.audio_specs == "FLAC · 16-bit · 44.1 kHz · 1,411 kbps"
assert meta.media_source == "musicbrainz"
assert meta.media_id == RECORDING_ID
def test_parse_declared_hires_audio_quality_from_resource_title():
@@ -144,6 +150,38 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
assert meta.audio_format == "MP3"
def test_read_audio_tags_does_not_fill_from_filename(monkeypatch):
"""标签识别层应只使用标签证据,避免把文件名线索提前混入。"""
audio = SimpleNamespace(
tags={"title": ["Tagged Title"]},
info=SimpleNamespace(length=180),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read_tags(Path("/music/Daft Punk - Get Lucky 2013.flac"))
assert meta.title == "Tagged Title"
assert meta.artists == []
assert meta.year is None
def test_read_audio_tags_ignores_invalid_musicbrainz_id(monkeypatch):
"""异常 MusicBrainz 标签不得进入 ID 详情路径。"""
audio = SimpleNamespace(
tags={
"title": ["Tagged Title"],
"musicbrainz_trackid": ["../../unexpected"],
},
info=SimpleNamespace(length=180),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read_tags(Path("/music/track.flac"))
assert meta.media_source is None
assert meta.media_id is None
def test_remote_path_meta_parses_track_prefix_once(tmp_path):
"""远程或尚未落盘的音频路径应先剥离曲序,不能把 08 误识别成艺术家。"""
audio_path = tmp_path / "Daft Punk - Random Access Memories (2013)" / "08 - Get Lucky.flac"
@@ -245,6 +283,8 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
success = AudioMetadataHelper.write(
Path("/music/08 - Get Lucky.flac"),
MusicInfo(
source="musicbrainz",
media_id=RECORDING_ID,
title="Get Lucky",
artists=["Daft Punk", "Pharrell Williams"],
album="Random Access Memories",
@@ -261,6 +301,7 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
assert audio.tags["title"] == ["Get Lucky"]
assert audio.tags["artist"] == ["Daft Punk", "Pharrell Williams"]
assert audio.tags["tracknumber"] == ["8/13"]
assert audio.tags["musicbrainz_trackid"] == [RECORDING_ID]
def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch):

View File

@@ -1,3 +1,6 @@
import asyncio
from unittest.mock import AsyncMock, Mock
import pytest
from app.chain.media import MediaChain
@@ -164,6 +167,60 @@ def test_recognize_album_directory_maps_files(tmp_path, music_chain, monkeypatch
assert calls["count"] == 1
def test_async_recognize_album_directory_calls_async_module(
tmp_path,
music_chain,
monkeypatch,
):
"""异步目录识别应直接调用模块异步接口,并兼容单个专辑返回值。"""
album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
album_dir.mkdir()
files = []
for index, (name, _length) in enumerate(ALBUM_TRACKS):
file = album_dir / f"{index + 1:02d}.{name}.wav"
file.write_bytes(b"RIFF")
files.append(file)
album = MusicAlbumInfo(
source="musicbrainz",
media_id="rg-1",
title="七里香",
artists=["周杰伦"],
tracks=[
MusicInfo(
source="musicbrainz",
media_id=f"rec-{index + 1}",
title=name,
artists=["周杰伦"],
album="七里香",
track_number=index + 1,
duration=length,
)
for index, (name, length) in enumerate(ALBUM_TRACKS)
],
)
async_run_module = AsyncMock(return_value=album)
run_module = Mock(side_effect=AssertionError("异步目录识别不应调用同步模块接口"))
monkeypatch.setattr(music_chain, "async_run_module", async_run_module)
monkeypatch.setattr(music_chain, "run_module", run_module)
monkeypatch.setattr(
music_chain,
"_read_album_path_metas",
lambda _files: _local_tracks(),
)
matched = asyncio.run(music_chain.async_recognize_album_directory(album_dir))
assert [matched[str(file.resolve())].media_id for file in files] == [
"rec-1",
"rec-2",
"rec-3",
]
async_run_module.assert_awaited_once()
assert async_run_module.await_args.args == ("async_match_music_album",)
run_module.assert_not_called()
def test_recognize_album_directory_skips_single_file(tmp_path, music_chain, monkeypatch):
"""单文件目录不走专辑匹配,交给单曲识别链路。"""
album_dir = tmp_path / "单曲"
@@ -240,3 +297,32 @@ def test_recognize_music_by_path_falls_back_to_album_match(tmp_path, monkeypatch
assert info.album == "七里香"
# 本地音频参数应保留在识别结果中
assert meta.audio_format == "WAV"
def test_async_music_album_fallback_calls_async_directory_match(tmp_path, monkeypatch):
"""异步路径回退应等待目录异步识别,不得调用同步目录识别。"""
album_dir = tmp_path / "七里香"
album_dir.mkdir()
file = album_dir / "01.我的地盘.wav"
file.write_bytes(b"RIFF")
matched_info = MusicInfo(
source="musicbrainz",
media_id="rec-1",
title="我的地盘",
)
async_recognize = AsyncMock(
return_value={str(file.resolve()): matched_info}
)
sync_recognize = Mock(side_effect=AssertionError("异步回退不应调用同步目录识别"))
monkeypatch.setattr(
MusicChain,
"async_recognize_album_directory",
async_recognize,
)
monkeypatch.setattr(MusicChain, "recognize_album_directory", sync_recognize)
result = asyncio.run(MediaChain._async_music_album_dir_fallback(file))
assert result is matched_info
async_recognize.assert_awaited_once_with(album_dir)
sync_recognize.assert_not_called()

View File

@@ -1,10 +1,10 @@
from pathlib import Path
from unittest.mock import AsyncMock, Mock
from app.chain.media import MediaChain
from app.chain.music import MusicChain
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
@@ -311,7 +311,17 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
)
chain = MediaChain()
recognize = AsyncMock(return_value=info)
monkeypatch.setattr(AudioMetadataHelper, "read", lambda path: meta)
filename_meta = MetaMusic(title="02. 眼泪成诗")
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(meta, meta, filename_meta)),
)
monkeypatch.setattr(
MusicChain,
"async_identify_by_fingerprint",
AsyncMock(return_value=None),
)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
import asyncio
@@ -322,59 +332,42 @@ 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=None)
recognize.assert_awaited_once_with(
meta=meta,
source=None,
music_type="recording",
)
def test_recognize_best_compares_all_sources_and_prefers_stronger_evidence(monkeypatch):
"""自动识别应查询全部来源,并让专辑、时长和曲序证据更完整的候选胜出"""
def test_recognize_best_only_queries_musicbrainz(monkeypatch):
"""自动音乐识别只应调用 MusicBrainz 主数据源"""
chain = MusicChain()
meta = MetaMusic(
title="Yellow",
artists=["Coldplay"],
album="Parachutes",
duration=269,
track_number=5,
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
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)
expected = MusicInfo(
source="musicbrainz",
media_id="mb-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
recognize_source = Mock(return_value=expected)
monkeypatch.setattr(chain, "recognize_from_source", recognize_source)
result = chain.recognize_best(meta)
assert requested == ["musicbrainz", "theaudiodb", "doubanmusic"]
assert result is candidates["theaudiodb"]
assert result is expected
recognize_source.assert_called_once_with(
source="musicbrainz",
meta=meta,
cache=True,
music_type="recording",
)
def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
@@ -414,76 +407,92 @@ def test_recognize_from_source_selects_only_declared_music_module(monkeypatch):
)
def test_recognize_best_uses_source_order_only_for_equal_scores(monkeypatch):
"""候选证据完全相同时应按 MusicBrainz、TheAudioDB、豆瓣音乐顺序稳定选择"""
def test_recognize_best_does_not_fallback_after_musicbrainz_miss(monkeypatch):
"""MusicBrainz 未命中时自动识别不得继续请求其它音乐来源"""
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],
)
recognize_source = Mock(return_value=None)
monkeypatch.setattr(chain, "recognize_from_source", recognize_source)
assert chain.recognize_best(meta) is candidates["musicbrainz"]
assert chain.recognize_best(meta) is None
recognize_source.assert_called_once()
assert recognize_source.call_args.kwargs["source"] == "musicbrainz"
def test_async_recognize_best_queries_sources_concurrently(monkeypatch):
"""异步自动识别应并发查询各来源,而不是串行等待三个远端请求"""
def test_async_recognize_best_only_queries_musicbrainz(monkeypatch):
"""异步自动音乐识别也只应调用 MusicBrainz 主数据源"""
import asyncio
chain = MusicChain()
active = 0
max_active = 0
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
recognize_source = AsyncMock(return_value=expected)
monkeypatch.setattr(chain, "async_recognize_from_source", recognize_source)
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=["周杰伦"],
)
result = asyncio.run(chain.async_recognize_best(meta))
assert result is expected
recognize_source.assert_awaited_once_with(
source="musicbrainz",
meta=meta,
cache=True,
music_type="recording",
)
def test_async_recognize_from_source_calls_module_async_method(monkeypatch):
"""单源异步识别必须直接等待模块异步入口。"""
chain = MusicChain()
expected = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
)
module = Mock()
module.async_recognize_media = AsyncMock(return_value=expected)
module.recognize_media = Mock(
side_effect=AssertionError("异步识别不应调用同步模块方法")
)
monkeypatch.setattr(
chain,
"_async_recognize_from_source",
fake_async_recognize_source,
"_music_recognize_module",
Mock(return_value=module),
)
result = asyncio.run(
chain.async_recognize_best(MetaMusic(title="晴天", artists=["周杰伦"]))
import asyncio
result = asyncio.run(chain._async_recognize_from_source(
MetaMusic(title="晴天"),
"musicbrainz",
True,
))
assert result is expected
module.async_recognize_media.assert_awaited_once()
module.recognize_media.assert_not_called()
def test_async_identify_by_fingerprint_uses_async_module_contract(monkeypatch):
"""指纹异步链路应请求模块的异步方法名。"""
chain = MusicChain()
async_run_module = AsyncMock(return_value="recording-1")
monkeypatch.setattr(chain, "async_run_module", async_run_module)
import asyncio
result = asyncio.run(chain.async_identify_by_fingerprint("/music/track.flac"))
assert result == "recording-1"
async_run_module.assert_awaited_once_with(
"async_identify_music_by_fingerprint",
path=Path("/music/track.flac"),
)
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):
"""热门专辑探索应把实体类型透传给 ListenBrainz 榜单模块。"""

View File

@@ -15,6 +15,7 @@ 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.theaudiodb import TheAudioDbModule
from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaType
@@ -103,10 +104,6 @@ def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain(
def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
"""本地音频识别应使用内嵌标签补全艺术家、专辑并保留音频质量参数。"""
from unittest.mock import AsyncMock
from app.helper.audio import AudioMetadataHelper
audio_path = tmp_path / "02. 眼泪成诗.m4a"
audio_path.write_bytes(b"audio")
meta = MetaMusic(
@@ -119,7 +116,17 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa
info = _music_info()
chain = MediaChain()
recognize = AsyncMock(return_value=info)
monkeypatch.setattr(AudioMetadataHelper, "read", lambda path: meta)
filename_meta = MetaMusic(title="02. 眼泪成诗")
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(meta, meta, filename_meta)),
)
monkeypatch.setattr(
MusicChain,
"async_identify_by_fingerprint",
AsyncMock(return_value=None),
)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
recognized_meta, recognized_info = asyncio.run(
@@ -128,7 +135,150 @@ 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=None)
recognize.assert_awaited_once_with(
meta=meta,
source=None,
music_type="recording",
)
def test_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch):
"""AcoustID 命中后应按 MBID 直查,且不再执行标签和文件名匹配。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
merged = MetaMusic(title="Get Lucky", audio_format="FLAC")
tag_meta = MetaMusic(title="Tagged Title")
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
)
chain = MediaChain()
direct = Mock(return_value=expected)
later_tier = Mock()
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(merged, tag_meta, filename_meta)),
)
monkeypatch.setattr(
MusicChain,
"identify_by_fingerprint",
Mock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", later_tier)
recognized_meta, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_meta is merged
assert recognized_info is expected
direct.assert_called_once_with(merged, recording_id)
later_tier.assert_not_called()
def test_recognize_music_by_path_tag_mbid_skips_multi_source_matching(monkeypatch):
"""指纹未命中但标签含 MBID 时应直查详情,不进入多来源标题匹配。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
tag_meta = MetaMusic(
title="Tagged Title",
media_source="musicbrainz",
media_id=recording_id,
)
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
source="musicbrainz",
media_id=recording_id,
title="Tagged Title",
)
chain = MediaChain()
direct = Mock(return_value=expected)
generic = Mock()
tier = Mock(wraps=chain._recognize_music_meta_tier)
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(MusicChain, "identify_by_fingerprint", Mock(return_value=None))
monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "recognize_media", generic)
monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier)
_, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_info is expected
direct.assert_called_once_with(meta=tag_meta, recording_id=recording_id)
generic.assert_not_called()
assert tier.call_count == 1
assert tier.call_args.kwargs["tier_name"] == "文件标签"
def test_recognize_music_by_path_falls_back_from_tags_to_filename(monkeypatch):
"""标签层未获得远端身份时应继续使用文件名层,且顺序不可反转。"""
tag_meta = MetaMusic(title="Tagged Title")
filename_meta = MetaMusic(title="Filename Title")
expected = MusicInfo(
source="musicbrainz",
media_id="recording-from-filename",
title="Filename Title",
)
chain = MediaChain()
recognize = Mock(side_effect=[MusicInfo(title="Offline Tag"), expected])
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(tag_meta, tag_meta, filename_meta)),
)
monkeypatch.setattr(MusicChain, "identify_by_fingerprint", Mock(return_value=None))
monkeypatch.setattr(chain, "recognize_media", recognize)
_, recognized_info = chain.recognize_music_by_path("track.flac")
assert recognized_info is expected
assert [call.kwargs["meta"] for call in recognize.call_args_list] == [
tag_meta,
filename_meta,
]
assert all(
call.kwargs["music_type"] == "recording"
for call in recognize.call_args_list
)
def test_async_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch):
"""异步路径也应在 AcoustID 命中后直查 MBID 并停止后续层级。"""
recording_id = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
merged = MetaMusic(title="Get Lucky")
expected = MusicInfo(
source="musicbrainz",
media_id=recording_id,
title="Get Lucky",
)
chain = MediaChain()
direct = AsyncMock(return_value=expected)
later_tier = AsyncMock()
monkeypatch.setattr(
MusicChain,
"read_path_evidence",
Mock(return_value=(merged, MetaMusic(), MetaMusic())),
)
monkeypatch.setattr(
MusicChain,
"async_identify_by_fingerprint",
AsyncMock(return_value=recording_id),
)
monkeypatch.setattr(chain, "_async_recognize_musicbrainz_recording", direct)
monkeypatch.setattr(chain, "_async_recognize_music_meta_tier", later_tier)
recognized_meta, recognized_info = asyncio.run(
chain.async_recognize_music_by_path("track.flac")
)
assert recognized_meta is merged
assert recognized_info is expected
direct.assert_awaited_once_with(merged, recording_id)
later_tier.assert_not_awaited()
def test_musicbrainz_module_recognize_media_ignores_non_music():
@@ -355,22 +505,49 @@ def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypat
def test_musicbrainz_module_async_recognize_media(monkeypatch):
"""异步模块入口应在线程池中调用同步实现"""
"""异步 MusicBrainz 识别应直接调用异步检索而不进入同步入口"""
module = MusicBrainzModule()
expected = _music_info()
sync_mock = Mock(return_value=expected)
async_search = AsyncMock(return_value=[expected])
sync_mock = Mock(side_effect=AssertionError("异步识别不应调用同步入口"))
monkeypatch.setattr(module, "_async_search_recordings", async_search)
monkeypatch.setattr(module, "recognize_media", sync_mock)
result = asyncio.run(module.async_recognize_media(
meta=MetaMusic(title="晴天"), mtype=MediaType.MUSIC
))
sync_mock.assert_called_once()
async_search.assert_awaited_once()
sync_mock.assert_not_called()
assert result is expected
def test_theaudiodb_module_async_recognize_media(monkeypatch):
"""异步 TheAudioDB 识别应直接调用异步检索而不进入同步入口。"""
module = TheAudioDbModule()
expected = MusicInfo(
source="theaudiodb",
media_id="track-1",
title="晴天",
)
async_search = AsyncMock(return_value=[expected])
sync_mock = Mock(side_effect=AssertionError("异步识别不应调用同步入口"))
monkeypatch.setattr(module, "_async_search_tracks", async_search)
monkeypatch.setattr(module, "recognize_media", sync_mock)
result = asyncio.run(module.async_recognize_media(
meta=MetaMusic(title="晴天"),
mtype=MediaType.MUSIC,
source="theaudiodb",
))
async_search.assert_awaited_once()
sync_mock.assert_not_called()
assert result is expected
def test_chain_recognize_media_returns_musicinfo_and_reports_share():
"""自动多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
"""MusicBrainz 自动识别返回的 MusicInfo 应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(MusicChain, "recognize_best", return_value=expected), patch(
@@ -384,7 +561,7 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share():
def test_chain_async_recognize_media_returns_musicinfo_and_reports_share():
"""异步多源识别最终选出的 MusicInfo 应只上报一次共享识别。"""
"""异步 MusicBrainz 自动识别结果应只上报一次共享识别。"""
expected = _music_info()
chain = MediaChain()
with patch.object(

View File

@@ -1,57 +1,29 @@
"""影视自动识别的 TMDB 优先和多源兜底测试。"""
"""影视自动识别主数据源路由回归测试。"""
import asyncio
from threading import Barrier
from typing import Optional
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.meta import MetaBase
from app.schemas.types import MediaType
def _video_meta(
title: str = "流浪地球",
year: str = "2019",
mtype: MediaType = MediaType.MOVIE,
) -> MetaInfo:
"""构造包含明确标题、年份和类型的影视解析信息。"""
meta = MetaInfo(title)
meta.year = year
meta.type = mtype
def _video_meta() -> MetaBase:
"""构造未指定数据源的电影元数据。"""
meta = MetaBase("流浪地球 2019")
meta.name = "流浪地球"
meta.year = "2019"
meta.type = MediaType.MOVIE
return meta
def _video_info(
source: str,
media_id: int | str,
title: str = "流浪地球",
year: str = "2019",
mtype: MediaType = MediaType.MOVIE,
**kwargs,
) -> MediaInfo:
"""构造带指定内置来源原生身份的标准影视信息。"""
identity_fields = {
"themoviedb": {"tmdb_id": int(media_id)},
"douban": {"douban_id": str(media_id)},
"bangumi": {"bangumi_id": int(media_id)},
"anilist": {"anilist_id": int(media_id)},
}
return MediaInfo(
source=source,
type=mtype,
title=title,
year=year,
**identity_fields[source],
**kwargs,
)
def _module_kwargs(meta: MetaInfo, source: str | None = None) -> dict:
"""构造原生识别路由需要的最小参数。"""
def _module_kwargs(meta: MetaBase, source: Optional[str] = None) -> dict:
"""构造原生识别路由使用的模块参数。"""
return {
"meta": meta,
"mtype": meta.type,
"mtype": MediaType.MOVIE,
"source": source,
"mediaid": None,
"tmdbid": None,
@@ -63,160 +35,94 @@ def _module_kwargs(meta: MetaInfo, source: str | None = None) -> dict:
}
def test_video_auto_recognize_stops_after_reliable_tmdb(monkeypatch) -> None:
"""TMDB 可靠命中时不得查询任何影视副源。"""
def test_video_auto_recognize_only_uses_tmdb(monkeypatch) -> None:
"""未指定影视来源时必须固定委托 TMDB失败后也不切换其它来源。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
tmdb = _video_info("themoviedb", 550, names=["The Wandering Earth"])
calls = []
def recognize_source(module_kwargs, source, cache):
"""记录单源调用并仅返回 TMDB 测试结果。"""
calls.append((module_kwargs, source, cache))
return tmdb if source == "themoviedb" else None
monkeypatch.setattr(chain, "_recognize_video_from_source", recognize_source)
result = chain._run_native_media_recognize(_module_kwargs(meta), cache=True)
assert result is tmdb
assert [call[1] for call in calls] == ["themoviedb"]
def test_video_auto_recognize_concurrently_scores_fallback_sources(monkeypatch) -> None:
"""TMDB 低置信时应并发查询全部副源,并返回评分最高的候选。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
fallback_barrier = Barrier(3, timeout=2)
candidates = {
"douban": _video_info("douban", "26266893"),
"bangumi": _video_info(
"bangumi", 302875, mtype=MediaType.TV
),
"anilist": _video_info(
"anilist", 105333, title="流浪星球"
),
}
called_sources = []
def recognize_source(module_kwargs, source, cache):
"""使用线程屏障证明三个副源不是串行执行。"""
del module_kwargs, cache
called_sources.append(source)
if source == "themoviedb":
return _video_info(
"themoviedb", 999, title="完全不同的电影"
)
fallback_barrier.wait()
return candidates[source]
monkeypatch.setattr(chain, "_recognize_video_from_source", recognize_source)
result = chain._run_native_media_recognize(_module_kwargs(meta), cache=True)
assert result is candidates["douban"]
assert called_sources[0] == "themoviedb"
assert set(called_sources[1:]) == {"douban", "bangumi", "anilist"}
def test_async_video_auto_recognize_concurrently_scores_fallback_sources(
monkeypatch,
) -> None:
"""异步自动识别应在 TMDB 失败后并发等待全部影视副源。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
candidates = {
source: _video_info(source, index)
for index, source in enumerate(("douban", "bangumi", "anilist"), start=1)
}
started_sources = set()
all_started = asyncio.Event()
async def recognize_source(module_kwargs, source, cache):
"""等三个副源均开始后再放行,串行实现会触发超时。"""
del module_kwargs, cache
if source == "themoviedb":
return None
started_sources.add(source)
if len(started_sources) == 3:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=1)
return candidates[source]
monkeypatch.setattr(
chain,
"_async_recognize_video_from_source",
recognize_source,
)
result = asyncio.run(
chain._async_run_native_media_recognize(_module_kwargs(meta), cache=True)
)
assert result is candidates["douban"]
assert started_sources == {"douban", "bangumi", "anilist"}
def test_video_candidate_score_uses_requested_season_year() -> None:
"""电视剧应使用请求季年份,而不是整部剧首播年份进行可信度判断。"""
meta = _video_meta("测试剧", "2024", MediaType.TV)
meta.begin_season = 2
candidate = _video_info(
"themoviedb",
42,
title="测试剧",
year="2020",
mtype=MediaType.TV,
seasons={1: [1], 2: [1]},
season_years={1: "2020", 2: "2024"},
)
score = MediaChain._video_candidate_score(meta, candidate, MediaType.TV)
assert score == 100
def test_video_candidate_score_rejects_type_and_year_conflicts() -> None:
"""类型冲突或明显年份冲突必须在进入副源排名前淘汰。"""
meta = _video_meta()
wrong_type = _video_info(
"bangumi", 1, mtype=MediaType.TV
)
wrong_year = _video_info(
"douban", 2, year="2023"
)
assert MediaChain._video_candidate_score(meta, wrong_type) is None
assert MediaChain._video_candidate_score(meta, wrong_year) is None
def test_video_explicit_source_bypasses_automatic_fallback(monkeypatch) -> None:
"""显式影视来源应继续走通用严格单源分发,不进入自动策略。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
expected = _video_info("douban", 26266893)
generic_calls = []
def generic_recognize(_self, module_kwargs, cache):
"""记录父类通用分发调用并返回显式来源结果"""
generic_calls.append((module_kwargs, cache))
return expected
def unexpected_auto(*_args, **_kwargs):
"""显式来源误入自动策略时立即使测试失败。"""
raise AssertionError("显式来源不应进入自动影视识别")
"""记录父类模块分发参数并模拟 TMDB 未命中"""
calls.append((module_kwargs, cache))
return None
monkeypatch.setattr(
ChainBase,
"_run_native_media_recognize",
generic_recognize,
)
monkeypatch.setattr(chain, "_recognize_video_best", unexpected_auto)
result = chain._run_native_media_recognize(
_module_kwargs(meta, source="douban"),
_module_kwargs(_video_meta()),
cache=True,
)
assert result is None
assert len(calls) == 1
assert calls[0][0]["source"] == "themoviedb"
def test_async_video_auto_recognize_only_uses_tmdb(monkeypatch) -> None:
"""异步未指定影视来源时也只委托 TMDB 原生识别入口。"""
chain = object.__new__(MediaChain)
expected = MediaInfo(
source="themoviedb",
media_id="550",
tmdb_id=550,
title="流浪地球",
year="2019",
type=MediaType.MOVIE,
)
calls = []
async def generic_recognize(_self, module_kwargs, cache):
"""记录异步父类模块分发参数并返回 TMDB 结果。"""
calls.append((module_kwargs, cache))
return expected
monkeypatch.setattr(
ChainBase,
"_async_run_native_media_recognize",
generic_recognize,
)
result = asyncio.run(chain._async_run_native_media_recognize(
_module_kwargs(_video_meta()),
cache=True,
))
assert result is expected
assert len(calls) == 1
assert calls[0][0]["source"] == "themoviedb"
def test_video_explicit_source_is_preserved(monkeypatch) -> None:
"""手工指定影视来源时必须保持严格单源分发。"""
chain = object.__new__(MediaChain)
expected = MediaInfo(
source="douban",
media_id="26266893",
douban_id="26266893",
title="流浪地球",
year="2019",
type=MediaType.MOVIE,
)
calls = []
def generic_recognize(_self, module_kwargs, cache):
"""记录显式来源并返回预设结果。"""
calls.append((module_kwargs, cache))
return expected
monkeypatch.setattr(
ChainBase,
"_run_native_media_recognize",
generic_recognize,
)
result = chain._run_native_media_recognize(
_module_kwargs(_video_meta(), source="douban"),
cache=True,
)
assert result is expected
assert generic_calls[0][0]["source"] == "douban"
assert calls[0][0]["source"] == "douban"