refactor(media): unify path search lyrics decisions

This commit is contained in:
jxxghp
2026-08-29 20:10:42 +08:00
parent 7e8c55bfd9
commit 793d4bed8b
4 changed files with 1100 additions and 277 deletions

View File

@@ -1,76 +1,178 @@
"""歌词候选聚合与择优处理链。"""
import time
from typing import Any, Optional, Union
from dataclasses import dataclass
from enum import Enum
from typing import Any, Generator, Optional, Union, cast
from app.chain.base import ChainBase
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
class _LyricsAction(Enum):
"""歌词状态机允许调用的模块接口。"""
LEGACY = "music_lyrics"
CANDIDATES = "music_lyrics_candidates"
@dataclass(frozen=True, slots=True)
class _LyricsRequest:
"""保存歌词聚合的初始候选、截止状态和本地兜底。"""
candidates: tuple[MusicLyrics, ...]
fallback: Optional[MusicLyrics]
budget_exceeded: bool
@dataclass(frozen=True, slots=True)
class _LyricsOutcome:
"""保存聚合后的稳定候选顺序及预算状态。"""
candidates: tuple[MusicLyrics, ...]
budget_exceeded: bool = False
def _normalize_lyrics_results(value: Any) -> list[MusicLyrics]:
"""把模块的单结果、列表或字典结果统一转换为候选列表。"""
values = value if isinstance(value, list) else [value]
normalized = []
for item in values:
if isinstance(item, MusicLyrics):
normalized.append(item)
elif isinstance(item, dict):
normalized.append(MusicLyrics.from_dict(item))
return normalized
def _deduplicate_lyrics(candidates: list[MusicLyrics]) -> list[MusicLyrics]:
"""按来源身份和内容去重,并保留首次出现位置。"""
selected: dict[tuple[str, str, str], MusicLyrics] = {}
for candidate in candidates:
if not candidate or not (candidate.content or candidate.instrumental):
continue
current = selected.get(candidate.identity_key)
if current is None or (
candidate.match_score,
candidate.quality_rank,
candidate.provider_priority,
) > (
current.match_score,
current.quality_rank,
current.provider_priority,
):
selected[candidate.identity_key] = candidate
return list(selected.values())
def _build_lyrics_request(
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]],
deadline: Optional[float],
) -> _LyricsRequest:
"""构造一次歌词聚合请求,截止时间只在进入模块调用前判定。"""
fallback_text = str(getattr(music, "lyrics", None) or "").strip()
fallback = (
MusicLyrics(
provider="theaudiodb",
plain_lyrics=fallback_text,
match_score=85,
provider_priority=10,
)
if fallback_text
else None
)
return _LyricsRequest(
candidates=tuple(local_candidates or ()),
fallback=fallback,
budget_exceeded=deadline is not None and time.monotonic() >= deadline,
)
def _lyrics_plan(
request: _LyricsRequest,
) -> Generator[_LyricsAction, Any, _LyricsOutcome]:
"""按旧接口、新接口和本地兜底的唯一顺序聚合歌词。"""
candidates = list(request.candidates)
if request.budget_exceeded:
return _LyricsOutcome(
candidates=tuple(_deduplicate_lyrics(candidates)),
budget_exceeded=True,
)
legacy = yield _LyricsAction.LEGACY
candidates.extend(_normalize_lyrics_results(legacy))
results = yield _LyricsAction.CANDIDATES
candidates.extend(_normalize_lyrics_results(results))
if request.fallback:
candidates.append(request.fallback)
return _LyricsOutcome(candidates=tuple(_deduplicate_lyrics(candidates)))
class LyricsChain(ChainBase):
"""聚合本地、插件和宿主歌词候选,并按匹配度与内容质量择优。"""
def get_music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> list[MusicLyrics]:
"""获取所有标准化歌词候选,同时兼容旧版单结果插件接口。"""
candidates = list(local_candidates or [])
if self.deadline is not None and time.monotonic() >= self.deadline:
request = _build_lyrics_request(music, local_candidates, self.deadline)
plan = _lyrics_plan(request)
outcome = _LyricsOutcome(candidates=())
try:
action = next(plan)
while True:
value = (
self.run_module("music_lyrics", music=music)
if action is _LyricsAction.LEGACY
else self.run_module("music_lyrics_candidates", music=music)
)
action = plan.send(value)
except StopIteration as completed:
outcome = cast(_LyricsOutcome, completed.value)
if outcome.budget_exceeded:
self.budget_exceeded = True
return self._deduplicate(candidates)
legacy = self.run_module("music_lyrics", music=music)
candidates.extend(self._normalize_results(legacy))
results = self.run_module("music_lyrics_candidates", music=music)
candidates.extend(self._normalize_results(results))
fallback = str(getattr(music, "lyrics", None) or "").strip()
if fallback:
candidates.append(MusicLyrics(
provider="theaudiodb",
plain_lyrics=fallback,
match_score=85,
provider_priority=10,
))
return self._deduplicate(candidates)
return list(outcome.candidates)
def get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> Optional[MusicLyrics]:
"""返回匹配可信且质量最高的歌词候选。"""
candidates = self.get_music_lyrics_candidates(music, local_candidates)
return self._select_best(candidates)
async def async_get_music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> list[MusicLyrics]:
"""异步聚合新旧模块候选,并复用同步路径的本地兜底规则。"""
candidates = list(local_candidates or [])
if self.deadline is not None and time.monotonic() >= self.deadline:
request = _build_lyrics_request(music, local_candidates, self.deadline)
plan = _lyrics_plan(request)
outcome = _LyricsOutcome(candidates=())
try:
action = next(plan)
while True:
value = (
await self.async_run_module("music_lyrics", music=music)
if action is _LyricsAction.LEGACY
else await self.async_run_module("music_lyrics_candidates", music=music)
)
action = plan.send(value)
except StopIteration as completed:
outcome = cast(_LyricsOutcome, completed.value)
if outcome.budget_exceeded:
self.budget_exceeded = True
return self._deduplicate(candidates)
legacy = await self.async_run_module("music_lyrics", music=music)
candidates.extend(self._normalize_results(legacy))
results = await self.async_run_module("music_lyrics_candidates", music=music)
candidates.extend(self._normalize_results(results))
fallback = str(getattr(music, "lyrics", None) or "").strip()
if fallback:
candidates.append(MusicLyrics(
provider="theaudiodb",
plain_lyrics=fallback,
match_score=85,
provider_priority=10,
))
return self._deduplicate(candidates)
return list(outcome.candidates)
async def async_get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> Optional[MusicLyrics]:
"""异步返回质量最高的标准歌词候选。"""
candidates = await self.async_get_music_lyrics_candidates(music, local_candidates)
@@ -90,37 +192,6 @@ class LyricsChain(ChainBase):
),
)
@classmethod
def _normalize_results(cls, value: Any) -> list[MusicLyrics]:
"""把模块的单结果、列表或字典结果统一转换为候选列表。"""
values = value if isinstance(value, list) else [value]
normalized = []
for item in values:
if isinstance(item, MusicLyrics):
normalized.append(item)
elif isinstance(item, dict):
normalized.append(MusicLyrics.from_dict(item))
return normalized
@staticmethod
def _deduplicate(candidates: list[MusicLyrics]) -> list[MusicLyrics]:
"""按来源身份和内容去重,重复项保留质量与匹配度更高的一条。"""
selected: dict[tuple[str, str, str], MusicLyrics] = {}
for candidate in candidates:
if not candidate or not (candidate.content or candidate.instrumental):
continue
current = selected.get(candidate.identity_key)
if current is None or (
candidate.match_score,
candidate.quality_rank,
candidate.provider_priority,
) > (
current.match_score,
current.quality_rank,
current.provider_priority,
):
selected[candidate.identity_key] = candidate
return list(selected.values())
def __init__(self, deadline: Optional[float] = None) -> None:
"""保存批次查询截止时间,防止单张专辑长期占用刮削任务。"""
super().__init__()

