Files
MoviePilot/app/chain/media/path.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

1139 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
音频证据分层核验与统一媒体路径识别。
"""
import re
from copy import deepcopy
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Awaitable, Callable, Generator, Optional, Tuple, TypeGuard, Union, cast
from app.application.audio import AudioMetadataHelper
from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.music.observation import (
capture_music_recognition,
music_recognition_failed,
music_recognition_is_blocked,
report_music_fingerprints,
)
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
from app.domain.meta.metamusic import MetaMusic, music_credit_values
from app.domain.metainfo import MetaInfoPath
from app.domain.music import MusicDirectoryMatch, music_album_title_is_weak, music_track_title_is_weak
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.schemas.media import normalize_media_source
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaSource,
)
_FINGERPRINT_TITLE_QUALIFIER = re.compile(
r"\s*[\[((【][^\]))】]*(?:radio|single|version|edit|mix|remix|remaster(?:ed)?|"
r"live|acoustic|demo|mono|stereo|recorded|pop)[^\]))】]*[\]))】]",
re.IGNORECASE,
)
_CONTENT_RATING_QUALIFIER = re.compile(
r"\s*[\[((【]\s*(?:explicit|clean)\s*[\]))】]",
re.IGNORECASE,
)
_SINGLE_RELEASE_SUFFIX = re.compile(
r"\s*[-–—]\s*(?:single|单曲)\s*$",
re.IGNORECASE,
)
def _is_regular_file(path: Path) -> bool:
"""
判断路径是否仍指向可读取的普通文件。
"""
return path.exists() and path.is_file()
class _MusicTierActionKind(Enum):
"""
音乐证据层允许执行的外部识别动作。
"""
# 按已知 MusicBrainz 录音标识直查。
DIRECT = "direct"
# 按本层音乐标题与艺人证据搜索。
SEARCH = "search"
@dataclass(frozen=True, slots=True)
class _MusicTierAction:
"""
描述一次音乐证据层 I/O,不携带可变执行状态。
:param kind: 本层需要执行的身份直查或标题搜索动作
:param meta: 本层用于验证远程候选的本地音乐证据
:param recording_id: MusicBrainz 录音直查标识,标题搜索时为 None
"""
kind: _MusicTierActionKind
meta: MetaMusic
recording_id: Optional[str] = None
@dataclass(frozen=True, slots=True)
class _MusicTierOutcome:
"""
保存证据层命中结果及其日志说明。
:param info: 本证据层识别出的音乐信息,未命中时为 None
:param message: 命中后向日志说明证据层与识别来源的文本,未命中时可为 None
"""
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:
"""
描述音乐路径状态机的一层识别动作。
:param kind: 当前需要尝试的指纹、标签、文件名或专辑目录识别层
:param meta: 标签或文件名层使用的音乐元数据,其他层可为 None
:param tier_name: 用于诊断的证据层名称,未指定时为 None
"""
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:
"""
保存同步和异步路径识别共同使用的请求参数。
:param path: 待识别媒体的文件或目录路径
:param route: 按音乐或影视协议处理路径的路由
:param media_source: 调用方指定的识别来源,None 时按系统来源策略选择
:param episode_group: 影视识别使用的剧集组标识,None 表示未指定
:param obtain_images: 是否在识别结果中补充图片信息
"""
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
identity_meta.music_type = MUSIC_ENTITY_RECORDING
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_path_evidence(
meta: MetaMusic,
evidence: Optional[MetaMusic],
) -> MetaMusic:
"""
把同一文件的标签证据补入文件名搜索,保留ISRC约束但不传播来源实体ID。
"""
if not meta or not evidence:
return meta
merged = MetaMusic.from_dict(meta.to_dict())
if not merged.artists and evidence.artists:
merged.artists = list(evidence.artists)
if not merged.album_artist and evidence.album_artist:
merged.album_artist = evidence.album_artist
if not merged.album and evidence.album:
merged.album = evidence.album
if not merged.year and evidence.year:
merged.year = evidence.year
if not merged.version and evidence.version:
merged.version = evidence.version
if not merged.isrc and evidence.isrc:
merged.isrc = evidence.isrc
return merged
def _merge_contextual_music_evidence(
meta: MetaMusic,
contextual_meta: MetaMusic,
) -> MetaMusic:
"""
以同目录高置信共识补齐缺失的艺人和专辑证据。
"""
merged = MetaMusic.from_dict(meta.to_dict())
for key, value in music_credit_values(contextual_meta).items():
if value and not getattr(merged, key):
setattr(merged, key, value)
merged.field_sources[key] = contextual_meta.field_sources.get(key, "torrent")
if not merged.artists and contextual_meta.artists:
merged.artists = list(contextual_meta.artists)
if not merged.album_artist and contextual_meta.album_artist:
merged.album_artist = contextual_meta.album_artist
if not merged.album and contextual_meta.album:
merged.album = contextual_meta.album
if contextual_meta.year:
# 整理链会先用最近的发行目录年份纠正下载包中的旧标签年份;这里
# 必须保留该强上下文,否则重新读取音频标签后又会退回创作年或旧版年。
merged.year = contextual_meta.year
if not merged.version and contextual_meta.version:
merged.version = contextual_meta.version
return merged
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)
if key in meta.field_sources:
info.field_sources[key] = meta.field_sources[key]
for key, value in music_credit_values(meta).items():
if value and meta.field_sources.get(key) in {"tag", "cue", "torrent", "manual"}:
setattr(info, key, value)
info.field_sources[key] = meta.field_sources[key]
return info
def _finalize_music_path_info(
meta: MetaMusic,
info: Optional[MusicInfo],
) -> MusicInfo:
"""
统一远端命中和本地兜底的音频质量合并。
"""
if info and music_recognition_is_blocked(info) and not _has_remote_music_identity(info):
result = MusicInfo.from_meta(meta)
result.raw_data["recognition"] = deepcopy(info.raw_data["recognition"])
else:
result = _merge_music_audio_quality(info or MusicInfo.from_meta(meta), meta)
if (
_has_remote_music_identity(result)
and result.music_type == MUSIC_ENTITY_RECORDING
and not result.album_type
):
# 部分 MusicBrainz Recording 没有关联 Release Group 类型,统一按单曲归类。
result.album_type = "Single"
return result
def _fingerprint_info_matches_evidence(
info: Optional[MusicInfo],
tag_meta: Optional[MetaMusic],
filename_meta: Optional[MetaMusic],
*,
fingerprint_score: Optional[float] = None,
fingerprint_duration: Optional[int] = None,
) -> bool:
"""
保留强标签冲突检查;原生高分指纹可用真实时长弥补占位名称或缺失署名。
"""
# 仅在本层音乐证据核验时加载所需的匹配规则。
from app.domain.music import ( # pylint: disable=import-outside-toplevel
music_album_matches,
music_artist_evidence_matches,
music_credit_conflicts,
music_isrc_conflicts,
music_text_key,
music_usable_artists,
music_version_matches,
music_year_matches,
)
if not _has_remote_music_identity(info):
return False
primary = tag_meta if tag_meta and not music_track_title_is_weak(tag_meta) else filename_meta or tag_meta
if not primary or music_isrc_conflicts(info, tag_meta or primary):
return False
weak_title = music_track_title_is_weak(primary)
local_duration = fingerprint_duration or (tag_meta.duration if tag_meta else None) or primary.duration
close_duration = bool(local_duration and info.duration and abs(local_duration - info.duration) <= max(3, min(8, local_duration * 0.025)))
native_strong = fingerprint_score is not None and fingerprint_score >= 0.98 and close_duration
if local_duration and info.duration and abs(local_duration - info.duration) > max(10, local_duration * 0.08):
return False
artist_meta = tag_meta if tag_meta and tag_meta.artists else primary
artist_evidence = music_usable_artists(artist_meta.artists) or music_usable_artists(
filename_meta.artists if filename_meta else []
)
collective = {music_text_key(value) for value in ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")}
if (fingerprint_score is not None and artist_meta.field_sources.get("artists") in {"directory", "torrent", "album_tags"}
and all(music_text_key(artist) in collective for artist in artist_evidence)):
artist_evidence = []
if not artist_evidence and not native_strong:
return False
if music_credit_conflicts(info, artist_meta) or (artist_evidence and not music_artist_evidence_matches(info, artist_meta, artist_evidence)):
return False
if (not weak_title or primary.version) and not music_version_matches(info, primary):
return False
if tag_meta and tag_meta is not primary and tag_meta.version and not music_version_matches(info, tag_meta):
return False
standalone_single = _is_standalone_single_evidence(primary)
if (
primary.album
and info.album
and not music_album_matches(info, primary.album)
and not standalone_single
and not native_strong
):
return False
if not native_strong and not standalone_single and not music_year_matches(info, primary):
return False
if weak_title:
return native_strong
return _fingerprint_title_matches(info, primary.title or "")
def _fingerprint_title_matches(info: MusicInfo, title_evidence: str) -> bool:
"""
在音频与署名已核验后比较曲名别称及展示差异,避免混入发行身份判断。
"""
from difflib import SequenceMatcher
# 仅在本层音乐证据核验时加载所需的匹配规则。
from app.domain.music import ( # pylint: disable=import-outside-toplevel
music_base_title,
music_text_key,
music_title_matches,
music_titles,
)
if music_title_matches(info, title_evidence):
return True
# 上层已核验音频、艺人及版本,允许常见展示后缀与轻微标签拼写差异。
evidence_key = music_text_key(music_base_title(title_evidence))
version_suffix = re.compile(
r"(?:radio|single|version|edit|mix|remix|remaster(?:ed)?|live|acoustic|"
r"demo|mono|stereo|recorded)+"
)
for title in music_titles(info):
candidate_key = music_text_key(music_base_title(title))
if not candidate_key:
continue
shorter, longer = sorted((candidate_key, evidence_key), key=len)
if shorter and longer.startswith(shorter) and version_suffix.fullmatch(longer[len(shorter):]):
return True
qualified_evidence_key = music_text_key(
music_base_title(_FINGERPRINT_TITLE_QUALIFIER.sub("", title_evidence))
)
qualified_candidate_key = music_text_key(
music_base_title(_FINGERPRINT_TITLE_QUALIFIER.sub("", title))
)
if qualified_evidence_key and qualified_evidence_key == qualified_candidate_key:
return True
if SequenceMatcher(None, candidate_key, evidence_key).ratio() >= 0.82:
return True
return False
def _is_standalone_single_evidence(meta: MetaMusic) -> bool:
"""
判断本地标签是否明确把当前录音描述为同名单曲发行。
"""
# 仅在本层音乐证据核验时加载所需的匹配规则。
from app.domain.music import ( # pylint: disable=import-outside-toplevel
music_base_title,
music_text_key,
)
title_key = music_text_key(
music_base_title(
_SINGLE_RELEASE_SUFFIX.sub(
"",
_CONTENT_RATING_QUALIFIER.sub("", meta.title or ""),
)
)
)
album_key = music_text_key(
music_base_title(
_SINGLE_RELEASE_SUFFIX.sub(
"",
_CONTENT_RATING_QUALIFIER.sub("", meta.album or ""),
)
)
)
return bool(title_key and album_key and title_key == album_key)
def _reconcile_fingerprint_release(
info: MusicInfo,
tag_meta: Optional[MetaMusic],
filename_meta: Optional[MetaMusic],
*,
verified_audio: bool = False,
) -> MusicInfo:
"""
指纹只确认录音,用本地发行证据校正远端随意选取的关联专辑。
MusicBrainz Recording 可以同时收录于单曲和原声专辑。指纹证明的是录音
身份,不能确认实际发行;采用本地专辑字段时移除没有证明的发行标识,
保留字段来源,旧插件的单ID回执仍只适用原有同名单曲纠正。
"""
# 仅在本层音乐证据核验时加载所需的匹配规则。
from app.domain.music import ( # pylint: disable=import-outside-toplevel
music_album_matches,
music_year_matches,
)
primary = tag_meta if tag_meta and tag_meta.title else filename_meta
if (
not primary
or not (_is_standalone_single_evidence(primary) or verified_audio)
or not primary.album
or music_album_title_is_weak(primary)
or (
music_album_matches(info, primary.album)
and music_year_matches(info, primary)
)
):
return info
reconciled = deepcopy(info)
reconciled.album = primary.album
reconciled.album_artist = primary.album_artist or info.artist
reconciled.album_id = None
reconciled.musicbrainz_release_id = primary.musicbrainz_release_id
reconciled.musicbrainz_release_group_id = primary.musicbrainz_release_group_id
reconciled.musicbrainz_release_track_id = primary.musicbrainz_release_track_id
reconciled.release_year = primary.release_year
reconciled.original_year = primary.original_year or info.original_year
reconciled.album_type = primary.album_type or ("Single" if _is_standalone_single_evidence(primary) else None)
reconciled.secondary_types = list(primary.secondary_types)
reconciled.year = int(primary.year) if primary.year is not None else None
reconciled.release_date = None
reconciled.release_status = None
reconciled.disc_number = primary.disc_number
reconciled.track_number = primary.track_number
reconciled.total_tracks = primary.total_tracks
reconciled.total_discs = primary.total_discs
reconciled.album_aliases = []
reconciled.cover_url = None
reconciled.category = ""
reconciled.metadata_category = reconciled.album_type or ""
reconciled.classification = None
for key in ("album", "album_artist", "year", "release_year", "disc_number", "track_number", "total_tracks", "total_discs",
"musicbrainz_release_id", "musicbrainz_release_group_id", "musicbrainz_release_track_id"):
if getattr(reconciled, key, None) is not None:
reconciled.field_sources[key] = primary.field_sources.get(key, "tag" if primary is tag_meta else "filename")
else:
reconciled.field_sources.pop(key, None)
return reconciled
def _select_fingerprint_info(
meta: MetaMusic, matches: list[tuple[Optional[float], MusicInfo]],
tag_meta: Optional[MetaMusic], filename_meta: Optional[MetaMusic],
*, truncated: bool = False,
) -> Optional[MusicInfo]:
"""
只采用唯一可解释的录音候选,近分多录音保留待确认,不用文本回退掩盖歧义。
"""
if not matches:
return None
if tag_meta and tag_meta.media_source == MediaSource.MusicBrainz and tag_meta.media_id:
tagged = next(((score, info) for score, info in matches if info.media_id == tag_meta.media_id), None)
if tagged is None:
return None
score, info = tagged
return _finish_fingerprint_recording(score, info, tag_meta, filename_meta)
ranked = sorted(matches, key=lambda item: item[0] if item[0] is not None else 1.0, reverse=True)
if truncated or (len(ranked) > 1 and round((ranked[0][0] or 0) - (ranked[1][0] or 0), 6) <= 0.05):
pending = MusicInfo.from_meta(meta)
pending.media_source, pending.media_id = None, None
pending.raw_data["recognition"] = {"status": "ambiguous", "message": "指纹对应多个合理的录音,请手动选择歌曲或专辑", "candidates": [
{"media_source": str(info.media_source), "media_id": info.media_id, "title": info.title, "artist": info.artist, "score": score}
for score, info in ranked[:5]
]}
return pending
score, info = ranked[0]
return _finish_fingerprint_recording(score, info, tag_meta, filename_meta)
def _finish_fingerprint_recording(
score: Optional[float], info: MusicInfo, tag_meta: Optional[MetaMusic], filename_meta: Optional[MetaMusic],
) -> MusicInfo:
"""
仅确认Recording;发行ID只能沿用实际标签,不能从指纹详情所选关联发行推断。
"""
if score is None:
# 旧插件的单ID回执沿用既有对象及文本核验契约,不伪造原生指纹结论。
return _reconcile_fingerprint_release(info, tag_meta, filename_meta)
result = deepcopy(_reconcile_fingerprint_release(info, tag_meta, filename_meta,
verified_audio=score is not None and score >= 0.98))
result.album_id = tag_meta.musicbrainz_release_group_id if tag_meta else None
result.field_sources.pop("album_id", None)
for key in ("musicbrainz_release_id", "musicbrainz_release_group_id", "musicbrainz_release_track_id"):
value = getattr(tag_meta, key, None)
setattr(result, key, value)
if value:
result.field_sources[key] = tag_meta.field_sources.get(key, "tag") if tag_meta else "tag"
else:
result.field_sources.pop(key, None)
result.raw_data["recognition"] = {"status": "matched", "method": "fingerprint", "score": score,
"identity_type": "recording", "release_verified": False}
return result
def _fingerprint_candidate_list(recording_id: Optional[str], observed: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
旧插件仍只给ID时保持原核验要求,不给它制造AcoustID分数。
"""
if observed:
return observed[:5]
return [{"recording_id": recording_id, "score": None, "duration": None}] if recording_id else []
def _matching_fingerprint(
candidate: dict[str, Any], info: Optional[MusicInfo],
tag_meta: Optional[MetaMusic], filename_meta: Optional[MetaMusic],
) -> bool:
"""
同步和异步候选采用同一证据核验规则。
"""
return _fingerprint_info_matches_evidence(info, tag_meta, filename_meta,
fingerprint_score=candidate.get("score"),
fingerprint_duration=candidate.get("duration"))
def _recognize_fingerprints(
path: Union[str, Path], meta: MetaMusic, tag_meta: Optional[MetaMusic], filename_meta: Optional[MetaMusic],
recognize: Callable[[MetaMusic, str], Optional[MusicInfo]],
) -> Optional[MusicInfo]:
"""
在一次有界调用中逐个核验原生候选,保留旧指纹插件的调用顺序。
"""
with capture_music_recognition(seconds=90, request_limit=16) as observation:
report_music_fingerprints([])
recording_id = AcoustIdChain().identify_music_by_fingerprint(path)
matches: list[tuple[Optional[float], MusicInfo]] = []
for candidate in _fingerprint_candidate_list(recording_id, observation.fingerprint_candidates):
info = recognize(meta, str(candidate["recording_id"]))
if info and _matching_fingerprint(candidate, info, tag_meta, filename_meta):
matches.append((candidate.get("score"), info))
return None if music_recognition_failed() else _select_fingerprint_info(
meta, matches, tag_meta, filename_meta, truncated=any(item.get("truncated") for item in observation.fingerprint_candidates),
)
async def _async_recognize_fingerprints(
path: Union[str, Path], meta: MetaMusic, tag_meta: Optional[MetaMusic], filename_meta: Optional[MetaMusic],
recognize: Callable[[MetaMusic, str], Awaitable[Optional[MusicInfo]]],
) -> Optional[MusicInfo]:
"""
异步路径使用相同候选集合、预算和最终歧义判断。
"""
with capture_music_recognition(seconds=90, request_limit=16) as observation:
report_music_fingerprints([])
recording_id = await AcoustIdChain().async_identify_music_by_fingerprint(path)
matches: list[tuple[Optional[float], MusicInfo]] = []
for candidate in _fingerprint_candidate_list(recording_id, observation.fingerprint_candidates):
info = await recognize(meta, str(candidate["recording_id"]))
if info and _matching_fingerprint(candidate, info, tag_meta, filename_meta):
matches.append((candidate.get("score"), info))
return None if music_recognition_failed() else _select_fingerprint_info(
meta, matches, tag_meta, filename_meta, truncated=any(item.get("truncated") for item in observation.fingerprint_candidates),
)
def _music_info_matches_text_evidence(
info: Optional[MusicInfo],
meta: Optional[MetaMusic],
) -> bool:
"""
统一校验版本、年份、ISRC和演奏证据,插件结果也不能绕过明确冲突。
"""
# 仅在本层音乐证据核验时加载所需的匹配规则。
from app.domain.music import ( # pylint: disable=import-outside-toplevel
music_artist_evidence_matches,
music_credit_conflicts,
music_isrc_conflicts,
music_isrc_matches,
music_version_matches,
music_year_matches,
)
if not _has_remote_music_identity(info) or not meta:
return False
return bool(music_version_matches(info, meta) and music_year_matches(info, meta)
and not music_credit_conflicts(info, meta)
and not music_isrc_conflicts(info, meta)
and (not any(music_credit_values(meta).values()) or music_isrc_matches(info, meta)
or music_artist_evidence_matches(info, 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 直查",
)
if music_recognition_is_blocked(direct):
return _MusicTierOutcome(info=direct, message="音乐识别需要重试或确认")
search_meta = _without_music_identity(meta)
if not search_meta.title:
return _MusicTierOutcome()
result = yield _MusicTierAction(
kind=_MusicTierActionKind.SEARCH,
meta=search_meta,
)
if music_recognition_is_blocked(result):
return _MusicTierOutcome(info=result, message="音乐识别需要重试或确认")
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) or music_recognition_is_blocked(info):
return info
info = yield _MusicPathAction(
kind=_MusicPathActionKind.TAG,
meta=tag_meta,
tier_name="文件标签",
)
if _has_remote_music_identity(info) or music_recognition_is_blocked(info):
return info
info = yield _MusicPathAction(
kind=_MusicPathActionKind.FILENAME,
meta=filename_meta,
tier_name="文件名",
)
if _has_remote_music_identity(info) or music_recognition_is_blocked(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):
"""
音频证据分层核验与统一媒体路径识别。
"""
@classmethod
def is_audio_path(cls, path: Union[str, Path]) -> bool:
"""
判断路径是否指向系统支持的音频文件。
"""
return Path(path).suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions
@classmethod
def read_path_meta(cls, path: Union[str, Path], *, storage: Optional[str] = "local") -> MetaMusic:
"""
本地文件读取音频证据;远端只解析名称,不能借用本机同名路径。
"""
file_path = Path(path)
if storage == "local" and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return AudioMetadataHelper.read_filename(file_path)
@classmethod
def _music_info_from_path_meta(cls, meta: MetaMusic) -> MusicInfo:
"""
把音频标签转换为文件管理可展示的最小音乐信息。
"""
return MusicInfo.from_meta(meta)
@staticmethod
def _merge_music_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo:
"""
将本地文件的实际音频参数合并到远端音乐身份识别结果。
"""
return _merge_music_audio_quality(info, meta)
@staticmethod
def _clear_music_identity(meta: MetaMusic) -> MetaMusic:
"""
复制音乐元数据并清除远程身份,供直查失败后按要素重新匹配。
"""
return _without_music_identity(meta)
@staticmethod
def _is_remote_music_info(info: Optional[MusicInfo]) -> TypeGuard[MusicInfo]:
"""
判断音乐识别结果是否携带可复用的远程身份。
"""
return _has_remote_music_identity(info)
def _recognize_musicbrainz_recording(
self,
meta: MetaMusic,
recording_id: str,
) -> Optional[MusicInfo]:
"""
按已知 MusicBrainz Recording ID 直接读取单曲详情。
"""
identity_meta = _musicbrainz_recording_meta(meta, recording_id)
return self.recognize_music_from_source(
media_source=MediaSource.MusicBrainz,
meta=identity_meta,
media_id=recording_id,
music_type=MUSIC_ENTITY_RECORDING,
)
async def _async_recognize_musicbrainz_recording(
self,
meta: MetaMusic,
recording_id: str,
) -> Optional[MusicInfo]:
"""
异步按已知 MusicBrainz 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,
media_id=recording_id,
music_type=MUSIC_ENTITY_RECORDING,
)
def _recognize_music_meta_tier(
self,
meta: Optional[MetaMusic],
media_source: Optional[MediaSource],
tier_name: str,
) -> Optional[MusicInfo]:
"""
识别单个音乐元数据证据层,标签中的 MBID 优先直查。
"""
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 music_recognition_is_blocked(outcome.info):
return outcome.info
if outcome.info and not _music_info_matches_text_evidence(outcome.info, meta):
logger.warning(
f"{tier_name}音乐候选与本地版本、发行年份或演奏证据不一致,已忽略:"
f"{outcome.info.artist} - {outcome.info.title} ({outcome.info.year or '-'})"
)
return None
if outcome.message:
logger.info(outcome.message)
return outcome.info
async def _async_recognize_music_meta_tier(
self,
meta: Optional[MetaMusic],
media_source: Optional[MediaSource],
tier_name: str,
) -> Optional[MusicInfo]:
"""
异步识别单个音乐元数据证据层,标签中的 MBID 优先直查。
"""
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 music_recognition_is_blocked(outcome.info):
return outcome.info
if outcome.info and not _music_info_matches_text_evidence(outcome.info, meta):
logger.warning(
f"{tier_name}音乐候选与本地版本、发行年份或演奏证据不一致,已忽略:"
f"{outcome.info.artist} - {outcome.info.title} ({outcome.info.year or '-'})"
)
return None
if outcome.message:
logger.info(outcome.message)
return outcome.info
def _music_album_dir_fallback(
self,
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 = self.recognize_music_album_directory(file_path.parent)
except Exception as err:
logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}")
return None
if isinstance(matched, MusicDirectoryMatch):
pending = MusicInfo(raw_data={"recognition": deepcopy(matched.recognition)})
if music_recognition_is_blocked(pending):
return pending
return matched.get(str(file_path.resolve()))
async def _async_music_album_dir_fallback(
self,
path: Union[str, Path],
) -> Optional[MusicInfo]:
"""
异步查找所在目录专辑匹配中属于当前文件的结果。
"""
file_path = Path(path)
if not await run_in_threadpool(_is_regular_file, file_path):
return None
try:
matched = await self.async_recognize_music_album_directory(file_path.parent)
except Exception as err:
logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}")
return None
if isinstance(matched, MusicDirectoryMatch):
pending = MusicInfo(raw_data={"recognition": deepcopy(matched.recognition)})
if music_recognition_is_blocked(pending):
return pending
return matched.get(str(file_path.resolve()))
def recognize_music_by_path(
self,
path: Union[str, Path],
media_source: Optional[MediaSource] = None,
contextual_meta: Optional[MetaMusic] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""
按指纹、文件标签、文件名三级顺序识别本地音乐。
"""
meta, tag_meta, filename_meta = AudioMetadataHelper.read_evidence(Path(path))
if meta.music_layout == "image_cue" or meta.organization_error:
# 整轨包含多个逻辑音轨,不能把开头的指纹 Recording 当作整个文件。
cue_info = MusicInfo.from_meta(meta)
return meta, cast(MusicInfo, self._finalize_recognition_result(cue_info, allow_enrichment=False))
filename_meta = _merge_music_path_evidence(filename_meta, meta)
if contextual_meta:
meta = _merge_contextual_music_evidence(meta, contextual_meta)
if tag_meta:
tag_meta = _merge_contextual_music_evidence(tag_meta, contextual_meta)
filename_meta = _merge_contextual_music_evidence(filename_meta, contextual_meta)
# 文件名层只负责提供曲名;即使调用方没有显式传目录上下文,也必须
# 继承 read_evidence 已确认的标签艺人、专辑、年份和版本,避免标签层
# 临时请求失败后退化成无约束的全库同名搜索。
filename_meta = _merge_contextual_music_evidence(filename_meta, 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:
info = _recognize_fingerprints(path, meta, tag_meta, filename_meta, self._recognize_musicbrainz_recording)
elif action.kind is _MusicPathActionKind.ALBUM:
info = self._music_album_dir_fallback(path)
if info and not music_recognition_is_blocked(info) and not _music_info_matches_text_evidence(info, meta):
logger.warning(
"音乐目录候选与本地版本、发行年份或演奏证据不一致,已忽略:"
f"{Path(path).name} -> {info.artist} - {info.album or info.title} "
f"({info.year or '-'})"
)
info = None
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)
simplified = self._simplify_recognized_music_info(result)
return meta, cast(
MusicInfo,
self._finalize_recognition_result(simplified),
)
async def async_recognize_music_by_path(
self,
path: Union[str, Path],
media_source: Optional[MediaSource] = None,
contextual_meta: Optional[MetaMusic] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""
异步按指纹、文件标签、文件名三级顺序识别本地音乐。
"""
meta, tag_meta, filename_meta = await run_in_threadpool(
AudioMetadataHelper.read_evidence,
Path(path),
)
if meta.music_layout == "image_cue" or meta.organization_error:
cue_info = MusicInfo.from_meta(meta)
return meta, cast(MusicInfo, await self._async_finalize_recognition_result(cue_info, allow_enrichment=False))
filename_meta = _merge_music_path_evidence(filename_meta, meta)
if contextual_meta:
meta = _merge_contextual_music_evidence(meta, contextual_meta)
if tag_meta:
tag_meta = _merge_contextual_music_evidence(tag_meta, contextual_meta)
filename_meta = _merge_contextual_music_evidence(filename_meta, contextual_meta)
filename_meta = _merge_contextual_music_evidence(filename_meta, 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:
info = await _async_recognize_fingerprints(path, meta, tag_meta, filename_meta, self._async_recognize_musicbrainz_recording)
elif action.kind is _MusicPathActionKind.ALBUM:
info = await self._async_music_album_dir_fallback(path)
if info and not music_recognition_is_blocked(info) and not _music_info_matches_text_evidence(info, meta):
logger.warning(
"音乐目录候选与本地版本、发行年份或演奏证据不一致,已忽略:"
f"{Path(path).name} -> {info.artist} - {info.album or info.title} "
f"({info.year or '-'})"
)
info = None
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)
simplified = self._simplify_recognized_music_info(result)
return meta, cast(
MusicInfo,
await self._async_finalize_recognition_result(simplified),
)
def _is_music_path_request(self, path: str, media_source: Optional[MediaSource]) -> bool:
"""
路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。
"""
return self.is_audio_path(path) or is_music_media_source(media_source)
def recognize_by_path(
self,
path: str,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息,影视与音乐统一入口
:param path: 文件路径
:param media_source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
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"{request.path} 未识别到媒体信息")
return _path_context(file_meta, mediainfo)
async def async_recognize_by_path(
self,
path: str,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息,影视与音乐统一入口(异步版本)
:param path: 文件路径
:param media_source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
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"{request.path} 未识别到媒体信息")
return _path_context(file_meta, mediainfo)