mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-12 17:24:34 +08:00
feat(media): refine primary-source recognition
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 标签中的当前编号和总数。"""
|
||||
|
||||
389
app/modules/acoustid/__init__.py
Normal file
389
app/modules/acoustid/__init__.py
Normal 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
|
||||
@@ -63,7 +63,7 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
"""返回音乐识别使用的数据源标识。"""
|
||||
return DoubanModule._music_source
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -498,6 +498,8 @@ class OtherModulesType(Enum):
|
||||
ListenBrainz = "ListenBrainz"
|
||||
# LRCLIB 歌词
|
||||
Lrclib = "LRCLIB"
|
||||
# AcoustID 音频指纹
|
||||
AcoustId = "AcoustID"
|
||||
|
||||
|
||||
class NameValueEnum(Enum):
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 命令
|
||||
|
||||
@@ -132,7 +132,7 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
|
||||
|
||||
#### 媒体识别 / 整理
|
||||
|
||||
媒体识别、搜索和手动整理内置支持 `themoviedb`、`douban`、`bangumi`、`anilist` 四种数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时优先使用 TMDB;TMDB 未可靠命中后才并发查询其它内置来源,并按标题、类型、年份和季信息选择最佳兜底结果。音乐自动识别会并发比较全部内置音乐来源。手动操作可通过请求级 `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。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
@@ -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 |
|
||||
|--------|------|-------------|
|
||||
|
||||
186
tests/test_acoustid_module.py
Normal file
186
tests/test_acoustid_module.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 榜单模块。"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user