View File

@@ -1,7 +1,9 @@
"""音频证据、单曲层级与统一路径识别 owner。"""
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional, Tuple, Union
from typing import Any, Generator, Optional, Tuple, TypeGuard, Union, cast
from app.application.audio import AudioMetadataHelper
from app.application.configuration import get_chain_runtime_config_snapshot
@@ -9,6 +11,7 @@ from app.chain.acoustid import AcoustIdChain
from app.chain.media.contract import _MediaOwnerBase
from app.domain.context import (
Context,
MediaInfo,
MusicInfo,
)
from app.domain.media import is_music_media_source
@@ -28,6 +31,206 @@ def _is_regular_file(path: Path) -> bool:
return path.exists() and path.is_file()
class _MusicTierActionKind(Enum):
"""音乐证据层允许执行的外部识别动作。"""
DIRECT = "direct"
SEARCH = "search"
@dataclass(frozen=True, slots=True)
class _MusicTierAction:
"""描述一次音乐证据层 I/O,不携带可变执行状态。"""
kind: _MusicTierActionKind
meta: MetaMusic
recording_id: Optional[str] = None
@dataclass(frozen=True, slots=True)
class _MusicTierOutcome:
"""保存证据层命中结果及其日志说明。"""
info: Optional[MusicInfo] = None
message: Optional[str] = None
class _MusicPathActionKind(Enum):
"""音乐路径识别状态机的稳定动作顺序。"""
FINGERPRINT = "fingerprint"
TAG = "tag"
FILENAME = "filename"
ALBUM = "album"
@dataclass(frozen=True, slots=True)
class _MusicPathAction:
"""描述音乐路径状态机的一层识别动作。"""
kind: _MusicPathActionKind
meta: Optional[MetaMusic] = None
tier_name: Optional[str] = None
class _PathRoute(Enum):
"""统一路径识别入口的业务路由。"""
MUSIC = "music"
VIDEO = "video"
@dataclass(frozen=True, slots=True)
class _PathRecognitionRequest:
"""保存同步和异步路径识别共同使用的请求参数。"""
path: str
route: _PathRoute
media_source: Optional[MediaSource]
episode_group: Optional[str]
obtain_images: bool
def _has_remote_music_identity(
info: Optional[MusicInfo],
) -> TypeGuard[MusicInfo]:
"""判断音乐结果是否已获得可终止回退的远程身份。"""
return bool(info and info.media_source and info.media_id)
def _musicbrainz_recording_meta(meta: MetaMusic, recording_id: str) -> MetaMusic:
"""构造 MusicBrainz Recording 直查使用的独立身份副本。"""
identity_meta = MetaMusic.from_dict(meta.to_dict())
identity_meta.media_source = MediaSource.MusicBrainz
identity_meta.media_id = recording_id
return identity_meta
def _without_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
def _merge_music_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo:
"""将本地文件的实际音频参数合并到音乐识别结果。"""
for key in (
"audio_format",
"audio_lossless",
"bit_depth",
"sample_rate",
"bitrate",
):
value = getattr(meta, key, None)
if value is not None:
setattr(info, key, value)
return info
def _finalize_music_path_info(
meta: MetaMusic,
info: Optional[MusicInfo],
) -> MusicInfo:
"""统一远端命中和本地兜底的音频质量合并。"""
return _merge_music_audio_quality(info or MusicInfo.from_meta(meta), meta)
def _music_tier_plan(
meta: Optional[MetaMusic],
media_source: Optional[MediaSource],
tier_name: str,
) -> Generator[_MusicTierAction, Optional[MusicInfo], _MusicTierOutcome]:
"""按 MBID 直查再标题搜索的固定顺序生成证据层动作。"""
if not meta:
return _MusicTierOutcome()
normalized_source = normalize_media_source(media_source)
search_meta = meta
if meta.media_source == MediaSource.MusicBrainz and meta.media_id:
if normalized_source in (None, MediaSource.MusicBrainz):
direct = yield _MusicTierAction(
kind=_MusicTierActionKind.DIRECT,
meta=meta,
recording_id=str(meta.media_id),
)
if _has_remote_music_identity(direct):
return _MusicTierOutcome(
info=direct,
message=f"音乐识别命中{tier_name}层 MusicBrainz ID 直查",
)
search_meta = _without_music_identity(meta)
if not search_meta.title:
return _MusicTierOutcome()
result = yield _MusicTierAction(
kind=_MusicTierActionKind.SEARCH,
meta=search_meta,
)
if _has_remote_music_identity(result):
return _MusicTierOutcome(
info=result,
message=f"音乐识别命中{tier_name}层:{result.title}",
)
return _MusicTierOutcome()
def _music_path_plan(
tag_meta: Optional[MetaMusic],
filename_meta: Optional[MetaMusic],
media_source: Optional[MediaSource],
) -> Generator[_MusicPathAction, Optional[MusicInfo], Optional[MusicInfo]]:
"""生成“指纹→标签→文件名→专辑目录”的唯一回退状态机。"""
normalized_source = normalize_media_source(media_source)
if normalized_source in (None, MediaSource.MusicBrainz):
info = yield _MusicPathAction(kind=_MusicPathActionKind.FINGERPRINT)
if _has_remote_music_identity(info):
return info
info = yield _MusicPathAction(
kind=_MusicPathActionKind.TAG,
meta=tag_meta,
tier_name="文件标签",
)
if _has_remote_music_identity(info):
return info
info = yield _MusicPathAction(
kind=_MusicPathActionKind.FILENAME,
meta=filename_meta,
tier_name="文件名",
)
if _has_remote_music_identity(info):
return info
if normalized_source in (None, MediaSource.MusicBrainz):
return (yield _MusicPathAction(kind=_MusicPathActionKind.ALBUM))
return None
def _build_path_recognition_request(
path: str,
media_source: Optional[MediaSource],
episode_group: Optional[str],
obtain_images: bool,
is_music: bool,
) -> _PathRecognitionRequest:
"""将路径识别参数投影为同步和异步共用的稳定路由请求。"""
return _PathRecognitionRequest(
path=path,
route=_PathRoute.MUSIC if is_music else _PathRoute.VIDEO,
media_source=media_source,
episode_group=episode_group,
obtain_images=obtain_images,
)
def _path_context(
meta: Any,
info: Optional[Union[MediaInfo, MusicInfo]],
) -> Context:
"""统一成功与失败路径的 Context 投影。"""
if info is not None:
return Context(meta_info=meta, media_info=info)
return Context(meta_info=meta)
class MediaPathOwner(_MediaOwnerBase):
"""音频证据、单曲层级与统一路径识别 owner。"""
@@ -52,24 +255,17 @@ class MediaPathOwner(_MediaOwnerBase):
@staticmethod
def _merge_music_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo:
"""将本地文件的实际音频参数合并到远端音乐身份识别结果。"""
for key in ("audio_format", "audio_lossless", "bit_depth", "sample_rate", "bitrate"):
value = getattr(meta, key, None)
if value is not None:
setattr(info, key, value)
return info
return _merge_music_audio_quality(info, meta)
@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
return _without_music_identity(meta)
@staticmethod
def _is_remote_music_info(info: Optional[MusicInfo]) -> bool:
"""判断音乐识别结果是否携带可复用的远程身份。"""
return bool(info and info.media_source and info.media_id)
return _has_remote_music_identity(info)
def _recognize_musicbrainz_recording(
self,
@@ -77,9 +273,7 @@ class MediaPathOwner(_MediaOwnerBase):
recording_id: str,
) -> Optional[MusicInfo]:
"""按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
identity_meta = MetaMusic.from_dict(meta.to_dict())
identity_meta.media_source = MediaSource.MusicBrainz
identity_meta.media_id = recording_id
identity_meta = _musicbrainz_recording_meta(meta, recording_id)
return self.recognize_music_from_source(
media_source=MediaSource.MusicBrainz,
meta=identity_meta,
@@ -93,9 +287,7 @@ class MediaPathOwner(_MediaOwnerBase):
recording_id: str,
) -> Optional[MusicInfo]:
"""异步按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
identity_meta = MetaMusic.from_dict(meta.to_dict())
identity_meta.media_source = MediaSource.MusicBrainz
identity_meta.media_id = recording_id
identity_meta = _musicbrainz_recording_meta(meta, recording_id)
return await self.async_recognize_music_from_source(
media_source=MediaSource.MusicBrainz,
meta=identity_meta,
@@ -110,31 +302,29 @@ class MediaPathOwner(_MediaOwnerBase):
tier_name: str,
) -> Optional[MusicInfo]:
"""识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(media_source)
search_meta = meta
if meta.media_source == MediaSource.MusicBrainz and meta.media_id:
if normalized_source in (None, MediaSource.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,
media_source=media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
if isinstance(result, MusicInfo) and self._is_remote_music_info(result):
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
return result
return None
plan = _music_tier_plan(meta, media_source, tier_name)
outcome = _MusicTierOutcome()
try:
action = next(plan)
while True:
if action.kind is _MusicTierActionKind.DIRECT:
result = self._recognize_musicbrainz_recording(
meta=action.meta,
recording_id=action.recording_id or "",
)
else:
candidate = self.recognize_media(
meta=action.meta,
media_source=media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
result = candidate if isinstance(candidate, MusicInfo) else None
action = plan.send(result)
except StopIteration as completed:
outcome = cast(_MusicTierOutcome, completed.value)
if outcome.message:
logger.info(outcome.message)
return outcome.info
async def _async_recognize_music_meta_tier(
self,
@@ -143,31 +333,29 @@ class MediaPathOwner(_MediaOwnerBase):
tier_name: str,
) -> Optional[MusicInfo]:
"""异步识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(media_source)
search_meta = meta
if meta.media_source == MediaSource.MusicBrainz and meta.media_id:
if normalized_source in (None, MediaSource.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,
media_source=media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
if isinstance(result, MusicInfo) and self._is_remote_music_info(result):
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
return result
return None
plan = _music_tier_plan(meta, media_source, tier_name)
outcome = _MusicTierOutcome()
try:
action = next(plan)
while True:
if action.kind is _MusicTierActionKind.DIRECT:
result = await self._async_recognize_musicbrainz_recording(
meta=action.meta,
recording_id=action.recording_id or "",
)
else:
candidate = await self.async_recognize_media(
meta=action.meta,
media_source=media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
result = candidate if isinstance(candidate, MusicInfo) else None
action = plan.send(result)
except StopIteration as completed:
outcome = cast(_MusicTierOutcome, completed.value)
if outcome.message:
logger.info(outcome.message)
return outcome.info
def _music_album_dir_fallback(
self,
@@ -206,32 +394,28 @@ class MediaPathOwner(_MediaOwnerBase):
) -> Tuple[MetaMusic, MusicInfo]:
"""按指纹、文件标签、文件名三级顺序识别本地音乐。"""
meta, tag_meta, filename_meta = AudioMetadataHelper.read_evidence(Path(path))
info = None
normalized_source = normalize_media_source(media_source)
if normalized_source in (None, MediaSource.MusicBrainz):
recording_id = AcoustIdChain().identify_music_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,
media_source=media_source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = self._recognize_music_meta_tier(
meta=filename_meta,
media_source=media_source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(info or self._music_info_from_path_meta(meta), meta)
if not result.media_source and media_source in (None, MediaSource.MusicBrainz):
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
matched = self._music_album_dir_fallback(path)
if matched:
result = self._merge_music_audio_quality(matched, meta)
plan = _music_path_plan(tag_meta, filename_meta, media_source)
info: Optional[MusicInfo] = None
try:
action = next(plan)
while True:
if action.kind is _MusicPathActionKind.FINGERPRINT:
recording_id = AcoustIdChain().identify_music_by_fingerprint(path)
info = self._recognize_musicbrainz_recording(meta, recording_id) if recording_id else None
if self._is_remote_music_info(info):
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
elif action.kind is _MusicPathActionKind.ALBUM:
info = self._music_album_dir_fallback(path)
else:
info = self._recognize_music_meta_tier(
meta=action.meta,
media_source=media_source,
tier_name=action.tier_name or "",
)
action = plan.send(info)
except StopIteration as completed:
info = cast(Optional[MusicInfo], completed.value)
result = _finalize_music_path_info(meta, info)
return meta, self._simplify_recognized_music_info(result)
async def async_recognize_music_by_path(
@@ -244,35 +428,35 @@ class MediaPathOwner(_MediaOwnerBase):
AudioMetadataHelper.read_evidence,
Path(path),
)
info = None
normalized_source = normalize_media_source(media_source)
if normalized_source in (None, MediaSource.MusicBrainz):
recording_id = await AcoustIdChain().async_identify_music_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,
media_source=media_source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = await self._async_recognize_music_meta_tier(
meta=filename_meta,
media_source=media_source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(info or self._music_info_from_path_meta(meta), meta)
if not result.media_source and media_source in (None, MediaSource.MusicBrainz):
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
matched = await self._async_music_album_dir_fallback(path)
if matched:
result = self._merge_music_audio_quality(matched, meta)
plan = _music_path_plan(tag_meta, filename_meta, media_source)
info: Optional[MusicInfo] = None
try:
action = next(plan)
while True:
if action.kind is _MusicPathActionKind.FINGERPRINT:
recording_id = await AcoustIdChain().async_identify_music_by_fingerprint(path)
info = (
await self._async_recognize_musicbrainz_recording(
meta,
recording_id,
)
if recording_id
else None
)
if self._is_remote_music_info(info):
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
elif action.kind is _MusicPathActionKind.ALBUM:
info = await self._async_music_album_dir_fallback(path)
else:
info = await self._async_recognize_music_meta_tier(
meta=action.meta,
media_source=media_source,
tier_name=action.tier_name or "",
)
action = plan.send(info)
except StopIteration as completed:
info = cast(Optional[MusicInfo], completed.value)
result = _finalize_music_path_info(meta, info)
return meta, self._simplify_recognized_music_info(result)
def _is_music_path_request(self, path: str, media_source: Optional[MediaSource]) -> bool:
@@ -295,25 +479,30 @@ class MediaPathOwner(_MediaOwnerBase):
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, media_source):
music_meta, music_info = self.recognize_music_by_path(path, media_source=media_source)
return Context(meta_info=music_meta, media_info=music_info)
file_path = Path(path)
# 元数据
file_meta = MetaInfoPath(file_path)
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=file_meta,
request = _build_path_recognition_request(
path=path,
media_source=media_source,
episode_group=episode_group,
obtain_images=obtain_images,
is_music=self._is_music_path_request(path, media_source),
)
logger.info(f"开始识别媒体信息,文件:{request.path} ...")
if request.route is _PathRoute.MUSIC:
music_meta, music_info = self.recognize_music_by_path(
request.path,
media_source=request.media_source,
)
return _path_context(music_meta, music_info)
file_meta = MetaInfoPath(Path(request.path))
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=file_meta,
media_source=request.media_source,
episode_group=request.episode_group,
obtain_images=request.obtain_images,
)
if not mediainfo:
logger.warn(f"{path} 未识别到媒体信息")
return Context(meta_info=file_meta)
# 返回上下文
return Context(meta_info=file_meta, media_info=mediainfo)
logger.warn(f"{request.path} 未识别到媒体信息")
return _path_context(file_meta, mediainfo)
async def async_recognize_by_path(
self,
@@ -331,22 +520,27 @@ class MediaPathOwner(_MediaOwnerBase):
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, media_source):
music_meta, music_info = await self.async_recognize_music_by_path(path, media_source=media_source)
return Context(meta_info=music_meta, media_info=music_info)
file_path = Path(path)
# 元数据
file_meta = MetaInfoPath(file_path)
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=file_meta,
request = _build_path_recognition_request(
path=path,
media_source=media_source,
episode_group=episode_group,
obtain_images=obtain_images,
is_music=self._is_music_path_request(path, media_source),
)
logger.info(f"开始识别媒体信息,文件:{request.path} ...")
if request.route is _PathRoute.MUSIC:
music_meta, music_info = await self.async_recognize_music_by_path(
request.path,
media_source=request.media_source,
)
return _path_context(music_meta, music_info)
file_meta = MetaInfoPath(Path(request.path))
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=file_meta,
media_source=request.media_source,
episode_group=request.episode_group,
obtain_images=request.obtain_images,
)
if not mediainfo:
logger.warn(f"{path} 未识别到媒体信息")
return Context(meta_info=file_meta)
# 返回上下文
return Context(meta_info=file_meta, media_info=mediainfo)
logger.warn(f"{request.path} 未识别到媒体信息")
return _path_context(file_meta, mediainfo)

View File

@@ -1,5 +1,6 @@
"""媒体搜索入口 owner。"""
from dataclasses import dataclass
from typing import List, Optional, Tuple
from app.chain.media.contract import _MediaOwnerBase
@@ -15,6 +16,44 @@ from app.schemas.types import (
)
@dataclass(frozen=True, slots=True)
class _MediaSearchRequest:
"""保存同步和异步搜索共同使用的输入投影。"""
content: str
meta: MetaBase
def _build_media_search_request(title: str) -> _MediaSearchRequest:
"""将搜索文本一次性投影为规范元数据,避免双入口规则漂移。"""
mtype, _, season_num, episode_num, year, content = title_rules.parse_search_keyword(title)
content = content or title
meta = MetaInfo(content)
if not meta.name:
meta.cn_name = content
if mtype:
meta.type = mtype
if season_num:
meta.begin_season = season_num
if episode_num:
meta.begin_episode = episode_num
if year:
meta.year = year
return _MediaSearchRequest(content=content, meta=meta)
def _finish_media_search(
request: _MediaSearchRequest,
medias: Optional[List[MediaInfo]],
) -> Tuple[MetaBase, List[MediaInfo]]:
"""统一空结果与成功结果投影,并保持既有日志语义。"""
if not medias:
logger.warn(f"{request.meta.name} 没有找到对应的媒体信息!")
return request.meta, []
logger.info(f"{request.content} 搜索到 {len(medias)} 条相关媒体信息")
return request.meta, medias
class MediaSearchOwner(_MediaOwnerBase):
"""媒体搜索入口 owner。"""
@@ -28,31 +67,13 @@ class MediaSearchOwner(_MediaOwnerBase):
:param media_source: 请求级搜索数据源
:return: 识别元数据,媒体信息列表
"""
# 提取要素
mtype, key_word, season_num, episode_num, year, content = title_rules.parse_search_keyword(title)
# 识别
content = content or title
meta = MetaInfo(content)
if not meta.name:
meta.cn_name = content
# 合并信息
if mtype:
meta.type = mtype
if season_num:
meta.begin_season = season_num
if episode_num:
meta.begin_episode = episode_num
if year:
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta, media_source=media_source)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
# 识别的元数据,媒体信息列表
return meta, medias
request = _build_media_search_request(title)
logger.info(f"开始搜索媒体信息:{request.meta.name}")
medias: Optional[List[MediaInfo]] = self.search_medias(
meta=request.meta,
media_source=media_source,
)
return _finish_media_search(request, medias)
async def async_search(
self, title: str, media_source: Optional[MediaSourceSelection] = None
@@ -64,28 +85,10 @@ class MediaSearchOwner(_MediaOwnerBase):
:param media_source: 请求级搜索数据源
:return: 识别元数据,媒体信息列表
"""
# 提取要素
mtype, key_word, season_num, episode_num, year, content = title_rules.parse_search_keyword(title)
# 识别
content = content or title
meta = MetaInfo(content)
if not meta.name:
meta.cn_name = content
# 合并信息
if mtype:
meta.type = mtype
if season_num:
meta.begin_season = season_num
if episode_num:
meta.begin_episode = episode_num
if year:
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta, media_source=media_source)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
# 识别的元数据,媒体信息列表
return meta, medias
request = _build_media_search_request(title)
logger.info(f"开始搜索媒体信息:{request.meta.name}")
medias: Optional[List[MediaInfo]] = await self.async_search_medias(
meta=request.meta,
media_source=media_source,
)
return _finish_media_search(request, medias)

View File

@@ -0,0 +1,555 @@
"""媒体搜索、路径识别与歌词聚合的同步异步同形回归。"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from app.chain.acoustid import AcoustIdChain
from app.chain.lyrics import LyricsChain
from app.chain.media import MediaChain
from app.domain.context import MediaInfo, MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
from app.schemas.types import MediaSource, MediaType
def _remote_music(stage: str) -> MusicInfo:
"""构造指定回退层命中的标准远端音乐结果。"""
return MusicInfo(
media_source=MediaSource.MusicBrainz,
media_id=f"recording-{stage}",
title=f"Track {stage}",
)
@pytest.mark.parametrize("empty", [False, True])
def test_media_search_sync_async_share_projection_and_empty_result(
monkeypatch: pytest.MonkeyPatch,
empty: bool,
) -> None:
"""搜索双入口应共享要素投影,并将所有空结果规范为新列表。"""
chain = MediaChain()
source = (MediaSource.TMDB,)
medias = [] if empty else [MediaInfo(type=MediaType.TV, title="Result")]
sync_search = Mock(return_value=medias or None)
async_search = AsyncMock(return_value=medias or None)
monkeypatch.setattr(
"app.chain.media.search.title_rules.parse_search_keyword",
Mock(
return_value=(
MediaType.TV,
"ignored",
2,
3,
2024,
"Projected Title",
)
),
)
monkeypatch.setattr(MediaChain, "search_medias", sync_search)
monkeypatch.setattr(MediaChain, "async_search_medias", async_search)
sync_meta, sync_result = chain.search("raw title", media_source=source)
async_meta, async_result = asyncio.run(chain.async_search("raw title", media_source=source))
assert sync_meta is not None and async_meta is not None
assert (
(
sync_meta.name,
sync_meta.type,
sync_meta.begin_season,
sync_meta.begin_episode,
sync_meta.year,
)
== (
async_meta.name,
async_meta.type,
async_meta.begin_season,
async_meta.begin_episode,
async_meta.year,
)
== ("Projected Title", MediaType.TV, 2, 3, 2024)
)
assert sync_result == async_result == medias
assert (sync_result is not async_result) is empty
assert sync_search.call_args.kwargs["media_source"] == source
assert async_search.await_args.kwargs["media_source"] == source
def test_media_search_sync_async_propagate_provider_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""搜索提供方异常不得被任一入口伪装为空结果。"""
chain = MediaChain()
monkeypatch.setattr(
MediaChain,
"search_medias",
Mock(side_effect=RuntimeError("sync search failed")),
)
monkeypatch.setattr(
MediaChain,
"async_search_medias",
AsyncMock(side_effect=RuntimeError("async search failed")),
)
with pytest.raises(RuntimeError, match="sync search failed"):
chain.search("Title")
with pytest.raises(RuntimeError, match="async search failed"):
asyncio.run(chain.async_search("Title"))
@pytest.mark.parametrize(
("hit_stage", "expected_order"),
[
("fingerprint", ["fingerprint"]),
("tag", ["fingerprint", "tag"]),
("filename", ["fingerprint", "tag", "filename"]),
("album", ["fingerprint", "tag", "filename", "album"]),
(None, ["fingerprint", "tag", "filename", "album"]),
],
)
def test_music_path_sync_async_follow_one_fallback_state_machine(
monkeypatch: pytest.MonkeyPatch,
hit_stage: str | None,
expected_order: list[str],
) -> None:
"""路径识别双入口应在同一层终止,并保持每层调用次数一致。"""
chain = MediaChain()
path = Path("Track.flac")
merged = MetaMusic(title="Track", audio_format="FLAC")
tag_meta = MetaMusic(title="Tagged")
filename_meta = MetaMusic(title="Filename")
sync_order: list[str] = []
async_order: list[str] = []
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(merged, tag_meta, filename_meta)),
)
def sync_fingerprint(_self: AcoustIdChain, _path: Path) -> str | None:
"""记录同步指纹层并按场景返回 Recording ID。"""
sync_order.append("fingerprint")
return "fingerprint-id" if hit_stage == "fingerprint" else None
async def async_fingerprint(_self: AcoustIdChain, _path: Path) -> str | None:
"""记录异步指纹层并按场景返回 Recording ID。"""
async_order.append("fingerprint")
return "fingerprint-id" if hit_stage == "fingerprint" else None
def sync_tier(
_chain: MediaChain,
*,
meta: MetaMusic,
media_source: MediaSource | None,
tier_name: str,
):
"""模拟同步标签或文件名层的远端命中。"""
del meta, media_source
stage = "tag" if tier_name == "文件标签" else "filename"
sync_order.append(stage)
return _remote_music(stage) if hit_stage == stage else None
async def async_tier(
_chain: MediaChain,
*,
meta: MetaMusic,
media_source: MediaSource | None,
tier_name: str,
):
"""模拟异步标签或文件名层的远端命中。"""
del meta, media_source
stage = "tag" if tier_name == "文件标签" else "filename"
async_order.append(stage)
return _remote_music(stage) if hit_stage == stage else None
def sync_album(_chain: MediaChain, _path: Path) -> MusicInfo | None:
"""记录同步专辑目录兜底。"""
sync_order.append("album")
return _remote_music("album") if hit_stage == "album" else None
async def async_album(_chain: MediaChain, _path: Path) -> MusicInfo | None:
"""记录异步专辑目录兜底。"""
async_order.append("album")
return _remote_music("album") if hit_stage == "album" else None
monkeypatch.setattr(AcoustIdChain, "identify_music_by_fingerprint", sync_fingerprint)
monkeypatch.setattr(AcoustIdChain, "async_identify_music_by_fingerprint", async_fingerprint)
monkeypatch.setattr(
MediaChain,
"_recognize_musicbrainz_recording",
Mock(return_value=_remote_music("fingerprint")),
)
monkeypatch.setattr(
MediaChain,
"_async_recognize_musicbrainz_recording",
AsyncMock(return_value=_remote_music("fingerprint")),
)
monkeypatch.setattr(MediaChain, "_recognize_music_meta_tier", sync_tier)
monkeypatch.setattr(MediaChain, "_async_recognize_music_meta_tier", async_tier)
monkeypatch.setattr(MediaChain, "_music_album_dir_fallback", sync_album)
monkeypatch.setattr(MediaChain, "_async_music_album_dir_fallback", async_album)
_, sync_info = chain.recognize_music_by_path(path)
_, async_info = asyncio.run(chain.async_recognize_music_by_path(path))
assert sync_order == async_order == expected_order
expected_id = f"recording-{hit_stage}" if hit_stage else None
assert sync_info.media_id == async_info.media_id == expected_id
assert sync_info.audio_format == async_info.audio_format == "FLAC"
@pytest.mark.parametrize(
("failed_stage", "expected_order"),
[
("fingerprint", ["fingerprint"]),
("tag", ["fingerprint", "tag"]),
("filename", ["fingerprint", "tag", "filename"]),
],
)
def test_music_path_sync_async_stop_at_same_failing_tier(
monkeypatch: pytest.MonkeyPatch,
failed_stage: str,
expected_order: list[str],
) -> None:
"""指纹、标签或文件名 I/O 异常时双入口应在相同层停止。"""
chain = MediaChain()
meta = MetaMusic(title="Track")
sync_order: list[str] = []
async_order: list[str] = []
monkeypatch.setattr(
"app.chain.media.path.AudioMetadataHelper.read_evidence",
Mock(return_value=(meta, meta, meta)),
)
def sync_fingerprint(_self: AcoustIdChain, _path: Path) -> None:
"""记录同步指纹动作并按场景抛错。"""
sync_order.append("fingerprint")
if failed_stage == "fingerprint":
raise RuntimeError("sync fingerprint failed")
async def async_fingerprint(_self: AcoustIdChain, _path: Path) -> None:
"""记录异步指纹动作并按场景抛错。"""
async_order.append("fingerprint")
if failed_stage == "fingerprint":
raise RuntimeError("async fingerprint failed")
def sync_tier(
_chain: MediaChain,
*,
meta: MetaMusic,
media_source: MediaSource | None,
tier_name: str,
):
"""记录同步元数据层并在指定层抛错。"""
del meta, media_source
stage = "tag" if tier_name == "文件标签" else "filename"
sync_order.append(stage)
if failed_stage == stage:
raise RuntimeError(f"sync {stage} failed")
return None
async def async_tier(
_chain: MediaChain,
*,
meta: MetaMusic,
media_source: MediaSource | None,
tier_name: str,
):
"""记录异步元数据层并在指定层抛错。"""
del meta, media_source
stage = "tag" if tier_name == "文件标签" else "filename"
async_order.append(stage)
if failed_stage == stage:
raise RuntimeError(f"async {stage} failed")
return None
monkeypatch.setattr(AcoustIdChain, "identify_music_by_fingerprint", sync_fingerprint)
monkeypatch.setattr(AcoustIdChain, "async_identify_music_by_fingerprint", async_fingerprint)
monkeypatch.setattr(MediaChain, "_recognize_music_meta_tier", sync_tier)
monkeypatch.setattr(MediaChain, "_async_recognize_music_meta_tier", async_tier)
with pytest.raises(RuntimeError, match=f"sync {failed_stage} failed"):
chain.recognize_music_by_path("Track.flac")
with pytest.raises(RuntimeError, match=f"async {failed_stage} failed"):
asyncio.run(chain.async_recognize_music_by_path("Track.flac"))
assert sync_order == async_order == expected_order
@pytest.mark.parametrize(
("source", "direct_hit", "has_title", "expected_calls"),
[
(None, True, True, ["direct"]),
(None, False, True, ["direct", "search"]),
(MediaSource.TMDB, False, True, ["search"]),
(None, False, False, ["direct"]),
],
)
def test_music_meta_tier_sync_async_share_direct_and_search_decisions(
monkeypatch: pytest.MonkeyPatch,
source: MediaSource | None,
direct_hit: bool,
has_title: bool,
expected_calls: list[str],
) -> None:
"""标签 MBID 直查、身份清理和标题回退在双入口中必须同形。"""
chain = MediaChain()
meta = MetaMusic(
title="Tagged" if has_title else None,
media_source=MediaSource.MusicBrainz,
media_id="recording-tagged",
)
sync_calls: list[str] = []
async_calls: list[str] = []
direct_result = _remote_music("direct") if direct_hit else None
search_result = _remote_music("search")
def sync_direct(_chain: MediaChain, **_kwargs) -> MusicInfo | None:
"""记录同步 MBID 直查。"""
sync_calls.append("direct")
return direct_result
async def async_direct(_chain: MediaChain, **_kwargs) -> MusicInfo | None:
"""记录异步 MBID 直查。"""
async_calls.append("direct")
return direct_result
def sync_search(_chain: MediaChain, **kwargs) -> MusicInfo:
"""记录同步标题搜索并验证身份已清理。"""
sync_calls.append("search")
assert kwargs["meta"].media_source is None
assert kwargs["meta"].media_id is None
return search_result
async def async_search(_chain: MediaChain, **kwargs) -> MusicInfo:
"""记录异步标题搜索并验证身份已清理。"""
async_calls.append("search")
assert kwargs["meta"].media_source is None
assert kwargs["meta"].media_id is None
return search_result
monkeypatch.setattr(MediaChain, "_recognize_musicbrainz_recording", sync_direct)
monkeypatch.setattr(MediaChain, "_async_recognize_musicbrainz_recording", async_direct)
monkeypatch.setattr(MediaChain, "recognize_media", sync_search)
monkeypatch.setattr(MediaChain, "async_recognize_media", async_search)
sync_result = chain._recognize_music_meta_tier(meta, source, "文件标签")
async_result = asyncio.run(chain._async_recognize_music_meta_tier(meta, source, "文件标签"))
assert sync_calls == async_calls == expected_calls
expected = direct_result if direct_hit else (search_result if has_title else None)
assert sync_result is expected
assert async_result is expected
def test_music_album_fallback_sync_async_isolate_directory_errors(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""专辑目录识别异常在同步和异步入口都应降级为空结果。"""
path = tmp_path / "Track.flac"
path.write_bytes(b"audio")
chain = MediaChain()
monkeypatch.setattr(
MediaChain,
"recognize_music_album_directory",
Mock(side_effect=RuntimeError("sync album failed")),
)
monkeypatch.setattr(
MediaChain,
"async_recognize_music_album_directory",
AsyncMock(side_effect=RuntimeError("async album failed")),
)
assert chain._music_album_dir_fallback(path) is None
assert asyncio.run(chain._async_music_album_dir_fallback(path)) is None
@pytest.mark.parametrize("recognized", [False, True])
def test_video_path_sync_async_share_route_and_failure_context(
monkeypatch: pytest.MonkeyPatch,
recognized: bool,
) -> None:
"""影视路径双入口应共享路由参数,并在失败时都保留元数据 Context。"""
chain = MediaChain()
info = MediaInfo(type=MediaType.MOVIE, title="Movie") if recognized else None
sync_recognize = Mock(return_value=info)
async_recognize = AsyncMock(return_value=info)
monkeypatch.setattr(MediaChain, "_is_music_path_request", Mock(return_value=False))
monkeypatch.setattr(MediaChain, "_recognize_with_fallback_by_meta", sync_recognize)
monkeypatch.setattr(MediaChain, "_async_recognize_with_fallback_by_meta", async_recognize)
sync_context = chain.recognize_by_path(
"Movie.2024.mkv",
media_source=MediaSource.TMDB,
episode_group="group-1",
obtain_images=True,
)
async_context = asyncio.run(
chain.async_recognize_by_path(
"Movie.2024.mkv",
media_source=MediaSource.TMDB,
episode_group="group-1",
obtain_images=True,
)
)
assert sync_context is not None and async_context is not None
assert sync_context.media_info is async_context.media_info is info
assert sync_context.meta_info.title == async_context.meta_info.title
for call in (sync_recognize.call_args, async_recognize.await_args):
assert call.kwargs["media_source"] == MediaSource.TMDB
assert call.kwargs["episode_group"] == "group-1"
assert call.kwargs["obtain_images"] is True
def test_music_path_route_sync_async_skip_video_recognition(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""音乐路由双入口应直接返回音乐 Context,且不调用影视识别。"""
chain = MediaChain()
meta = MetaMusic(title="Track")
info = _remote_music("route")
sync_music = Mock(return_value=(meta, info))
async_music = AsyncMock(return_value=(meta, info))
sync_video = Mock()
async_video = AsyncMock()
monkeypatch.setattr(MediaChain, "_is_music_path_request", Mock(return_value=True))
monkeypatch.setattr(MediaChain, "recognize_music_by_path", sync_music)
monkeypatch.setattr(MediaChain, "async_recognize_music_by_path", async_music)
monkeypatch.setattr(MediaChain, "_recognize_with_fallback_by_meta", sync_video)
monkeypatch.setattr(MediaChain, "_async_recognize_with_fallback_by_meta", async_video)
sync_context = chain.recognize_by_path("Track.flac", media_source=MediaSource.MusicBrainz)
async_context = asyncio.run(chain.async_recognize_by_path("Track.flac", media_source=MediaSource.MusicBrainz))
assert sync_context is not None and async_context is not None
assert sync_context.meta_info is async_context.meta_info is meta
assert sync_context.media_info is async_context.media_info is info
sync_music.assert_called_once_with("Track.flac", media_source=MediaSource.MusicBrainz)
async_music.assert_awaited_once_with("Track.flac", media_source=MediaSource.MusicBrainz)
sync_video.assert_not_called()
async_video.assert_not_awaited()
def test_lyrics_sync_async_share_deadline_fallback_dedup_and_call_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""歌词双入口应按旧、新、本地顺序聚合,并稳定替换重复候选。"""
low = MusicLyrics(provider="same", plain_lyrics="duplicate", match_score=10)
high = MusicLyrics(provider="same", plain_lyrics="duplicate", match_score=90)
new = MusicLyrics(provider="new", synced_lyrics="[00:01]new", match_score=80)
music = MusicInfo(title="Track", lyrics="fallback")
sync_chain = LyricsChain()
async_chain = LyricsChain()
sync_calls: list[str] = []
async_calls: list[str] = []
def sync_module(method: str, **_kwargs):
"""记录同步歌词接口顺序并返回对应候选。"""
sync_calls.append(method)
return high if method == "music_lyrics" else [new]
async def async_module(method: str, **_kwargs):
"""记录异步歌词接口顺序并返回对应候选。"""
async_calls.append(method)
return high if method == "music_lyrics" else [new]
monkeypatch.setattr(sync_chain, "run_module", sync_module)
monkeypatch.setattr(async_chain, "async_run_module", async_module)
sync_result = sync_chain.get_music_lyrics_candidates(music, [low])
async_result = asyncio.run(async_chain.async_get_music_lyrics_candidates(music, [low]))
assert sync_calls == async_calls == ["music_lyrics", "music_lyrics_candidates"]
assert [item.provider for item in sync_result] == ["same", "new", "theaudiodb"]
assert [item.provider for item in async_result] == ["same", "new", "theaudiodb"]
assert sync_result[0] is async_result[0] is high
def test_lyrics_sync_async_deadline_skips_modules_and_local_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""预算耗尽时双入口只返回现有本地候选,不再调用模块或追加来源兜底。"""
monkeypatch.setattr("app.chain.lyrics.time.monotonic", Mock(return_value=10.0))
local = MusicLyrics(provider="embedded", plain_lyrics="local")
music = MusicInfo(title="Track", lyrics="fallback")
sync_chain = LyricsChain(deadline=10.0)
async_chain = LyricsChain(deadline=10.0)
sync_run = Mock()
async_run = AsyncMock()
monkeypatch.setattr(sync_chain, "run_module", sync_run)
monkeypatch.setattr(async_chain, "async_run_module", async_run)
sync_result = sync_chain.get_music_lyrics_candidates(music, [local])
async_result = asyncio.run(async_chain.async_get_music_lyrics_candidates(music, [local]))
assert sync_result == async_result == [local]
assert sync_chain.budget_exceeded is async_chain.budget_exceeded is True
sync_run.assert_not_called()
async_run.assert_not_awaited()
def test_lyrics_sync_async_empty_modules_without_fallback_return_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""新旧接口均为空且无本地歌词时,双入口应稳定返回空列表。"""
music = MusicInfo(title="Track")
sync_chain = LyricsChain()
async_chain = LyricsChain()
sync_run = Mock(side_effect=[None, []])
async_run = AsyncMock(side_effect=[None, []])
monkeypatch.setattr(sync_chain, "run_module", sync_run)
monkeypatch.setattr(async_chain, "async_run_module", async_run)
assert sync_chain.get_music_lyrics_candidates(music) == []
assert asyncio.run(async_chain.async_get_music_lyrics_candidates(music)) == []
assert sync_run.call_count == async_run.await_count == 2
@pytest.mark.parametrize(
("failed_method", "expected_calls"),
[
("music_lyrics", ["music_lyrics"]),
(
"music_lyrics_candidates",
["music_lyrics", "music_lyrics_candidates"],
),
],
)
def test_lyrics_sync_async_propagate_same_module_exception(
monkeypatch: pytest.MonkeyPatch,
failed_method: str,
expected_calls: list[str],
) -> None:
"""旧或新模块接口异常时双入口应在同一步停止且不伪造兜底成功。"""
music = MusicInfo(title="Track", lyrics="fallback")
sync_chain = LyricsChain()
async_chain = LyricsChain()
sync_calls: list[str] = []
async_calls: list[str] = []
def sync_module(method: str, **_kwargs):
"""在指定同步歌词接口抛出异常。"""
sync_calls.append(method)
if method == failed_method:
raise RuntimeError("sync failed")
return None
async def async_module(method: str, **_kwargs):
"""在指定异步歌词接口抛出异常。"""
async_calls.append(method)
if method == failed_method:
raise RuntimeError("async failed")
return None
monkeypatch.setattr(sync_chain, "run_module", sync_module)
monkeypatch.setattr(async_chain, "async_run_module", async_module)
with pytest.raises(RuntimeError, match="sync failed"):
sync_chain.get_music_lyrics_candidates(music)
with pytest.raises(RuntimeError, match="async failed"):
asyncio.run(async_chain.async_get_music_lyrics_candidates(music))
assert sync_calls == async_calls == expected_calls