Files
Aqr-K bcbf79f030 fix(media): preserve recognition and rule mutation identities (#7144)
* fix(media): preserve recognition and rule mutation identities

* test: cover typed classification facts with alias policies

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:33:27 +08:00

3343 lines
141 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.
"""
MusicBrainz 音乐识别、发行版本匹配与艺术家信息查询。
"""
import asyncio
import re
import threading
import time
from copy import deepcopy
from dataclasses import dataclass
from difflib import SequenceMatcher
from itertools import chain
from typing import Any, Iterable, Optional, Tuple, TypeVar, Union
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.application.music.observation import (
BLOCKING_MUSIC_RECOGNITION_STATES,
capture_music_recognition,
music_recognition_diagnostics,
music_recognition_failed,
music_request_timeout,
music_wait_allowed,
report_music_recognition,
)
from app.application.music.recognition import (
async_enrich_music_artist_aliases,
enrich_music_artist_aliases,
unique_music_match,
)
from app.domain.classification.evaluator import read_fact
from app.domain.classification.facts import build_classification_facts
from app.domain.context import (
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
MusicRelease,
)
from app.domain.media import is_media_source_enabled, is_media_source_selected
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MUSIC_CREDIT_FIELDS, MetaMusic, music_credit_values
from app.domain.music import (
align_music_tracks,
music_album_artist_evidence_matches,
music_album_matches,
music_album_title_is_weak,
music_album_year_matches,
music_artist_affix_matches,
music_artist_evidence_matches,
music_base_title,
music_credit_conflicts,
music_isrc_conflicts,
music_isrc_matches,
music_query_artists,
music_text_key,
music_title_matches,
music_titles,
music_track_title_is_weak,
music_version_matches,
music_year_matches,
unique_music_texts,
)
from app.foundation.text import convert as zhconv_convert
from app.modules import _ModuleBase
from app.modules.musicbrainz.cache import MusicBrainzCache
from app.runtime.cache import cached
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.category import (
ClassificationEnrichmentMatch,
ClassificationEnrichmentRequest,
ClassificationEnrichmentResponse,
)
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_TYPES,
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
MusicEntityType,
)
_RECORDING_INCLUDES = "artists+releases+release-groups+isrcs+genres+aliases+artist-rels+work-rels+work-level-rels"
_RELEASE_RELATIONS = "+recording-level-rels+work-rels+work-level-rels+artist-rels"
_MusicEvidenceModel = TypeVar("_MusicEvidenceModel", MusicInfo, MusicAlbumInfo)
def _musicbrainz_artist_role(credits: dict[str, Any], relation: dict[str, Any], *, work: bool = False) -> None:
"""
只消费实际Artist关系;作品作曲者和录音演奏角色各归其位。
"""
artist = relation.get("artist") or {}
name = relation.get("target-credit") or artist.get("name")
kind = str(relation.get("type") or "").casefold()
if not artist.get("id") or not name:
return
if work:
if kind == "composer":
credits["composers"].append(str(name))
return
field = {"conductor": "conductors", "orchestra": "orchestras", "choir": "orchestras"}.get(kind)
if kind == "performer" and str(artist.get("type") or "").casefold() in {"orchestra", "choir"}:
field = "orchestras"
if field:
credits[field].append(str(name))
elif kind in {"instrument", "vocal", "performer"}:
role = ", ".join(str(item) for item in relation.get("attributes") or []) or kind
credits["performers"].setdefault(role, []).append(str(name))
def _musicbrainz_credits(payload: dict[str, Any]) -> dict[str, Any]:
"""
同次详情展开录音关系及其作品关系,不逐个作品额外请求或向所有曲目传播专辑角色。
"""
credits = music_credit_values({})
for relation in payload.get("relations") or []:
_musicbrainz_artist_role(credits, relation)
if relation.get("type") == "performance":
for related in (relation.get("work") or {}).get("relations") or []:
_musicbrainz_artist_role(credits, related, work=True)
return music_credit_values(credits)
def _remote_music_evidence(info: _MusicEvidenceModel) -> _MusicEvidenceModel:
"""
只为响应中实际存在的标准字段标记远端来源,不给空值制造证据。
"""
fields = ("title", "artists", "album", "album_artist", "year", "original_year", "release_year",
"release_date", "disc_number", "track_number", "total_tracks", "total_discs",
"version", "media_id", "musicbrainz_release_id", "musicbrainz_release_group_id",
"musicbrainz_release_track_id", *MUSIC_CREDIT_FIELDS)
info.field_sources.update({
key: "remote" for key in fields if getattr(info, key, None) not in (None, "", [], {})
})
return info
@dataclass(frozen=True, slots=True)
class _MusicBrainzRecognitionPlan:
"""
MusicBrainz 识别的来源身份、实体范围与缓存策略。
:param meta: 候选搜索的音乐线索;仅按身份查询详情时可为 None
:param media_source: 本次查询解析后的媒体来源
:param media_id: 详情查询使用的原生标识;None 表示需要候选搜索
:param music_type: 请求实体范围,recording 为单曲、album 为专辑;None 保留先单曲后专辑的兼容顺序
:param cache_enabled: 是否允许读取本地识别缓存,默认允许;纯详情计划关闭该标志
"""
meta: Optional[MetaMusic]
media_source: MediaSource
media_id: Optional[str]
music_type: Optional[str]
cache_enabled: bool = True
@property
def search_recording(self) -> bool:
"""
判断计划是否允许查询单曲。
:return: 仅当实体范围不是 album 时返回 True
"""
return bool(self.music_type != MUSIC_ENTITY_ALBUM)
@property
def search_album(self) -> bool:
"""
判断单曲未命中后是否允许尝试专辑。
:return: 仅当实体范围不是 recording 时返回 True
"""
return bool(self.music_type != MUSIC_ENTITY_RECORDING)
def require_meta(self) -> MetaMusic:
"""
读取候选搜索必需的音乐线索。
:return: 计划保存的音乐线索
:raises RuntimeError: 计划未提供 meta,调用方选错了执行分支
"""
if self.meta is None:
raise RuntimeError("MusicBrainz 候选识别计划缺少音乐元数据")
return self.meta
def require_media_id(self) -> str:
"""
读取详情查询必需的原生标识。
:return: 计划保存的来源标识
:raises RuntimeError: 计划未提供 media_id,调用方选错了执行分支
"""
if self.media_id is None:
raise RuntimeError("MusicBrainz 详情识别计划缺少原生 ID")
return self.media_id
def detail_kwargs(self) -> dict[str, str]:
"""
生成详情调用的可选实体参数,保持旧签名兼容。
:return: 已指定实体时返回含 music_type 的映射,未指定时为空映射
"""
return (
{"music_type": self.music_type}
if self.music_type is not None
else {}
)
@dataclass(frozen=True, slots=True)
class _MusicReleasePreference:
"""
发行版本选择时使用的地区与文字字形优先顺序。
:param regions: 按优先级排列的地区代码,未列出地区排在最后
:param scripts: 按优先级排列的字形代码,未列出字形排在最后
"""
regions: tuple[str, ...]
scripts: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class _MusicBrainzResponseDecision:
"""
单次响应解析结果及是否需要等待重试的决定。
:param payload: 解析后的业务响应;未得到结果时为 None,空映射可表示未找到
:param retry_delay: 重试前等待秒数;None 表示不再重试,默认 None
"""
payload: Optional[dict[str, Any]] = None
retry_delay: Optional[float] = None
@dataclass(frozen=True, slots=True)
class _MusicBrainzRequestPlan:
"""
同步与异步 MusicBrainz 查询共享的请求计划。
:param path: 相对 MusicBrainz 服务根地址的接口路径
:param params: 查询参数,包含实体展开范围或 JSON 响应格式
"""
path: str
params: dict[str, Any]
class MusicBrainzModule(_ModuleBase):
"""
通过 MusicBrainz 提供音乐元数据搜索和详情识别。
"""
_source = MediaSource.MusicBrainz
_base_url = "https://musicbrainz.org/ws/2"
_detail_url = "https://musicbrainz.org/recording"
_album_detail_url = "https://musicbrainz.org/release-group"
_artist_detail_url = "https://musicbrainz.org/artist"
_cover_url = "https://coverartarchive.org/release-group"
# 公共接口的相邻请求至少间隔一秒,同步与异步入口共享预留时间
_request_interval = 1.0
_request_lock = threading.Lock()
_last_request_at = 0.0
# 本地识别缓存,由模块管理器初始化时挂载
cache: Optional[MusicBrainzCache] = None
# 全局复用 HTTP 连接,减少重复 DNS 查询和 TLS 握手。
_request: Optional[RequestUtils] = None
_client_lock = threading.Lock()
# 服务端繁忙(429/5xx)时的重试次数与退避基数,重试间隔随次数翻倍递增
_busy_retries = 2
_busy_backoff = 5.0
# 关联艺术家按关系可读性排序,纪念性质的致敬关系数量庞大且价值低,放到最后
_artist_relation_priority = (
"member of band",
"subgroup",
"collaboration",
"founder",
"artist rename",
"supporting musician",
"conductor position",
"involved with",
"teacher",
"sibling",
"parent",
"married",
)
# 艺术家外链只保留对用户有意义的官方与流媒体入口
_artist_link_types = (
"official homepage",
"wikidata",
"wikipedia",
"discogs",
"allmusic",
"social network",
"free streaming",
"streaming",
"youtube",
"purchase for download",
)
@staticmethod
def _preference_values(
values: Optional[Iterable[str]],
setting_name: str,
*,
upper: bool,
) -> tuple[str, ...]:
"""
规范请求级代码;未传入时读取系统默认并稳定去重。
"""
configured: Iterable[str]
if values is None:
configured = str(get_runtime_setting(setting_name, "") or "").split(",")
else:
configured = values
normalized: list[str] = []
for value in configured:
code = str(value).strip()
code = code.upper() if upper else code.title()
if code and code not in normalized:
normalized.append(code)
return tuple(normalized[:3])
@classmethod
def _release_preference(
cls,
regions: Optional[Iterable[str]] = None,
scripts: Optional[Iterable[str]] = None,
) -> _MusicReleasePreference:
"""
返回本次发行选择的显式偏好或部署默认值。
"""
return _MusicReleasePreference(
regions=cls._preference_values(
regions,
"MUSIC_RELEASE_REGION_PRIORITY",
upper=True,
),
scripts=cls._preference_values(
scripts,
"MUSIC_RELEASE_SCRIPT_PRIORITY",
upper=False,
),
)
@staticmethod
def _release_script(release: dict[str, Any]) -> str:
"""
提取 MusicBrainz Release 的 ISO 15924 文字字形。
"""
representation = release.get("text-representation") or {}
return str(representation.get("script") or "").strip().title()
@classmethod
def _release_preference_sort_key(
cls,
release: dict[str, Any],
preference: _MusicReleasePreference,
) -> tuple[int, int]:
"""
生成地区与字形的稳定升序键;未匹配项排在显式偏好之后。
"""
country = str(release.get("country") or "").strip().upper()
script = cls._release_script(release)
region_rank = preference.regions.index(country) if country in preference.regions else len(preference.regions)
script_rank = preference.scripts.index(script) if script in preference.scripts else len(preference.scripts)
return region_rank, script_rank
def init_module(self) -> None:
"""
初始化 MusicBrainz 模块并挂载本地识别缓存。
"""
self.cache = MusicBrainzCache()
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""
MusicBrainz 无需独立密钥或启用开关。
"""
return None
def stop(self) -> None:
"""
持久化识别缓存并关闭复用的 HTTP 客户端。
缓存保存异常记录日志后继续关闭客户端;关闭后清空类级客户端引用。
"""
if self.cache:
try:
self.cache.save()
except Exception as err:
logger.error(f"保存音乐识别缓存失败:{str(err)}")
if self._request is not None:
self._request.close()
self.__class__._request = None
def scheduler_job(self) -> None:
"""
定时任务,每10分钟持久化一次音乐识别缓存。
"""
if self.cache:
self.cache.save()
def clear_cache(self) -> None:
"""
响应全局缓存清理事件,清空音乐识别缓存。
"""
logger.info("开始清除音乐识别缓存 ...")
self.music_cache_clear()
logger.info("音乐识别缓存清除完成")
def music_cache_items(self) -> list[dict[str, object]]:
"""
列出可供管理界面展示的音乐识别缓存。
:return: 按缓存键排序的条目列表;缓存未初始化时为空列表
"""
return self.cache.list_items() if self.cache else []
def music_cache_delete(self, cache_key: str) -> dict[str, object]:
"""
按键删除识别缓存,删除成功时同时清空 HTTP 请求缓存。
:param cache_key: 管理界面返回的完整缓存键
:return: 删除前的数据;键不存在或缓存未初始化时为空映射
"""
deleted = self.cache.delete(cache_key) if self.cache else {}
if deleted:
getattr(self._request_json, "cache_clear")()
return deleted
def music_cache_clear(self) -> None:
"""
清空全部音乐识别缓存。
"""
if self.cache:
self.cache.clear()
getattr(self._request_json, "cache_clear")()
def test(self) -> Tuple[bool, str]:
"""
测试 MusicBrainz 搜索接口连通性。
"""
result = self._request_json(
"/recording",
params={"query": "recording:test", "limit": 1, "fmt": "json"},
)
return (True, "") if result is not None else (False, "MusicBrainz 网络连接失败")
@staticmethod
def get_name() -> str:
"""
返回模块展示名称。
"""
return "MusicBrainz"
@staticmethod
def get_classification_enrichment_sources() -> tuple[MediaSource, ...]:
"""
声明本模块只能以 MusicBrainz 来源补充音乐标准事实。
"""
return (MediaSource.MusicBrainz,)
def get_media_classification_facts(
self,
request: ClassificationEnrichmentRequest,
) -> ClassificationEnrichmentResponse | None:
"""
按精确 ISRC 确认单曲身份,只补充请求中缺失的标准分类事实。
:param request: 分类补充请求,需为音乐类型并包含 isrc 外部标识
:return: 带来源和身份依据的事实响应;条件不满足或无法取得详情时返回 None
"""
if request.media_type != MediaType.MUSIC.value:
return None
isrc = request.external_ids.get("isrc")
if not isrc:
return None
payload = self._request_json(
"/recording",
params={
"query": f'isrc:"{self._escape_query(isrc)}"',
"limit": 5,
"fmt": "json",
},
)
candidate = next(
(
item
for item in self._project_recording_search(payload)
if item.media_id and self._same_text(item.isrc, isrc)
),
None,
)
if candidate is None:
return None
candidate_id = candidate.media_id
if not candidate_id:
return None
detail = self.recognize_music(
MediaSource.MusicBrainz,
candidate_id,
music_type=MUSIC_ENTITY_RECORDING,
)
if detail is None:
return None
facts = build_classification_facts(detail)
supplied = {}
for field_id in request.missing_fields:
value, missing = read_fact(facts, field_id)
if not missing:
supplied[field_id] = value
return ClassificationEnrichmentResponse(
media_source=MediaSource.MusicBrainz.value,
match=ClassificationEnrichmentMatch(
kind="external_id",
media_source="isrc",
media_id=isrc,
),
facts=supplied,
)
@staticmethod
def get_music_source() -> MediaSource:
"""
返回音乐识别使用的数据源标识。
"""
return MusicBrainzModule._source
@staticmethod
def get_type() -> ModuleType:
"""
返回模块所属的媒体识别类型。
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
返回 MusicBrainz 模块子类型。
"""
return MediaRecognizeType.MusicBrainz
@staticmethod
def get_priority() -> int:
"""
音乐识别在所有 MediaRecognize 模块中最先响应,避免音乐请求被影视模块误识别。
"""
return 0
def search_music(
self,
meta: MetaMusic,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
music_types: Optional[Iterable[MusicEntityType]] = None,
) -> Optional[list[MusicInfo]]:
"""
按请求实体搜索音乐候选,并交错合并不同实体结果。
中文标题和单独专辑搜索会扩大召回窗口再重排,最终仍按 limit 截断。
:param meta: 标题、艺术家、专辑及其他搜索线索
:param limit: 结果数量,限制在 1 至 100
:param media_source: 来源选择条件,不包含 MusicBrainz 时不响应
:param music_types: 允许的单曲、专辑、艺术家实体;空值使用全部,未知实体被忽略
:return: 统一音乐候选列表;来源未选中时为 None,无有效实体或候选时为空列表
"""
if not is_media_source_selected(media_source, self._source):
return None
normalized_limit = max(1, min(limit, 100))
requested_types = {
music_type
for music_type in (music_types or MUSIC_ENTITY_TYPES)
if music_type in MUSIC_ENTITY_TYPES
}
if not requested_types:
return []
# 中文逐字召回可能包含很多局部命中,扩大单次窗口后按完整名称重新排序。
fetch_limit = min(100, normalized_limit * 3) if self._QUERY_CJK_RE.search(meta.title or "") else normalized_limit
# 同名发行组远多于一页;专辑实体单独搜索时扩大召回,再用年份、艺人与发行类型重排。
album_fetch_limit = min(100, normalized_limit * 5) if requested_types == {MUSIC_ENTITY_ALBUM} else fetch_limit
recordings = (
self._rank_search_candidates(meta, self._search_recordings(meta, limit=fetch_limit))
if MUSIC_ENTITY_RECORDING in requested_types else []
)
albums = (
self._rank_album_search_candidates(meta, self._search_albums(meta, limit=album_fetch_limit))
if MUSIC_ENTITY_ALBUM in requested_types else []
)
artists = (
self._rank_search_candidates(meta, self._search_artists(meta, limit=fetch_limit))
if MUSIC_ENTITY_ARTIST in requested_types else []
)
groups = {
MUSIC_ENTITY_RECORDING: recordings,
MUSIC_ENTITY_ALBUM: albums,
MUSIC_ENTITY_ARTIST: artists,
}
return self._interleave_results(
*(groups[music_type] for music_type in (
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
) if music_type in requested_types),
limit=normalized_limit,
)
def search_persons(
self,
name: str,
media_source: Optional[MediaSourceSelection] = None,
) -> Optional[list[MusicArtistInfo]]:
"""
按名称查询最多 20 个 MusicBrainz 艺术家。
:param name: 艺术家名称,空值不查询
:param media_source: 启用的来源选择条件
:return: 艺术家列表;来源未启用时为 None,无名称或结果时为空列表
"""
if not is_media_source_enabled(media_source, self._source):
return None
if not name:
return []
return self._search_artist_infos(MetaMusic(title=name), limit=20)
async def async_search_persons(
self,
name: str,
media_source: Optional[MediaSourceSelection] = None,
) -> Optional[list[MusicArtistInfo]]:
"""
异步按名称查询最多 20 个 MusicBrainz 艺术家。
:param name: 艺术家名称,空值不查询
:param media_source: 启用的来源选择条件
:return: 艺术家列表;来源未启用时为 None,无名称或结果时为空列表
"""
if not is_media_source_enabled(media_source, self._source):
return None
if not name:
return []
return await self._async_search_artist_infos(MetaMusic(title=name), limit=20)
@staticmethod
def _rank_search_candidates(meta: MetaMusic, candidates: list[MusicInfo]) -> list[MusicInfo]:
"""
按完整作品名及输入署名排序浏览候选,不把逐字 OR 命中视为精确身份。
"""
expected = music_text_key(meta.title)
expected_artists = {music_text_key(artist) for artist in meta.artists}
def score(info: MusicInfo) -> tuple[bool, float, bool]:
"""
整名优先;普通组合输入同时比较艺术家与作品名,原始顺序用于同分稳定排序。
"""
names = [info.title, *(info.title_aliases or [])]
if not meta.artists:
names.extend(f"{artist} {info.title or ''}" for artist in [*info.artists, *(info.artist_aliases or [])])
keys = [music_text_key(name) for name in names if name]
artist_match = bool(expected_artists & {
music_text_key(artist) for artist in [*info.artists, *(info.artist_aliases or [])]
})
similarity = max((SequenceMatcher(None, expected, key).ratio() for key in keys), default=0.0)
return expected in keys, similarity, artist_match
return sorted(candidates, key=score, reverse=True)
@staticmethod
def _rank_album_search_candidates(meta: MetaMusic, candidates: list[MusicInfo]) -> list[MusicInfo]:
"""
按完整专辑名、艺人、年份和发行类型排序 Release Group 候选。
"""
expected = music_text_key(meta.album or meta.title)
expected_artists = {music_text_key(artist) for artist in meta.artists}
def score(info: MusicInfo) -> tuple[bool, bool, bool, bool, bool, float]:
"""
按专辑完整标题、署名、年份和发行类型生成稳定排序权重。
:param info: 统一音乐格式的专辑候选
:return: 用于候选排序的匹配权重
"""
names = [info.title, *(info.title_aliases or [])]
keys = [music_text_key(name) for name in names if name]
candidate_artists = {
music_text_key(artist)
for artist in [*info.artists, *(info.artist_aliases or [])]
}
similarity = max((SequenceMatcher(None, expected, key).ratio() for key in keys), default=0.0)
return (
expected in keys,
bool(expected_artists and expected_artists & candidate_artists),
bool(meta.year and info.year and int(meta.year) == int(info.year)),
str(info.album_type or "").casefold() == "album",
not bool(info.secondary_types),
similarity,
)
return sorted(candidates, key=score, reverse=True)
def _search_recordings(self, meta: MetaMusic, limit: int, require_match: bool = False) -> list[MusicInfo]:
"""
查询 Recording;自动识别须确认身份才停止,手动浏览仍保留原始候选。
"""
credit_details: dict[str, Optional[dict[str, Any]]] = {}
for query in self._recording_queries(meta):
payload = self._request_json(
"/recording",
params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"},
)
results = self._project_recording_search(payload)
if require_match:
self._enrich_recording_credits(meta, results, credit_details)
enrich_music_artist_aliases(
((meta, item) for item in results), self._source,
lambda identity: self._lookup_artist_aliases([identity], []),
)
if results and (not require_match or self._select_candidate(meta, results, self._source)):
return results
return []
async def _async_search_recordings(
self,
meta: MetaMusic,
limit: int,
require_match: bool = False,
) -> list[MusicInfo]:
"""
异步查询 Recording,与同步入口共用候选准入和停止条件。
"""
credit_details: dict[str, Optional[dict[str, Any]]] = {}
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 = self._project_recording_search(payload)
if require_match:
await self._async_enrich_recording_credits(meta, results, credit_details)
await async_enrich_music_artist_aliases(
((meta, item) for item in results), self._source,
lambda identity: self._async_lookup_artist_aliases([identity], []),
)
if results and (not require_match or self._select_candidate(meta, results, self._source)):
return results
return []
@classmethod
def _project_recording_search(
cls, payload: Optional[dict[str, Any]]
) -> list[MusicInfo]:
"""
把 Recording 搜索响应投影为统一音乐候选。
"""
return [
info
for item in (payload or {}).get("recordings") or []
if (info := cls._recording_to_info(item))
]
@classmethod
def _recording_credit_targets(cls, meta: MetaMusic, candidates: list[MusicInfo]) -> dict[str, list[MusicInfo]]:
"""
本地存在角色证据且搜索摘要缺失时,最多核验三个不同录音,沿用现有请求预算。
"""
roles = [key for key, value in music_credit_values(meta).items() if value]
targets: dict[str, list[MusicInfo]] = {}
if not roles:
return targets
for candidate in candidates:
identity = candidate.media_id
if not identity or candidate.media_source != cls._source or all(getattr(candidate, key) for key in roles):
continue
if not music_title_matches(candidate, meta.title) or not music_version_matches(candidate, meta) or not music_year_matches(candidate, meta):
continue
if identity in targets or len(targets) < 3:
targets.setdefault(identity, []).append(candidate)
return targets
@staticmethod
def _apply_recording_credits(identity: str, candidates: list[MusicInfo], payload: Optional[dict[str, Any]]) -> None:
"""
只把同一Recording详情中的非空角色补入本次摘要副本,不改变其主身份。
"""
if not payload or payload.get("id") != identity:
return
credits = {key: value for key, value in _musicbrainz_credits(payload).items() if value}
for candidate in candidates:
candidate.__dict__.update(deepcopy(credits))
_remote_music_evidence(candidate)
def _enrich_recording_credits(self, meta: MetaMusic, candidates: list[MusicInfo],
details: dict[str, Optional[dict[str, Any]]]) -> None:
"""
检索阶梯共用三个录音的补证上限,失败身份同样计数,普通音乐不新增请求。
"""
for identity, targets in self._recording_credit_targets(meta, candidates).items():
if identity not in details and len(details) < 3:
details[identity] = self._request_json(f"/recording/{identity}", params={"inc": _RECORDING_INCLUDES, "fmt": "json"})
self._apply_recording_credits(identity, targets, details.get(identity))
async def _async_enrich_recording_credits(self, meta: MetaMusic, candidates: list[MusicInfo],
details: dict[str, Optional[dict[str, Any]]]) -> None:
"""
异步补证使用相同候选上限、身份核验、缓存与请求预算。
"""
for identity, targets in self._recording_credit_targets(meta, candidates).items():
if identity not in details and len(details) < 3:
details[identity] = await self._async_request_json(f"/recording/{identity}", params={"inc": _RECORDING_INCLUDES, "fmt": "json"})
self._apply_recording_credits(identity, targets, details.get(identity))
@classmethod
def _recording_queries(cls, meta: MetaMusic) -> list[str]:
"""
构造 Recording 检索式阶梯,由严到宽逐级放宽避免零命中。
条目括号多为全角、艺术家署名存在变体(如外文艺名),精确 AND 条件容易零命中;
放宽后由候选评分负责收紧(已知艺术家时必须艺术家命中),不会产生错误身份。
"""
title = cls._search_title(meta.title)
if not title:
return []
query_artists = music_query_artists(meta)
artist = query_artists[0] if query_artists else None
# 括号内的影视关联说明、版本说明多为半角,与条目全角写法不一致,准备去注释曲名兜底
bare_title = cls._strip_parenthetical(title)
# 曲名开头的艺术家署名前缀是命名习惯不是曲名内容,用主体名检索
title = cls._strip_artist_prefix(title, meta.artists)
bare_title = cls._strip_artist_prefix(bare_title, meta.artists)
queries: list[str] = []
for query in [
cls._build_query(meta),
f"recording:{cls._query_phrase(title)}" if title else None,
f'recording:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}'
if artist and bare_title and bare_title != title else None,
# 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索,
# 候选挑选阶段要求艺术家命中兜住同名异曲
f"recording:{cls._query_phrase(bare_title)}" if bare_title else None,
f"recording:{cls._query_phrase(bare_title or title, loose=True)}"
if cls._QUERY_CJK_RE.search(title) else None,
]:
if query and query not in queries:
queries.append(query)
return queries
@classmethod
def _strip_parenthetical(cls, value: Optional[str]) -> str:
"""
去除标题中的括号注释,保留主体曲名;空标题返回空字符串。
"""
text = re.sub(r"[\((][^\))]*[\))]", " ", str(value or ""))
return cls._normalize_text(text)
@classmethod
def _main_title(cls, value: Optional[str]) -> str:
"""
提取冒号副标题结构的主标题,兼容全角/半角冒号。
"""
text = re.split(r"\s*[::]\s*", str(value or ""), maxsplit=1)[0]
return cls._normalize_text(text)
@classmethod
def _head_title(cls, value: Optional[str]) -> str:
"""
提取候选「曲名-歌手」「曲名《巡演名》…」命名的首段曲名。
"""
text = re.split(r"\s*[-–—−-::]\s*|\s*《", str(value or ""), maxsplit=1)[0]
return cls._normalize_text(text)
@classmethod
def _lead_token(cls, value: Optional[str]) -> str:
"""
提取候选标题首个空白分隔段(「愛情電影主題曲 雲且留住」的主体名)。
"""
text = str(value or "").strip()
return cls._normalize_text(text.split(" ", 1)[0]) if text else ""
# 系列专辑的卷号后缀(好歌茹芸, Vol. 3)是发行分卷标记,条目本体不含卷号
_VOLUME_SUFFIX_RE = re.compile(r",?\s*vol\.?\s*\d+$", re.IGNORECASE)
# 卷号提取:标题任意位置的 Vol. N 写法,系列专辑弱匹配时用于分卷一致性校验
_VOLUME_NUMBER_RE = re.compile(r"vol\.?\s*(\d+)", re.IGNORECASE)
@classmethod
def _strip_volume_suffix(cls, value: Optional[str]) -> str:
"""
剔除标题尾部的卷号后缀,返回专辑本体名。
"""
return cls._normalize_text(cls._VOLUME_SUFFIX_RE.sub("", str(value or "")))
@classmethod
def _volume_number(cls, value: Optional[str]) -> Optional[str]:
"""
提取标题中的卷号(Vol. 3 -> 3),无卷号返回 None。
"""
match = cls._VOLUME_NUMBER_RE.search(str(value or ""))
return match.group(1) if match else None
# 原声带资源标题的通用描述词尾部(条目本体是电影名);
# 尾部描述词保留时作为原声带形态标记参与弱匹配判定
_SOUNDTRACK_SUFFIX_RE = re.compile(
r"\s*(?:original\s+motion\s+picture|motion\s+picture)?\s*"
r"(?<![A-Za-z0-9])(?:original\s+)?(?:soundtrack|score|ost)$",
re.IGNORECASE,
)
@classmethod
def _soundtrack_body(cls, value: Optional[str]) -> str:
"""
剔除原声带标题尾部的通用描述词,返回电影名本体;无描述词返回空串。
"""
text = str(value or "").strip()
body = cls._SOUNDTRACK_SUFFIX_RE.sub("", text)
if not body or body == text:
return ""
return cls._normalize_text(body)
# 演唱会资源的标题常带演出后缀(S.H.E十七音乐会),条目仅保留演出名本体
_PERFORMANCE_SUFFIX_RE = re.compile(
r"\s*(?:音乐会|音樂會|演唱会|演唱會|巡回|巡演|Live|Tour)$", re.IGNORECASE)
@classmethod
def _performance_title(cls, value: Optional[str]) -> str:
"""
剔除标题尾部的演出形态后缀,返回演出名本体。
"""
return cls._normalize_text(cls._PERFORMANCE_SUFFIX_RE.sub("", str(value or "")))
@classmethod
def _strip_artist_prefix(cls, title: Optional[str], artists: Optional[list[str]]) -> str:
"""
剥离完整署名前缀,不截断单词;保留中文连写与“的/之”署名习惯。
"""
text = str(title or "").strip()
for artist in artists or []:
artist = str(artist or "").strip()
if len(artist) < 2:
continue
if text.startswith(artist) and music_artist_affix_matches(text, artist):
remainder = text[len(artist):].lstrip(" \t-–—−-/|::;;")
remainder = re.sub(r"^[的之]\s*", "", remainder).strip()
if remainder:
return remainder
return text
def _search_albums(self, meta: MetaMusic, limit: int, require_match: bool = False) -> list[MusicInfo]:
"""
查询 Release Group;自动识别只在存在可确认专辑时停止检索式回退。
"""
for query in self._album_queries(meta):
payload = self._request_json(
"/release-group",
params={
"query": query,
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
)
results = self._project_album_search(payload)
if require_match:
enrich_music_artist_aliases(
((meta, item) for item in results), self._source,
lambda identity: self._lookup_artist_aliases([identity], []),
)
if results and (not require_match or self._select_album_candidate(meta, results)):
return results
return []
async def _async_search_albums(
self,
meta: MetaMusic,
limit: int,
require_match: bool = False,
) -> 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 = self._project_album_search(payload)
if require_match:
await async_enrich_music_artist_aliases(
((meta, item) for item in results), self._source,
lambda identity: self._async_lookup_artist_aliases([identity], []),
)
if results and (not require_match or self._select_album_candidate(meta, results)):
return results
return []
@classmethod
def _project_album_search(
cls, payload: Optional[dict[str, Any]]
) -> list[MusicInfo]:
"""
把 Release Group 搜索响应投影为统一音乐候选。
"""
return [
album.to_music_info()
for item in (payload or {}).get("release-groups") or []
if (album := cls._release_group_to_album(item))
]
@classmethod
def _album_queries(cls, meta: MetaMusic) -> list[str]:
"""
构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。
"""
original_title = cls._search_title(meta.album or meta.title, preserve_script=True)
title = cls._search_title(meta.album or meta.title)
if not title:
return []
artist = meta.artists[0] if meta.artists else None
# 括号注释与 Vol. 卷号在条目中常以 disambiguation 形式存在,变体名兜底检索
bare_title = cls._strip_parenthetical(title)
bare_title = cls._strip_volume_suffix(bare_title)
# 专辑名开头的艺术家署名前缀同样是命名习惯,用主体名检索
title = cls._strip_artist_prefix(title, meta.artists)
bare_title = cls._strip_artist_prefix(bare_title, meta.artists)
# 原声带标题的通用描述词(Original Motion Picture Soundtrack)在条目中常省略,
# 用电影名本体补充一级检索(The Hateful Eight / Pulp Fiction);
# 本体过短(The Score 类)时不作为独立检索目标避免噪声
soundtrack_body = cls._soundtrack_body(bare_title)
if len(cls._match_text(soundtrack_body)) < 4:
soundtrack_body = ""
queries: list[str] = []
for query in [
f'releasegroup:{cls._query_phrase(original_title)} AND artist:{cls._query_phrase(artist)}'
if artist else f"releasegroup:{cls._query_phrase(original_title)}",
f'releasegroup:{cls._query_phrase(title)} AND artist:{cls._query_phrase(artist)}'
if artist else None,
f"releasegroup:{cls._query_phrase(title)}" if title else None,
f'releasegroup:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}'
if artist and bare_title and bare_title != title else None,
f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:{cls._query_phrase(artist)}'
if artist and soundtrack_body else None,
f"releasegroup:{cls._query_phrase(soundtrack_body)}" if soundtrack_body else None,
# 署名变体兜底:仅按去注释专辑名检索,挑选阶段要求艺术家同时命中
f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None,
f"releasegroup:{cls._query_phrase(bare_title or title, loose=True)}"
if cls._QUERY_CJK_RE.search(title) else None,
]:
if query and query not in queries:
queries.append(query)
return queries
def _search_artist_infos(self, meta: MetaMusic, limit: int) -> list[MusicArtistInfo]:
"""
按用户输入中的艺术家部分搜索标准 Artist 信息。
"""
artist_name = meta.artists[0] if meta.artists else meta.title
phrase = self._query_phrase(artist_name)
if not phrase:
return []
payload = self._request_json(
"/artist",
params={"query": f"artist:{phrase}", "limit": max(1, min(limit, 100)), "fmt": "json"},
)
return [
artist
for item in (payload or {}).get("artists") or []
if (artist := self._artist_to_info(item, include_raw=True))
]
async def _async_search_artist_infos(self, meta: MetaMusic, limit: int) -> list[MusicArtistInfo]:
"""
异步按用户输入中的艺术家部分搜索标准 Artist 信息。
"""
artist_name = meta.artists[0] if meta.artists else meta.title
phrase = self._query_phrase(artist_name)
if not phrase:
return []
payload = await self._async_request_json(
"/artist",
params={"query": f"artist:{phrase}", "limit": max(1, min(limit, 100)), "fmt": "json"},
)
return [
artist
for item in (payload or {}).get("artists") or []
if (artist := self._artist_to_info(item, include_raw=True))
]
def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""
按用户输入中的艺术家部分搜索音乐卡片候选。
"""
return [artist.to_music_info() for artist in self._search_artist_infos(meta, limit)]
@staticmethod
def _interleave_results(*groups: list[MusicInfo], limit: int) -> list[MusicInfo]:
"""
按实体轮询合并结果,避免单曲数量占满全局搜索页。
"""
results: list[MusicInfo] = []
index = 0
while len(results) < limit and any(index < len(group) for group in groups):
for group in groups:
if index < len(group):
results.append(group[index])
if len(results) >= limit:
break
index += 1
return results
def match_music_album(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int = 5,
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
) -> Optional[MusicAlbumInfo]:
"""
根据目录线索和曲目证据匹配具体发行版本。
先获取候选发行详情,再比较曲目数、时长、署名与版本证据。
任何必要详情请求失败都会放弃本次匹配,避免在不完整候选中错误确认。
:param meta: 专辑线索,可携带明确的发行标识;含整理错误时不查询
:param tracks: 本地曲目线索,至少需要一条
:param limit: 候选发行搜索的数量预算
:param music_release_regions: 发行地区优先顺序,省略时使用运行配置
:param music_release_scripts: 发行文字字形优先顺序,省略时使用运行配置
:return: 带完整曲目表的确认专辑;无合格结果、版本歧义或查询失败时返回 None
"""
with capture_music_recognition():
if not tracks or meta.organization_error:
return None
preference = self._release_preference(
music_release_regions,
music_release_scripts,
)
details: list[dict[str, Any]] = []
releases = [{"id": meta.musicbrainz_release_id}] if meta.musicbrainz_release_id else self._search_release_candidates(
meta,
tracks,
limit=limit,
preference=preference,
)
for request in self._release_detail_requests(releases):
detail = self._request_json(request.path, params=request.params)
if detail is None or music_recognition_failed():
report_music_recognition("service_error", "音乐发行详情暂时无法获取,请稍后重试")
return None
if not detail:
continue
details.append(detail)
albums = {str(detail["id"]): album for detail in details if (album := self._release_to_album(detail))}
pairs: Iterable[tuple[MetaMusic, MusicInfo | MusicAlbumInfo]] = chain(
((meta, album) for album in albums.values()),
((local, remote) for album in albums.values() for remote in album.tracks for local in tracks),
)
enrich_music_artist_aliases(pairs, self._source, lambda identity: self._lookup_artist_aliases([identity], []))
if music_recognition_failed():
return None
return self._select_release_match(meta, tracks, details, preference, albums)
async def async_match_music_album(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int = 5,
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
) -> Optional[MusicAlbumInfo]:
"""
异步根据目录线索和曲目证据匹配具体发行版本。
先获取候选发行详情,再比较曲目数、时长、署名与版本证据。
任何必要详情请求失败都会放弃本次匹配,避免在不完整候选中错误确认。
:param meta: 专辑线索,可携带明确的发行标识;含整理错误时不查询
:param tracks: 本地曲目线索,至少需要一条
:param limit: 候选发行搜索的数量预算
:param music_release_regions: 发行地区优先顺序,省略时使用运行配置
:param music_release_scripts: 发行文字字形优先顺序,省略时使用运行配置
:return: 带完整曲目表的确认专辑;无合格结果、版本歧义或查询失败时返回 None
"""
with capture_music_recognition():
if not tracks or meta.organization_error:
return None
preference = self._release_preference(
music_release_regions,
music_release_scripts,
)
details: list[dict[str, Any]] = []
releases = [{"id": meta.musicbrainz_release_id}] if meta.musicbrainz_release_id else await self._async_search_release_candidates(
meta,
tracks,
limit=limit,
preference=preference,
)
for request in self._release_detail_requests(releases):
detail = await self._async_request_json(
request.path, params=request.params
)
if detail is None or music_recognition_failed():
report_music_recognition("service_error", "音乐发行详情暂时无法获取,请稍后重试")
return None
if not detail:
continue
details.append(detail)
albums = {str(detail["id"]): album for detail in details if (album := self._release_to_album(detail))}
pairs: Iterable[tuple[MetaMusic, MusicInfo | MusicAlbumInfo]] = chain(
((meta, album) for album in albums.values()),
((local, remote) for album in albums.values() for remote in album.tracks for local in tracks),
)
await async_enrich_music_artist_aliases(pairs, self._source, lambda identity: self._async_lookup_artist_aliases([identity], []))
if music_recognition_failed():
return None
return self._select_release_match(meta, tracks, details, preference, albums)
@classmethod
def _select_release_match(
cls,
meta: MetaMusic,
tracks: list[MetaMusic],
details: Iterable[dict[str, Any]],
preference: Optional[_MusicReleasePreference] = None,
albums: Optional[dict[str, MusicAlbumInfo]] = None,
) -> Optional[MusicAlbumInfo]:
"""
统一比较发行;复用本次已补证的模型,避免重新投影丢失Artist别名。
"""
selected_preference = preference or cls._release_preference()
ranked: list[tuple[float, tuple[int, int], MusicAlbumInfo]] = []
seen: set[str] = set()
for detail in details:
release_id = str(detail.get("id") or "")
if not release_id or release_id in seen:
continue
seen.add(release_id)
summary = cls._release_track_summary(detail)
album = albums.get(release_id) if albums is not None else cls._release_to_album(detail)
score = cls._score_release(meta, tracks, detail, summary, album=album)
if album and score > 0:
ranked.append((score, cls._release_preference_sort_key(detail, selected_preference), album))
if not ranked:
report_music_recognition("not_found", "没有候选发行通过曲目信息核对")
return None
ranked.sort(key=lambda entry: (-entry[0], entry[1]))
best_score, _preference, best = ranked[0]
if best_score < cls._album_match_threshold:
return None
equivalent = cls._release_content_key(best)
for score, _rank, candidate in ranked[1:]:
if best_score - score <= 5 and cls._release_content_key(candidate) != equivalent:
report_music_recognition("ambiguous", "多个发行版本均符合现有信息,请手动选择专辑版本", [
{"release_id": item.musicbrainz_release_id, "album_id": item.media_id,
"title": item.title, "artist": item.artist, "year": item.year, "score": round(points, 2)}
for points, _preference_rank, item in ranked[:5]
])
return None
best.raw_data.update(match_score=round(best_score, 2), match_coverage=1.0,
match_basis="release_id" if meta.musicbrainz_release_id else "track_evidence")
report_music_recognition("matched", "专辑及当前曲目已匹配")
return best
@staticmethod
def _release_content_key(album: MusicAlbumInfo) -> tuple[Optional[str], tuple[tuple[int, int, str], ...]]:
"""
同发行组且录音序列一致的文字/地区版本可按偏好选取,其他近分结果保持歧义。
"""
return (album.musicbrainz_release_group_id or album.musicbrainz_release_id, tuple(sorted(
(track.disc_number or 1, track.track_number or 0, track.media_id or "")
for track in album.tracks
)))
_album_match_threshold = 60.0
def _search_release_candidates(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int,
preference: Optional[_MusicReleasePreference] = None,
) -> list[dict[str, Any]]:
"""
按专辑名和曲名线索搜索候选发行版本,多个查询按命中顺序去重。
"""
releases: list[dict[str, Any]] = []
seen: set[str] = set()
search_limit = max(25, min(100, limit * 5))
for request in self._release_search_requests(meta, tracks, search_limit):
payload = self._request_json(
request.path, params=request.params
)
self._merge_release_candidates(releases, seen, payload)
if payload is None or music_recognition_failed():
report_music_recognition("service_error", "音乐元数据服务暂时不可用,请稍后重试")
return []
return self._rank_release_candidates(
releases,
preference or self._release_preference(),
)[:limit]
async def _async_search_release_candidates(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int,
preference: Optional[_MusicReleasePreference] = None,
) -> list[dict[str, Any]]:
"""
异步按专辑名和曲名线索搜索并去重候选发行版本。
"""
releases: list[dict[str, Any]] = []
seen: set[str] = set()
search_limit = max(25, min(100, limit * 5))
for request in self._release_search_requests(meta, tracks, search_limit):
payload = await self._async_request_json(
request.path, params=request.params
)
self._merge_release_candidates(releases, seen, payload)
if payload is None or music_recognition_failed():
report_music_recognition("service_error", "音乐元数据服务暂时不可用,请稍后重试")
return []
return self._rank_release_candidates(
releases,
preference or self._release_preference(),
)[:limit]
@classmethod
def _rank_release_candidates(
cls,
releases: list[dict[str, Any]],
preference: _MusicReleasePreference,
) -> list[dict[str, Any]]:
"""
在相近搜索相关度内优先目标地区与字形,保留强相关性边界。
"""
def sort_key(release: dict[str, Any]) -> tuple[int, int, int, int, int]:
"""
同等相关度先看多首录音的共同支持,再应用地区和字形偏好。
"""
try:
score = int(release.get("score") or 0)
except (TypeError, ValueError):
score = 0
region_rank, script_rank = cls._release_preference_sort_key(
release,
preference,
)
return -(score // 5), -len(release.get("_recording_hits") or []), region_rank, script_rank, -score
return sorted(releases, key=sort_key)
@staticmethod
def _merge_release_candidates(
releases: list[dict[str, Any]],
seen: set[str],
payload: Optional[dict[str, Any]],
) -> None:
"""
合并发行搜索与录音搜索关联的发行,保留不同录音共同支持的证据。
"""
candidates = list((payload or {}).get("releases") or [])
for recording in (payload or {}).get("recordings") or []:
candidates.extend({**release, "score": recording.get("score", 0),
"_recording_hits": [recording["id"]] if recording.get("id") else []}
for release in recording.get("releases") or [])
existing = {item["id"]: item for item in releases}
for item in candidates:
release_id = item.get("id")
if release_id and release_id not in seen:
seen.add(release_id)
copied = dict(item)
releases.append(copied)
existing[release_id] = copied
elif release_id and release_id in existing:
previous = existing[release_id]
previous["_recording_hits"] = sorted(set(previous.get("_recording_hits") or []) | set(item.get("_recording_hits") or []))
try:
previous["score"] = max(int(previous.get("score") or 0), int(item.get("score") or 0))
except (TypeError, ValueError):
pass
@classmethod
def _release_search_requests(
cls,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int,
) -> list[_MusicBrainzRequestPlan]:
"""
曲名必须查询 recording 索引,再从响应提取发行;release 索引不支持 recording 字段。
"""
normalized_limit = max(1, min(limit, 25))
return [
_MusicBrainzRequestPlan(
path="/recording" if query.startswith("(") else "/release",
params={
"query": query,
"limit": normalized_limit,
"fmt": "json",
},
)
for query in cls._release_queries(meta, tracks)
]
@staticmethod
def _release_detail_requests(
releases: Iterable[dict[str, Any]],
) -> list[_MusicBrainzRequestPlan]:
"""
按候选顺序构造发行详情请求计划并跳过无 ID 条目。
"""
return [
_MusicBrainzRequestPlan(
path=f"/release/{release_id}",
params={
# Release lookup 默认只返回 Release Group 的最小引用,
# 不包含 primary-type / secondary-types。目录级专辑识别
# 后续需要这些字段执行音乐分类,因此必须显式展开。
"inc": "recordings+media+artist-credits+release-groups+isrcs" + _RELEASE_RELATIONS,
"fmt": "json",
},
)
for release in releases
if (release_id := release.get("id"))
]
@classmethod
def _release_queries(cls, meta: MetaMusic, tracks: list[MetaMusic]) -> list[str]:
"""
构造专辑搜索表达式:优先专辑名+歌手,无专辑线索时用曲名兜底。
"""
queries: list[str] = []
if meta.musicbrainz_release_group_id:
return [f'rgid:{cls._escape_query(meta.musicbrainz_release_group_id)}']
album_title = None if music_album_title_is_weak(meta) else meta.album or meta.title
artist = meta.artists[0] if meta.artists else meta.album_artist
if album_title:
if artist:
queries.append(
f'release:"{cls._escape_query(album_title)}" AND artist:"{cls._escape_query(artist)}"'
)
queries.append(f'release:"{cls._escape_query(album_title)}"')
# 目录名无意义时(如 Various Artists 合集),用代表性曲名反查所属发行版本
titles = cls._unique_texts(
[track.title for track in tracks if track.title and not music_track_title_is_weak(track)]
)[:3]
if titles:
recording_clause = " OR ".join(
f'recording:"{cls._escape_query(title)}"' for title in titles
)
query = f"({recording_clause})"
if artist:
query += f' AND artist:"{cls._escape_query(artist)}"'
queries.append(query)
return queries
@classmethod
def _release_track_summary(cls, detail: dict[str, Any]) -> list[dict[str, Any]]:
"""
提取发行版本的曲目概要(碟号、曲序、时长、标题)供打分使用。
"""
summary: list[dict[str, Any]] = []
for medium in detail.get("media") or []:
disc = cls._optional_int(medium.get("position")) or 1
for track in medium.get("tracks") or []:
recording = track.get("recording") or {}
summary.append({
"disc": disc,
"position": cls._optional_int(track.get("position")),
"length": cls._duration_seconds(
track.get("length") or recording.get("length")
),
"title": track.get("title") or recording.get("title"),
})
return summary
@classmethod
def _score_release(
cls,
meta: MetaMusic,
tracks: list[MetaMusic],
detail: dict[str, Any],
summary: list[dict[str, Any]],
*, album: Optional[MusicAlbumInfo] = None,
) -> float:
"""
给候选发行版本打分(0-100),综合标题、歌手、曲目数和时长相似度。
"""
album = album or cls._release_to_album(detail)
if not album or not tracks or len(album.tracks) != len(summary):
return 0.0
for field in ("musicbrainz_release_id", "musicbrainz_release_group_id"):
expected = getattr(meta, field)
if expected and expected != getattr(album, field):
return 0.0
if not music_album_year_matches(album.to_music_info(), meta):
return 0.0
known_title = not music_album_title_is_weak(meta)
title_sim = cls._text_similarity(meta.album or meta.title, album.title) if known_title else 0.0
if known_title and title_sim < 0.7 and not music_album_matches(album.to_music_info(), meta.album or meta.title):
return 0.0
artists = [meta.album_artist] if meta.album_artist else meta.artists
artist_match = music_album_artist_evidence_matches(album, meta)
if (artists or any(music_credit_values(meta).values())) and not artist_match:
return 0.0
if meta.version and not music_version_matches(album.to_music_info(), meta):
return 0.0
aligned = align_music_tracks(tracks, album.tracks)
if len(aligned) != len(tracks):
return 0.0
# 曲序只是槽位。无标签曲名且没有可比时长时,除非提供具体发行ID,否则不能确认内容。
named = sum(not music_track_title_is_weak(track) for track in tracks)
durations = [
cls._duration_similarity(tracks[local].duration, album.tracks[remote].duration)
for local, remote in aligned.items() if tracks[local].duration and album.tracks[remote].duration
]
if not meta.musicbrainz_release_id and (not named and not durations or not known_title and len(tracks) < 2):
return 0.0
score = 40.0 + 25 * title_sim + 15 * artist_match
year_hint = meta.original_year or meta.year
score += (10 if year_hint else 15) * sum(durations) / len(tracks)
# 原始年份也可能描述合辑中歌曲的首发年,仅用于排名;当前发行年份才是版本约束。
year_matches = str(year_hint) == str(album.original_year) or (not meta.original_year and str(year_hint) == str(album.year))
score += 5 if year_hint and year_matches else 0
score += 5 if len(tracks) == len(album.tracks) else 0
if meta.musicbrainz_release_id:
score += 40
return min(score, 100.0)
@staticmethod
def _duration_similarity(left: Optional[int], right: Optional[int]) -> float:
"""
比较两个时长的接近程度,完全一致为 1,差异越大越接近 0。
"""
if not left or not right:
return 0.0
return max(0.0, 1 - abs(left - right) / max(left, right))
@classmethod
def _text_similarity(cls, left: Optional[str], right: Optional[str]) -> float:
"""
忽略大小写和标点后比较两段音乐文本的相似度。
"""
normalized_left = cls._match_text(left)
normalized_right = cls._match_text(right)
if not normalized_left or not normalized_right:
return 0.0
return SequenceMatcher(None, normalized_left, normalized_right).ratio()
@staticmethod
def _match_text(value: Optional[str]) -> str:
"""
与资源匹配共用繁简、全半角、变音符和标点归一化规则。
"""
return music_text_key(value)
@classmethod
def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]:
"""
按规范化文本去重并保留原始顺序。
"""
results: list[str] = []
seen: set[str] = set()
for value in values:
normalized = cls._normalize_text(value)
identity = normalized.casefold()
if not normalized or identity in seen:
continue
seen.add(identity)
results.append(normalized)
return results
@classmethod
def _release_to_album(cls, detail: dict[str, Any]) -> Optional[MusicAlbumInfo]:
"""
将 MusicBrainz Release 详情转换为带曲目表的标准化专辑信息。
"""
release_id = detail.get("id")
title = detail.get("title")
if not release_id or not title:
return None
release_group = detail.get("release-group") or {}
group_id = release_group.get("id")
artists, artist_ids = cls._artist_credits(detail.get("artist-credit"))
album = MusicAlbumInfo(
**_musicbrainz_credits(detail),
media_source=cls._source,
# Release 与 Release Group 是不同实体,缺少 Group 时不能用 Release ID 补位。
media_id=str(group_id) if group_id else None,
musicbrainz_release_id=str(release_id),
musicbrainz_release_group_id=str(group_id) if group_id else None,
original_year=cls._year(release_group.get("first-release-date")),
total_discs=len(detail.get("media") or []) or None,
title=str(title),
artists=artists,
artist_ids=artist_ids,
artist_aliases=cls._credit_aliases(detail.get("artist-credit")),
album_type=cls._stripped(release_group.get("primary-type")),
secondary_types=[value for item in release_group.get("secondary-types") or [] if (value := cls._stripped(item))],
release_date=detail.get("date") or None,
cover_url=cls._build_cover_url(group_id),
genres=cls._names_of(detail.get("genres")),
detail_link=f"https://musicbrainz.org/release/{release_id}",
raw_data={
"release_id": str(release_id),
"release_country": cls._stripped(detail.get("country")),
"release_script": cls._release_script(detail) or None,
},
)
album.tracks = [
info
for medium in detail.get("media") or []
for track in medium.get("tracks") or []
if (info := cls._track_to_info(album, medium, track))
]
return _remote_music_evidence(album)
def recognize_media(
self,
meta: Optional[MetaBase] = None,
mtype: Optional[MediaType] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Optional[MusicInfo]:
"""
接收统一识别请求,按明确身份查询详情或按音乐线索匹配。
在音乐识别观察上下文中记录候选与失败原因,并按规则更新本地缓存。
:param meta: 音乐识别线索;仅按明确的 MusicBrainz 身份查询时可省略
:param mtype: 统一媒体类型,用于非 MetaMusic 请求的准入判断
:param media_source: 显式来源,其他来源请求不处理;省略时使用线索来源
:param media_id: 显式来源标识,优先于线索内的标识
:param kwargs: music_type 限制实体范围,cache 控制读取识别缓存,默认允许
:return: 统一音乐结果,未匹配时可能保留本地线索与诊断;请求不适用或详情不可用时返回 None
"""
with capture_music_recognition():
plan = self._recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
music_type=kwargs.get("music_type"),
cache_enabled=bool(kwargs.get("cache", True)),
)
if not plan:
return None
if plan.media_id:
info = self.recognize_music(
plan.media_source,
plan.require_media_id(),
**plan.detail_kwargs(),
)
return self._finalize_detail_recognition(plan, info)
return self._recognize_from_candidates_sync(plan)
def _update_recognize_cache(self, meta: MetaMusic, info: Optional[MusicInfo],
music_type: Optional[str] = None) -> None:
"""
识别完成后把结果写入本地识别缓存,未挂载缓存时静默跳过。
"""
if self.cache and (info and info.media_id or not music_recognition_failed()):
self.cache.update(meta, info, music_type=music_type)
def update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""
回填共享识别结果,覆盖未限定实体及已确认实体的旧缓存。
:param meta: 原始音乐线索,必须为 MetaMusic
:param mediainfo: 已识别的 MusicBrainz 音乐结果
:return: 输入类型与来源符合条件时为 True,否则为 None;True 不代表缓存已初始化
"""
if not meta or not mediainfo:
return None
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
return None
if mediainfo.media_source != self._source:
return None
self._update_recognize_cache(meta, mediainfo)
if mediainfo.media_id and mediainfo.music_type in (MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM):
self._update_recognize_cache(meta, mediainfo, music_type=mediainfo.music_type)
return True
async def async_update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""
异步回填共享识别结果,覆盖未限定实体及已确认实体的旧缓存。
:param meta: 原始音乐线索,必须为 MetaMusic
:param mediainfo: 已识别的 MusicBrainz 音乐结果
:return: 输入类型与来源符合条件时为 True,否则为 None;True 不代表缓存已初始化
"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
async def async_recognize_media(
self,
meta: Optional[MetaBase] = None,
mtype: Optional[MediaType] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Optional[MusicInfo]:
"""
异步接收统一识别请求,按明确身份查询详情或按音乐线索匹配。
在音乐识别观察上下文中记录候选与失败原因,并按规则更新本地缓存。
:param meta: 音乐识别线索;仅按明确的 MusicBrainz 身份查询时可省略
:param mtype: 统一媒体类型,用于非 MetaMusic 请求的准入判断
:param media_source: 显式来源,其他来源请求不处理;省略时使用线索来源
:param media_id: 显式来源标识,优先于线索内的标识
:param kwargs: music_type 限制实体范围,cache 控制读取识别缓存,默认允许
:return: 统一音乐结果,未匹配时可能保留本地线索与诊断;请求不适用或详情不可用时返回 None
"""
with capture_music_recognition():
plan = self._recognition_plan(
meta=meta,
mtype=mtype,
media_source=media_source,
media_id=media_id,
music_type=kwargs.get("music_type"),
cache_enabled=bool(kwargs.get("cache", True)),
)
if not plan:
return None
if plan.media_id:
info = await self.async_recognize_music(
plan.media_source,
plan.require_media_id(),
**plan.detail_kwargs(),
)
return self._finalize_detail_recognition(plan, info)
return await self._recognize_from_candidates_async(plan)
@classmethod
def _recognition_plan(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource],
media_id: Optional[str],
music_type: Optional[str],
cache_enabled: bool,
) -> Optional[_MusicBrainzRecognitionPlan]:
"""
统一完成来源、音乐类型、显式身份和缓存准入。
"""
if media_source and media_source != cls._source:
return None
if not isinstance(meta, MetaMusic):
if mtype != MediaType.MUSIC and media_source != cls._source:
return None
if media_source != cls._source or not media_id:
return None
return _MusicBrainzRecognitionPlan(
meta=None,
media_source=cls._source,
media_id=str(media_id),
music_type=music_type,
cache_enabled=False,
)
resolved_source = media_source or meta.media_source
resolved_media_id = media_id or meta.media_id
return _MusicBrainzRecognitionPlan(
meta=meta,
media_source=resolved_source or cls._source,
media_id=(
str(resolved_media_id)
if resolved_source and resolved_media_id
else None
),
music_type=music_type,
cache_enabled=cache_enabled,
)
def _cached_recognition(
self, plan: _MusicBrainzRecognitionPlan
) -> Optional[MusicInfo]:
"""
读取并标记一次候选识别缓存命中。
"""
meta = plan.require_meta()
if not plan.cache_enabled or not self.cache:
return None
cached_info = self.cache.get(meta, music_type=plan.music_type)
if not cached_info:
return None
if plan.music_type and cached_info.music_type != plan.music_type:
return None
if cached_info.media_id:
album_matches = not meta.album or not cached_info.album or music_album_matches(cached_info, meta.album)
release_matches = album_matches and music_year_matches(cached_info, meta)
identity_matches = (
(not (meta.artists or any(music_credit_values(meta).values())) or music_artist_evidence_matches(cached_info, meta))
and (not meta.title or music_title_matches(cached_info, meta.title))
and music_version_matches(cached_info, meta)
)
if (music_credit_conflicts(cached_info, meta) or music_isrc_conflicts(cached_info, meta) or not release_matches
or (not music_isrc_matches(cached_info, meta) and not identity_matches)):
return None
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
def _finalize_detail_recognition(
self,
plan: _MusicBrainzRecognitionPlan,
info: Optional[MusicInfo],
) -> Optional[MusicInfo]:
"""
统一完成显式详情识别后的缓存回填。
"""
if info and plan.meta:
self._update_recognize_cache(plan.meta, info, music_type=plan.music_type)
return info
@classmethod
def _select_recognition_candidate(
cls,
plan: _MusicBrainzRecognitionPlan,
recordings: Iterable[MusicInfo],
albums: Iterable[MusicInfo] = (),
) -> Optional[MusicInfo]:
"""
按同一来源和实体规则选择 Recording 或专辑候选。
"""
meta = plan.require_meta()
if plan.music_type == MUSIC_ENTITY_ALBUM:
return cls._select_album_candidate(meta, albums)
matched = cls._select_candidate(
meta, recordings, media_source=plan.media_source
)
if matched or not plan.search_album or not meta.artists:
return matched
return cls._select_album_candidate(meta, albums)
@staticmethod
def _should_search_albums(
plan: _MusicBrainzRecognitionPlan,
preliminary: Optional[MusicInfo],
) -> bool:
"""
统一决定候选识别是否需要继续查询专辑。
"""
meta = plan.require_meta()
return bool(
plan.music_type == MUSIC_ENTITY_ALBUM
or (not preliminary and plan.search_album and meta.artists)
)
@staticmethod
def _should_probe_album(
plan: _MusicBrainzRecognitionPlan,
recording: Optional[MusicInfo],
) -> bool:
"""
统一决定 Recording 详情未命中后是否继续探测专辑。
"""
return bool(not recording and plan.search_album)
def _finalize_recognition(
self,
plan: _MusicBrainzRecognitionPlan,
matched: Optional[MusicInfo],
) -> MusicInfo:
"""
统一生成候选识别兜底并写入本地缓存。
"""
meta = plan.require_meta()
diagnostic = music_recognition_diagnostics()
if diagnostic.get("status") in BLOCKING_MUSIC_RECOGNITION_STATES:
result = self._info_from_meta(meta)
result.raw_data["recognition"] = diagnostic
return result
result = matched or self._info_from_meta(meta)
self._update_recognize_cache(meta, result, music_type=plan.music_type)
return result
def _recognize_from_candidates_sync(
self, plan: _MusicBrainzRecognitionPlan
) -> Optional[MusicInfo]:
"""
同步获取候选,所有准入与选择由共享决策方法完成。
"""
meta = plan.require_meta()
if plan.music_type != MUSIC_ENTITY_ALBUM:
cached_info = self._cached_recognition(plan)
if cached_info:
return cached_info
recordings = (
self._search_recordings(meta, limit=10, require_match=True)
if plan.search_recording else []
)
preliminary = self._select_recognition_candidate(plan, recordings)
albums = (
self._search_albums(meta, limit=10, require_match=True)
if self._should_search_albums(plan, preliminary)
else []
)
matched = self._select_recognition_candidate(plan, recordings, albums)
if plan.music_type == MUSIC_ENTITY_ALBUM and music_recognition_diagnostics().get("status") not in BLOCKING_MUSIC_RECOGNITION_STATES:
return matched
return self._finalize_recognition(plan, matched)
async def _recognize_from_candidates_async(
self, plan: _MusicBrainzRecognitionPlan
) -> Optional[MusicInfo]:
"""
异步获取候选,所有准入与选择由共享决策方法完成。
"""
meta = plan.require_meta()
if plan.music_type != MUSIC_ENTITY_ALBUM:
cached_info = self._cached_recognition(plan)
if cached_info:
return cached_info
recordings = (
await self._async_search_recordings(meta, limit=10, require_match=True)
if plan.search_recording else []
)
preliminary = self._select_recognition_candidate(plan, recordings)
albums = (
await self._async_search_albums(meta, limit=10, require_match=True)
if self._should_search_albums(plan, preliminary)
else []
)
matched = self._select_recognition_candidate(plan, recordings, albums)
if plan.music_type == MUSIC_ENTITY_ALBUM and music_recognition_diagnostics().get("status") not in BLOCKING_MUSIC_RECOGNITION_STATES:
return matched
return self._finalize_recognition(plan, matched)
@classmethod
def _select_candidate(
cls,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
media_source: MediaSource,
) -> Optional[MusicInfo]:
"""
优先采用同一 ISRC,其他候选须满足完整名称、已有署名和录音版本约束。
"""
normalized_source = cls._normalize_text(media_source).casefold()
# 完整名称优先,去署名只产生回退名称,不能覆盖实际包含艺名的曲名。
original_title = cls._search_title(meta.title)
clean_title = cls._strip_artist_prefix(original_title, meta.artists)
bare_title = music_base_title(clean_title)
ranked: list[tuple[bool, int, MusicInfo]] = []
identities: list[MusicInfo] = []
for candidate in candidates:
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
continue
if music_credit_conflicts(candidate, meta) or music_isrc_conflicts(candidate, meta):
continue
album_matches = not meta.album or not candidate.album or music_album_matches(candidate, meta.album)
release_matches = album_matches and music_year_matches(candidate, meta)
if music_isrc_matches(candidate, meta) and release_matches:
# 相同 ISRC 是明确录音身份,不能被另一条纯标题命中的得分压过。
identities.append(candidate)
continue
score = 0
title_match = False
# 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配
artist_match = music_artist_evidence_matches(candidate, meta)
titles = music_titles(candidate)
exact_title = bool(original_title and any(cls._same_text(original_title, title) for title in titles))
if exact_title:
score += 4
title_match = True
elif (
bare_title
and artist_match
and music_title_matches(candidate, clean_title)
):
score += 2
title_match = True
if artist_match:
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
# 同名多版本(如不同年份的重发单曲)靠发行年份消歧
if meta.year and candidate.year and int(meta.year) == int(candidate.year):
score += 1
# 非显式身份必须同时满足作品名、已有署名与版本,不能只靠累计得分确认。
if (
((meta.artists or any(music_credit_values(meta).values())) and not artist_match)
or not title_match
or not music_version_matches(candidate, meta)
or not release_matches
):
continue
ranked.append((exact_title, score, candidate))
if identities or not ranked:
return unique_music_match(identities)
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
return unique_music_match([item[2] for item in ranked if item[:2] == ranked[0][:2]])
@classmethod
def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]:
"""
单曲检索未命中时,从专辑候选中挑选高置信目标。
专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回,
避免把音轨身份安到错误专辑上。
"""
original_title = cls._search_title(meta.album or meta.title)
clean_title = cls._strip_artist_prefix(original_title, meta.artists)
if not clean_title:
return None
# 去括号与卷号后缀后的本体名用于弱匹配(好歌茹芸, Vol. 3 -> 好歌茹芸);
# 资源带卷号时弱匹配要求候选卷号一致,避免 Ibiza Vol.1 误配 Vol.3
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
meta_volume = cls._volume_number(clean_title)
ranked: list[tuple[bool, int, MusicInfo]] = []
for album in albums:
if music_credit_conflicts(album, meta) or not music_album_year_matches(album, meta):
continue
score = 0
album_title = album.title or album.album
artist_match = music_artist_evidence_matches(album, meta)
title_match = False
exact_title = False
# 资源带卷号时候选卷号不一致(含其他分卷)直接排除,避免 Vol.1 误配 Vol.3
if meta_volume and cls._volume_number(album_title) not in (None, meta_volume):
pass
elif any(cls._same_text(original_title, title) for title in music_titles(album, album=True)):
score += 4
title_match = True
exact_title = True
elif (
artist_match
and bare_title
and (
cls._same_text(bare_title, cls._strip_parenthetical(album_title))
# 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配
or cls._same_text(bare_title, cls._main_title(album_title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(album_title))
# 条目「愛情電影主題曲 雲且留住」这类「主体名 补充说明」结构,首段一致视为弱匹配
or (
len(cls._match_text(bare_title)) >= 3
and cls._same_text(bare_title, cls._lead_token(album_title))
)
# 条目带额外前缀/后缀完整包含资源主体名(好莱坞原声带类),长文本包含视为弱匹配
or (
len(cls._match_text(bare_title)) >= 6
and cls._match_text(bare_title) in cls._match_text(album_title)
)
# 资源标题带演出后缀(S.H.E十七音乐会),条目本体一致视为弱匹配
or (
cls._performance_title(bare_title)
and cls._same_text(cls._performance_title(bare_title), album_title)
)
# 原声带资源的描述词在条目中常省略(Pulp Fiction: Music From the…),
# 电影名本体与条目标题或主标题一致视为弱匹配
or (
len(cls._match_text(cls._soundtrack_body(bare_title))) >= 4
and (
cls._same_text(cls._soundtrack_body(bare_title), album_title)
or cls._same_text(cls._soundtrack_body(bare_title), cls._main_title(album_title))
)
)
)
):
# 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异
score += 2
title_match = True
if artist_match:
score += 3
if meta.year and album.year and int(meta.year) == int(album.year):
score += 1
# 标题与艺术家缺一不可,仅有标题相似不能采信
if title_match and artist_match and music_version_matches(album, meta):
ranked.append((exact_title, score, album))
if not ranked:
return None
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
return unique_music_match([item[2] for item in ranked if item[:2] == ranked[0][:2]])
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""
音乐识别无候选时,把元数据转换为可展示的最小信息。
"""
return MusicInfo.from_meta(meta)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
"""
忽略大小写、空白与标点差异比较两个音乐文本字段。
"""
compact_left = cls._match_text(left)
compact_right = cls._match_text(right)
if compact_left and compact_left == compact_right:
return True
# 条目与资源标题的汉字数字写法不一致(十三首/13首),归一后再比对
return bool(compact_left) and cls._convert_cjk_numerals(
compact_left) == cls._convert_cjk_numerals(compact_right)
# 常见汉字数字归一为阿拉伯数字:十三首/13首、二十周年/20周年 在条目与资源标题间混用
_CJK_DIGIT_MAP = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
_CJK_NUMERAL_RUN_RE = re.compile(r"[零一二两三四五六七八九十百]+")
@classmethod
def _convert_cjk_numerals(cls, value: str) -> str:
"""
将文本中连续的汉字数字转换为阿拉伯数字,支持十位与百位组合。
"""
def _convert(run: str) -> str:
"""
把连续中文数字转换为阿拉伯数字,保留艺术家名称中的其他文本。
"""
total, current = 0, 0
for char in run:
if char in cls._CJK_DIGIT_MAP:
current = cls._CJK_DIGIT_MAP[char]
elif char == "十":
total += (current or 1) * 10
current = 0
elif char == "百":
total += (current or 1) * 100
current = 0
return str(total + current)
return cls._CJK_NUMERAL_RUN_RE.sub(lambda m: _convert(m.group()), str(value or ""))
@classmethod
def _normalize_text(cls, value: Optional[str]) -> str:
"""
清理音乐检索文本中的多余空白,并统一繁简写法避免候选比对失误。
"""
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text:
return text
# 仅归一化本地比较;实际查询使用原文和完整繁简变体。
try:
return zhconv_convert(text, "zh-hans")
# 字形转换失败时保留原文,避免可选转换组件影响识别主流程。
except Exception: # pylint: disable=broad-except
return text
# 资源标题中的音质规格(格式、位深采样参数、年份后缀、发行实体标记)会污染检索式,检索前统一剥离
_quality_token_pattern = re.compile(
r"\[[^\]]*\]|\((?:19|20)\d{2}\)|"
r"\b(?:DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB)\b|"
r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:\.\d)?\s*k(?:hz|bps?)\b|"
# 流媒体发行实体标记(- Single / - EP),不是曲名的一部分
r"\s+[-–—]\s*(?:single|ep|album)\s*$",
re.IGNORECASE,
)
@classmethod
def _search_title(cls, value: Optional[str], *, preserve_script: bool = False) -> str:
"""
剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。
"""
text = cls._quality_token_pattern.sub(" ", str(value or ""))
normalize_text = (lambda value: re.sub(r"\s+", " ", str(value or "")).strip()) \
if preserve_script else cls._normalize_text
# 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语
text = text.replace("_", " ")
# 规格剥离后可能残留悬空分隔符,统一修剪
text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", normalize_text(text))
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB),整体剔除
text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text)
# 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取),
# 反复剥离尾部年份:场景命名可能重复携带(Live At Montreux 2011 2011)
text = normalize_text(text)
while True:
# 仅剔除空白分隔的尾部年份,纯年份标题(1999)无前导空白不受影响
stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text)
if stripped == text:
break
text = stripped
return normalize_text(text)
def recognize_music(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""
按来源标识查询单曲或专辑详情。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 录音或发行组标识
:param music_type: recording 仅查询单曲,album 仅查询专辑,省略时先单曲后专辑
:return: 带来源身份的统一音乐结果;无有效标识、无匹配或查询失败时返回 None
"""
plan = self._detail_plan(media_source, media_id, music_type)
if not plan:
return None
result: Optional[MusicInfo] = None
if plan.search_recording:
payload = self._request_json(
f"/recording/{plan.require_media_id()}",
params={
"inc": _RECORDING_INCLUDES,
"fmt": "json",
},
)
result = self._project_recording_detail(payload)
if result:
result.artist_aliases = self._lookup_artist_aliases(result.artist_ids, result.artist_aliases)
return result
if not self._should_probe_album(plan, result):
return None
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
album = self.music_album(self._source, plan.require_media_id())
return self._project_album_result(album)
async def async_recognize_music(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""
异步按来源标识查询单曲或专辑详情。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 录音或发行组标识
:param music_type: recording 仅查询单曲,album 仅查询专辑,省略时先单曲后专辑
:return: 带来源身份的统一音乐结果;无有效标识、无匹配或查询失败时返回 None
"""
plan = self._detail_plan(media_source, media_id, music_type)
if not plan:
return None
result: Optional[MusicInfo] = None
if plan.search_recording:
payload = await self._async_request_json(
f"/recording/{plan.require_media_id()}",
params={
"inc": _RECORDING_INCLUDES,
"fmt": "json",
},
)
result = self._project_recording_detail(payload)
if result:
result.artist_aliases = await self._async_lookup_artist_aliases(result.artist_ids, result.artist_aliases)
return result
if not self._should_probe_album(plan, result):
return None
album = await self._async_music_album(
self._source, plan.require_media_id()
)
return self._project_album_result(album)
@classmethod
def _detail_plan(
cls,
media_source: MediaSource,
media_id: str,
music_type: Optional[str],
) -> Optional[_MusicBrainzRecognitionPlan]:
"""
统一校验详情来源并冻结 Recording 到专辑的探测顺序。
"""
if media_source != cls._source or not media_id:
return None
return _MusicBrainzRecognitionPlan(
meta=None,
media_source=cls._source,
media_id=str(media_id),
music_type=music_type,
cache_enabled=False,
)
@classmethod
def _project_recording_detail(
cls, payload: Optional[dict[str, Any]]
) -> Optional[MusicInfo]:
"""
把 Recording 详情响应投影为统一音乐信息。
"""
return cls._recording_to_info(payload) if payload else None
@staticmethod
def _project_album_result(
album: Optional[MusicAlbumInfo],
) -> Optional[MusicInfo]:
"""
把专辑详情统一投影到音乐识别返回类型。
"""
return album.to_music_info() if album else None
async def _async_music_album(
self,
media_source: MediaSource,
media_id: str,
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
musicbrainz_release_id: Optional[str] = None,
) -> Optional[MusicAlbumInfo]:
"""
异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。
"""
if not self._detail_plan(media_source, media_id, MUSIC_ENTITY_ALBUM):
return None
if musicbrainz_release_id is not None and not re.fullmatch(
r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", musicbrainz_release_id):
return None
payload = await self._async_request_json(
f"/release-group/{media_id}",
params={
"inc": "artists+releases+media+genres+tags+ratings+aliases",
"fmt": "json",
},
)
album = self._project_album_detail(payload)
if not payload or not album or (musicbrainz_release_id is not None and album.media_id != media_id):
return None
tracks_payload = await self._async_album_tracks_payload(
payload.get("releases") or [],
preference=self._release_preference(
music_release_regions,
music_release_scripts,
),
release_id=musicbrainz_release_id,
group_id=media_id,
)
if musicbrainz_release_id is not None and not tracks_payload:
return None
self._apply_selected_release(album, tracks_payload)
album.tracks = self._project_album_tracks(album, tracks_payload)
if musicbrainz_release_id is not None and not album.tracks:
return None
album.artist_aliases = await self._async_lookup_artist_aliases(album.artist_ids, album.artist_aliases)
return album
def music_album(
self,
media_source: MediaSource,
media_id: str,
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
musicbrainz_release_id: Optional[str] = None,
) -> Optional[MusicAlbumInfo]:
"""
按发行组标识获取专辑,并选择具体发行版本填充曲目。
明确指定发行时核验发行组身份;未指定发行时允许返回暂未加载曲目的专辑。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 发行组标识
:param music_release_regions: 发行地区优先顺序,省略时使用运行配置
:param music_release_scripts: 发行文字字形优先顺序,省略时使用运行配置
:param musicbrainz_release_id: 明确指定的发行版本 UUID,省略时根据偏好选择
:return: 专辑详情;来源或标识无效、详情缺失,或指定发行无有效曲目时返回 None
"""
if not self._detail_plan(media_source, media_id, MUSIC_ENTITY_ALBUM):
return None
if musicbrainz_release_id is not None and not re.fullmatch(
r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", musicbrainz_release_id):
return None
payload = self._request_json(
f"/release-group/{media_id}",
params={
"inc": "artists+releases+media+genres+tags+ratings+aliases",
"fmt": "json",
},
)
album = self._project_album_detail(payload)
if not payload or not album or (musicbrainz_release_id is not None and album.media_id != media_id):
return None
tracks_payload = self._album_tracks_payload(
payload.get("releases") or [],
preference=self._release_preference(
music_release_regions,
music_release_scripts,
),
release_id=musicbrainz_release_id,
group_id=media_id,
)
if musicbrainz_release_id is not None and not tracks_payload:
return None
self._apply_selected_release(album, tracks_payload)
album.tracks = self._project_album_tracks(album, tracks_payload)
if musicbrainz_release_id is not None and not album.tracks:
return None
album.artist_aliases = self._lookup_artist_aliases(album.artist_ids, album.artist_aliases)
return album
@staticmethod
def _alias_artist_ids(artist_ids: list[str]) -> list[str]:
"""
限制补充查询预算,仅使用来源返回的有效 MusicBrainz 艺术家 UUID。
"""
return list(dict.fromkeys(artist_id for artist_id in artist_ids if re.fullmatch(
r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", artist_id,
)))[:3]
@classmethod
def _artist_alias_values(cls, payload: Optional[dict[str, Any]], artist_id: str) -> list[str]:
"""
只采信精确艺术家 ID 响应中的名称及别名,避免串入搜索得到的同名艺人。
"""
if not payload or payload.get("id") != artist_id:
return []
return unique_music_texts([payload.get("name"), *cls._names_of(payload.get("aliases"))])
def _lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]:
"""
利用现有请求缓存补全已识别艺术家的可信别名,不按文字猜测其他艺人。
"""
values = list(aliases)
for artist_id in self._alias_artist_ids(artist_ids):
payload = self._request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"})
values.extend(self._artist_alias_values(payload, artist_id))
return unique_music_texts(values)
async def _async_lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]:
"""
原生异步补全同一艺术家别名,保留站点客户端的限流和请求缓存。
"""
values = list(aliases)
for artist_id in self._alias_artist_ids(artist_ids):
payload = await self._async_request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"})
values.extend(self._artist_alias_values(payload, artist_id))
return unique_music_texts(values)
@classmethod
def _project_album_detail(
cls, payload: Optional[dict[str, Any]]
) -> Optional[MusicAlbumInfo]:
"""
把 Release Group 详情投影为尚未装载曲目的专辑。
"""
if not payload:
return None
album = cls._release_group_to_album(payload)
if album:
album.releases = cls._release_variants(payload.get("releases") or [])
return album
def music_artist(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicArtistInfo]:
"""
按原生艺术家标识读取详情。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 艺术家标识
:return: 艺术家模型;来源不符、标识缺失或请求失败时返回 None
"""
if media_source != self._source or not media_id:
return None
payload = self._request_json(
f"/artist/{media_id}",
params={"inc": "url-rels+genres+tags+aliases", "fmt": "json"},
)
return self._artist_to_info(payload) if payload else None
def music_artist_albums(
self,
media_source: MediaSource,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""
分页浏览艺术家的专辑、EP 和单曲发行组。
服务端不支持排序,只对当前页按发行日期降序排列,不能保证全局最新作品位于首页。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 艺术家标识
:param page: 从 1 开始的页码,非正值按第一页处理
:param count: 每页数量,限制在 1 至 100
:param album_type: 可选发行类型,原样传给 MusicBrainz 浏览接口
:return: 统一音乐列表;来源不符、输入缺失或请求失败时为空列表
"""
if media_source != self._source or not media_id:
return []
limit = max(1, min(count, 100))
params: dict[str, Any] = {
"artist": media_id,
"inc": "artist-credits",
"limit": limit,
"offset": max(page - 1, 0) * limit,
"fmt": "json",
}
if album_type:
params["type"] = album_type
payload = self._request_json("/release-group", params=params)
albums = [
album
for item in (payload or {}).get("release-groups") or []
if (album := self._release_group_to_album(item))
]
# 浏览接口不支持排序,只能对当前页按发行日期降序排列,不能推断全局顺序。
albums.sort(key=lambda item: item.release_date or "", reverse=True)
return [album.to_music_info() for album in albums]
def music_artist_related(
self,
media_source: MediaSource,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""
按艺术家关系查询可继续浏览的关联艺术家。
:param media_source: 请求来源,必须为 MusicBrainz 才执行本模块查询
:param media_id: MusicBrainz 艺术家标识
:param count: 最多返回的关联艺术家数量
:return: 按关系优先级排列的艺术家列表;来源不符、标识缺失或请求失败时为空列表
"""
if media_source != self._source or not media_id:
return []
payload = self._request_json(
f"/artist/{media_id}",
params={"inc": "artist-rels", "fmt": "json"},
)
if not payload:
return []
return self._related_artists(payload.get("relations") or [], count=count)
@classmethod
def _build_query(cls, meta: MetaMusic) -> str:
"""
构造 MusicBrainz Recording 搜索表达式。
"""
clauses = []
# 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中
title = cls._search_title(meta.title, preserve_script=True)
if title:
clauses.append(f"recording:{cls._query_phrase(title)}")
query_artists = music_query_artists(meta)
if query_artists:
clauses.append(f'artist:{cls._query_phrase(query_artists[0])}')
if meta.album:
clauses.append(f'release:{cls._query_phrase(meta.album)}')
if meta.isrc:
clauses.append(f'isrc:"{cls._escape_query(meta.isrc)}"')
return " AND ".join(clauses)
@staticmethod
def _escape_query(value: str) -> str:
"""
转义 MusicBrainz 查询中的引号和反斜线。
"""
return value.replace("\\", "\\\\").replace('"', '\\"').strip()
# 中日韩名称优先完整短语,仅在前置查询无结果时启用逐字兜底。
_QUERY_CJK_RE = re.compile(
r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF]")
# 检索词元切分:按空白、标点与括号拆分,保留 CJK 串与拉丁词(括号对逐字检索无意义)
_QUERY_TOKEN_SPLIT_RE = re.compile(
r"[\s\-–—−-。,、;:!?·.…()()「」『』【】\[\]《》,;]+")
@classmethod
def _query_phrase(cls, value: Optional[str], *, loose: bool = False) -> Optional[str]:
"""
优先构造完整名称的繁简短语组,逐字 OR 只作为显式请求的末级兜底。
"""
text = str(value or "").strip()
if not text:
return None
if not cls._QUERY_CJK_RE.search(text):
return f'"{cls._escape_query(text)}"'
if not loose:
variants = sorted(unique_music_texts([
text, zhconv_convert(text, "zh-hans"), zhconv_convert(text, "zh-hant"),
]))
return cls._or_group([f'"{cls._escape_query(variant)}"' for variant in variants])
tokens = [
token for token in cls._QUERY_TOKEN_SPLIT_RE.split(text) if token.strip()
]
if not tokens:
return None
parts = [
f'"{cls._escape_query(token)}"'
if not cls._QUERY_CJK_RE.search(token) else cls._or_group(
[f'"{cls._escape_query(char)}"' for char in token]
)
for token in tokens
]
return cls._or_group(parts)
@staticmethod
def _or_group(parts: list[str]) -> str:
"""
拼接 OR 检索表达式,多项时用括号包裹避免与外层 AND 产生优先级歧义。
"""
if len(parts) == 1:
return parts[0]
return "(" + " OR ".join(parts) + ")"
@classmethod
def _recording_to_info(cls, recording: dict[str, Any]) -> Optional[MusicInfo]:
"""
将 MusicBrainz Recording 响应转换为标准音乐信息。
"""
media_id = recording.get("id")
title = recording.get("title")
if not media_id or not title:
return None
releases = recording.get("releases") or []
release = cls._select_release(releases)
release_group = (release or {}).get("release-group") or {}
release_date = cls._release_date(recording, release)
album = (release or {}).get("title")
artists, artist_ids = cls._artist_credits(recording.get("artist-credit"))
album_artists, _ = cls._artist_credits((release or {}).get("artist-credit"))
secondary_types = [
value
for item in release_group.get("secondary-types") or []
if (value := cls._stripped(item))
]
category_parts = [cls._stripped(release_group.get("primary-type")), *secondary_types]
return _remote_music_evidence(MusicInfo(
**_musicbrainz_credits(recording),
media_source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists,
artist_ids=artist_ids,
album=album,
album_artist=" / ".join(album_artists) if album_artists else None,
album_id=str(release_group["id"]) if release_group.get("id") else None,
musicbrainz_release_id=str(release["id"]) if release.get("id") else None,
musicbrainz_release_group_id=str(release_group["id"]) if release_group.get("id") else None,
original_year=cls._year(recording.get("first-release-date") or release_group.get("first-release-date")),
release_year=cls._year(release.get("date")),
album_type=cls._stripped(release_group.get("primary-type")),
secondary_types=secondary_types,
year=cls._year(release_date),
release_date=release_date,
duration=cls._duration_seconds(recording.get("length")),
isrc=next(iter(recording.get("isrcs") or []), None),
cover_url=cls._build_cover_url(release_group.get("id")),
version=recording.get("disambiguation") or None,
metadata_category=" / ".join(str(part) for part in category_parts if part),
genres=cls._names_of(recording.get("genres")),
release_status=cls._stripped((release or {}).get("status")),
names=[name for name in (title, album) if name],
title_aliases=cls._names_of(recording.get("aliases")),
album_aliases=cls._names_of(release_group.get("aliases")),
artist_aliases=cls._credit_aliases(recording.get("artist-credit")),
detail_link=f"{cls._detail_url}/{media_id}",
raw_data=recording,
))
@classmethod
def _release_group_to_album(cls, release_group: dict[str, Any]) -> Optional[MusicAlbumInfo]:
"""
将 MusicBrainz Release Group 响应转换为标准专辑信息。
"""
media_id = release_group.get("id")
title = release_group.get("title")
if not media_id or not title:
return None
artists, artist_ids = cls._artist_credits(release_group.get("artist-credit"))
rating = release_group.get("rating") or {}
return _remote_music_evidence(MusicAlbumInfo(
media_source=cls._source,
media_id=str(media_id),
musicbrainz_release_group_id=str(media_id),
original_year=cls._year(release_group.get("first-release-date")),
title=str(title),
artists=artists,
artist_ids=artist_ids,
album_type=cls._stripped(release_group.get("primary-type")),
title_aliases=cls._names_of(release_group.get("aliases")),
artist_aliases=cls._credit_aliases(release_group.get("artist-credit")),
secondary_types=[value for item in release_group.get("secondary-types") or [] if (value := cls._stripped(item))],
release_date=release_group.get("first-release-date") or None,
cover_url=cls._build_cover_url(media_id),
genres=cls._names_of(release_group.get("genres")),
tags=cls._names_of(release_group.get("tags")),
# MusicBrainz 评分是 5 分制,统一放大到与影视一致的 10 分制展示
rating=round(float(rating["value"]) * 2, 1) if rating.get("value") else 0.0,
rating_votes=rating.get("votes-count"),
detail_link=f"{cls._album_detail_url}/{media_id}",
raw_data=release_group,
))
@classmethod
def _release_variants(cls, releases: list[dict[str, Any]]) -> list[MusicRelease]:
"""
整理同一专辑下的发行版本,供详情页对比介质和地区。
"""
variants = []
for release in releases:
if not release.get("id"):
continue
media = release.get("media") or []
variants.append(
MusicRelease(
media_id=str(release["id"]),
title=release.get("title"),
date=release.get("date") or None,
country=release.get("country") or None,
status=release.get("status") or None,
packaging=release.get("packaging") or None,
formats=[str(item["format"]) for item in media if item.get("format")],
track_count=sum(int(item.get("track-count") or 0) for item in media) or None,
)
)
variants.sort(key=lambda item: (cls._date_sort_key(item.date), item.title or ""))
return variants
@staticmethod
def _artist_credits(
artist_credit: Optional[list[dict[str, Any]]],
) -> Tuple[list[str], list[str]]:
"""
从 MusicBrainz artist-credit 提取有序艺术家名称和按位置对齐的标准 ID。
"""
names: list[str] = []
ids: list[str] = []
for credit in artist_credit or []:
artist = credit.get("artist") or {}
name = artist.get("name") or credit.get("name")
if not name or str(name) in names:
continue
names.append(str(name))
# 名称与 ID 按下标一一对应,缺少 ID 时补空串,前端据此决定是否可跳转
ids.append(str(artist.get("id") or ""))
return names, ids
@classmethod
def _credit_aliases(cls, credits: Optional[list[dict[str, Any]]]) -> list[str]:
"""
保留 artist-credit 中同一人的实际署名与来源别名,不丢弃外文艺名。
"""
names: list[str] = []
for credit in credits or []:
artist = credit.get("artist") or {}
names.extend(unique_music_texts([
credit.get("name"), artist.get("name"), *cls._names_of(artist.get("aliases")),
]))
return unique_music_texts(names)
@staticmethod
def _names_of(items: Optional[list[dict[str, Any]]]) -> list[str]:
"""
提取 MusicBrainz 风格、标签或别名列表的名称,热度高的排在前面。
"""
entries = [item for item in items or [] if item.get("name")]
entries.sort(key=lambda item: (-int(item.get("count") or 0), str(item["name"])))
return [str(item["name"]) for item in entries]
@classmethod
def _select_track_release(
cls,
releases: list[dict[str, Any]],
preference: Optional[_MusicReleasePreference] = None,
) -> dict[str, Any]:
"""
选择曲目完整的正式版本,并按地区、字形和日期确定代表版本。
"""
candidates = [
release
for release in releases
if release.get("id")
and sum(int(item.get("track-count") or 0) for item in release.get("media") or [])
]
if not candidates:
candidates = [release for release in releases if release.get("id")]
if not candidates:
return {}
official = [release for release in candidates if release.get("status") == "Official"]
selected_preference = preference or cls._release_preference()
return min(
official or candidates,
key=lambda release: (
*cls._release_preference_sort_key(release, selected_preference),
cls._date_sort_key(release.get("date")),
),
)
@classmethod
def _album_tracks_payload(
cls,
releases: list[dict[str, Any]],
preference: Optional[_MusicReleasePreference] = None,
*,
release_id: Optional[str] = None,
group_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
同步读取指定发行或默认代表版本的曲目,显式版本失败不回退。
"""
release = {"id": release_id.lower()} if release_id is not None else cls._select_track_release(releases, preference)
if not release.get("id"):
return None
payload = cls._request_json(
f"/release/{release['id']}",
params={"inc": "recordings+artist-credits+isrcs" + _RELEASE_RELATIONS
+ ("+release-groups" if release_id is not None else ""), "fmt": "json"},
)
return cls._validate_album_release(payload, release_id=release_id, group_id=group_id)
@classmethod
async def _async_album_tracks_payload(
cls,
releases: list[dict[str, Any]],
preference: Optional[_MusicReleasePreference] = None,
*,
release_id: Optional[str] = None,
group_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
异步读取指定发行或默认代表版本的曲目,显式版本失败不回退。
"""
release = {"id": release_id.lower()} if release_id is not None else cls._select_track_release(releases, preference)
if not release.get("id"):
return None
payload = await cls._async_request_json(
f"/release/{release['id']}",
params={"inc": "recordings+artist-credits+isrcs" + _RELEASE_RELATIONS
+ ("+release-groups" if release_id is not None else ""), "fmt": "json"},
)
return cls._validate_album_release(payload, release_id=release_id, group_id=group_id)
@staticmethod
def _validate_album_release(
payload: Optional[dict[str, Any]],
*,
release_id: Optional[str],
group_id: Optional[str],
) -> Optional[dict[str, Any]]:
"""
核验具体发行及所属发行组,不以可能截断的内嵌发行列表决定归属。
"""
if not isinstance(payload, dict):
return None
if release_id is not None:
release_group = payload.get("release-group")
if (payload.get("id") != release_id.lower() or not isinstance(release_group, dict)
or release_group.get("id") != group_id):
return None
return payload
@classmethod
def _apply_selected_release(
cls,
album: MusicAlbumInfo,
payload: Optional[dict[str, Any]],
) -> None:
"""
把代表 Release 的标题、日期和来源身份回填到 Release Group 专辑。
"""
if not payload:
return
if title := cls._stripped(payload.get("title")):
album.title = title
if release_date := cls._stripped(payload.get("date")):
album.release_date = release_date
album.musicbrainz_release_id = cls._stripped(payload.get("id"))
album.total_discs = len(payload.get("media") or []) or None
album.__dict__.update(_musicbrainz_credits(payload))
album.raw_data = {
**(album.raw_data or {}),
"release_id": cls._stripped(payload.get("id")),
"release_country": cls._stripped(payload.get("country")),
"release_script": cls._release_script(payload) or None,
}
_remote_music_evidence(album)
@classmethod
def _project_album_tracks(
cls,
album: MusicAlbumInfo,
payload: Optional[dict[str, Any]],
) -> list[MusicInfo]:
"""
把代表性发行版本响应投影为专辑曲目列表。
"""
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,
album: MusicAlbumInfo,
medium: dict[str, Any],
track: dict[str, Any],
) -> Optional[MusicInfo]:
"""
将发行版本中的单条曲目转换为可继续浏览的音乐信息。
"""
recording = track.get("recording") or {}
media_id = recording.get("id")
title = track.get("title") or recording.get("title")
if not media_id or not title:
return None
artists, artist_ids = cls._artist_credits(
track.get("artist-credit") or recording.get("artist-credit")
)
return _remote_music_evidence(MusicInfo(
**_musicbrainz_credits(recording),
media_source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists or list(album.artists),
artist_ids=artist_ids or list(album.artist_ids),
artist_aliases=cls._credit_aliases(track.get("artist-credit") or recording.get("artist-credit")) if artists else list(album.artist_aliases),
album=album.title,
album_artist=album.artist or None,
album_id=album.media_id,
musicbrainz_release_id=album.musicbrainz_release_id,
musicbrainz_release_group_id=album.musicbrainz_release_group_id or album.media_id,
musicbrainz_release_track_id=cls._stripped(track.get("id")),
original_year=cls._year(recording.get("first-release-date")) or album.original_year,
release_year=album.year if album.musicbrainz_release_id else None,
total_discs=album.total_discs,
album_type=album.album_type,
year=album.year,
release_date=album.release_date,
disc_number=cls._optional_int(medium.get("position")),
track_number=cls._optional_int(track.get("position")),
total_tracks=cls._optional_int(medium.get("track-count")),
duration=cls._duration_seconds(track.get("length") or recording.get("length")),
isrc=next(iter(recording.get("isrcs") or []), None),
cover_url=album.cover_url,
version=recording.get("disambiguation") or None,
secondary_types=list(album.secondary_types),
metadata_category=album.metadata_category,
genres=list(album.genres),
tags=list(album.tags),
artist_country=album.artist_country,
release_status=album.release_status,
names=[str(title)],
detail_link=f"{cls._detail_url}/{media_id}",
raw_data={"isrcs": list(recording.get("isrcs") or [])},
))
@classmethod
def _select_release(cls, releases: list[dict[str, Any]]) -> dict[str, Any]:
"""
优先选择正式且符合系统地区、字形优先级的发行记录。
"""
if not releases:
return {}
official = [release for release in releases if release.get("status") == "Official"]
candidates = official or releases
preference = cls._release_preference()
return min(
candidates,
key=lambda release: (
*cls._release_preference_sort_key(release, preference),
cls._date_sort_key(release.get("date")),
),
)
@staticmethod
def _date_sort_key(value: Optional[str]) -> tuple[int, str]:
"""
将完整或不完整发行日期转换为稳定排序键。
"""
return (0, value) if value else (1, "")
@staticmethod
def _release_date(recording: dict[str, Any], release: dict[str, Any]) -> Optional[str]:
"""
从录音和发行信息中选择最可靠的发行日期。
"""
return recording.get("first-release-date") or release.get("date")
@staticmethod
def _year(release_date: Optional[str]) -> Optional[int]:
"""
从 MusicBrainz 的可变精度日期提取年份。
"""
if not release_date:
return None
try:
return int(release_date[:4])
except (TypeError, ValueError):
return None
@staticmethod
def _duration_seconds(value: Any) -> Optional[int]:
"""
将 MusicBrainz 毫秒时长转换为整数秒。
"""
try:
return round(int(value) / 1000) if value is not None else None
except (TypeError, ValueError):
return None
@staticmethod
def _stripped(value: Any) -> Optional[str]:
"""
去除 MusicBrainz 响应字段两端的空白,空值返回 None。
"""
text = str(value).strip() if value is not None else ""
return text or None
@staticmethod
def _optional_int(value: Any) -> Optional[int]:
"""
将 MusicBrainz 的碟号、音轨号等计数字段转换为可选整数。
"""
try:
return int(value) if value not in (None, "") else None
except (TypeError, ValueError):
return None
@classmethod
def _artist_to_info(
cls,
artist: dict[str, Any],
relation: Optional[str] = None,
include_raw: bool = False,
) -> Optional[MusicArtistInfo]:
"""
将 MusicBrainz Artist 响应转换为标准艺术家信息。
"""
media_id = artist.get("id")
name = artist.get("name")
if not media_id or not name:
return None
life_span = artist.get("life-span") or {}
area = artist.get("area") or {}
begin_area = artist.get("begin-area") or {}
relations = artist.get("relations") or []
return MusicArtistInfo(
media_source=cls._source,
media_id=str(media_id),
name=str(name),
sort_name=artist.get("sort-name") or None,
disambiguation=artist.get("disambiguation") or None,
artist_type=artist.get("type") or None,
gender=artist.get("gender") or None,
country=artist.get("country") or None,
area=area.get("name") or begin_area.get("name") or None,
begin_date=life_span.get("begin") or None,
end_date=life_span.get("end") or None,
ended=bool(life_span.get("ended")),
genres=cls._names_of(artist.get("genres")),
tags=cls._names_of(artist.get("tags")),
aliases=cls._names_of(artist.get("aliases")),
relation=relation,
image_url=cls._artist_image(relations),
detail_link=f"{cls._artist_detail_url}/{media_id}",
external_links=cls._artist_links(relations),
raw_data=artist if include_raw else {},
)
@classmethod
def _artist_image(cls, relations: list[dict[str, Any]]) -> Optional[str]:
"""
从艺术家 image 关系解析可直接展示的图片地址。
"""
for relation in relations:
if relation.get("type") != "image":
continue
resource = (relation.get("url") or {}).get("resource") or ""
# MusicBrainz 记录的是维基共享资源页地址,需要转成文件直链才能展示
if "commons.wikimedia.org/wiki/File:" in resource:
file_name = resource.rsplit("File:", 1)[-1]
return (
"https://commons.wikimedia.org/wiki/Special:FilePath/"
f"{file_name}?width=500"
)
if resource:
return resource
return None
@classmethod
def _artist_links(cls, relations: list[dict[str, Any]]) -> dict[str, str]:
"""
整理艺术家可对外跳转的官方与流媒体链接。
"""
links: dict[str, str] = {}
for relation in relations:
relation_type = str(relation.get("type") or "")
resource = (relation.get("url") or {}).get("resource")
if relation_type in cls._artist_link_types and resource:
links.setdefault(relation_type, str(resource))
return links
@classmethod
def _related_artists(
cls,
relations: list[dict[str, Any]],
count: int,
) -> list[MusicArtistInfo]:
"""
按关系类型优先级整理关联艺术家,并按来源去重。
"""
ranked: list[tuple[int, MusicArtistInfo]] = []
seen: set[str] = set()
fallback_priority = len(cls._artist_relation_priority)
for relation in relations:
if relation.get("target-type") != "artist":
continue
artist = relation.get("artist") or {}
artist_id = str(artist.get("id") or "")
if not artist_id or artist_id in seen:
continue
relation_type = str(relation.get("type") or "")
info = cls._artist_to_info(artist, relation=relation_type or None)
if not info:
continue
seen.add(artist_id)
priority = (
cls._artist_relation_priority.index(relation_type)
if relation_type in cls._artist_relation_priority
else fallback_priority
)
ranked.append((priority, info))
ranked.sort(key=lambda item: (item[0], item[1].name or ""))
return [info for _, info in ranked[: max(1, count)]]
@classmethod
def _build_cover_url(cls, release_group_id: Optional[str]) -> Optional[str]:
"""
根据 Release Group ID 构造 Cover Art Archive 封面地址。
"""
if not release_group_id:
return None
# 支持配置音乐封面代理地址,解决 coverartarchive.org 无法访问的问题
base = (get_runtime_setting('MUSIC_COVER_PROXY') or "https://coverartarchive.org").rstrip("/")
return f"{base}/release-group/{release_group_id}/front-500"
@classmethod
def _get_request(cls) -> RequestUtils:
"""
懒创建并复用统一 HTTP 客户端,批量识别时避免重复建连。
"""
if cls._request is None:
with cls._client_lock:
if cls._request is None:
cls._request = RequestUtils(
headers={
"User-Agent": (
f"{get_runtime_setting('USER_AGENT')} "
"(https://github.com/jxxghp/MoviePilot)"
),
"Accept": "application/json",
},
proxies=get_runtime_setting('PROXY'),
use_session=True,
timeout=20,
)
return cls._request
@classmethod
def _reserve_request_delay(cls) -> float:
"""
为同步和异步 MusicBrainz 请求统一预留发送时间。
"""
with cls._request_lock:
now = time.monotonic()
request_at = max(now, cls._last_request_at + cls._request_interval)
if not music_wait_allowed(request_at - now):
return -1
cls._last_request_at = request_at
return max(0.0, request_at - now)
@classmethod
def _wait_for_rate_limit(cls) -> bool:
"""
同步等待 MusicBrainz 公共接口的已预留请求时间。
"""
delay = cls._reserve_request_delay()
if delay < 0:
return False
if delay:
time.sleep(delay)
return True
@classmethod
async def _async_wait_for_rate_limit(cls) -> bool:
"""
异步等待 MusicBrainz 公共接口的已预留请求时间。
"""
delay = cls._reserve_request_delay()
if delay < 0:
return False
if delay:
await asyncio.sleep(delay)
return True
@classmethod
def _response_decision(
cls,
response: Any,
path: str,
attempt: int,
attempts: int,
) -> _MusicBrainzResponseDecision:
"""
统一分类响应、解析 JSON,并决定是否执行下一次退避重试。
"""
status_code = response.status_code
if status_code == 404:
logger.debug(f"MusicBrainz 资源不存在:{path}")
return _MusicBrainzResponseDecision(payload={})
if status_code == 429 or status_code >= 500:
logger.warning(
f"MusicBrainz 服务繁忙:{status_code} {response.text[:200]}"
)
if attempt < attempts - 1:
return _MusicBrainzResponseDecision(
retry_delay=cls._busy_backoff * (2 ** attempt)
)
report_music_recognition("service_error", "音乐元数据服务繁忙,请稍后重试")
return _MusicBrainzResponseDecision()
if status_code != 200:
logger.warning(
f"MusicBrainz 请求失败:{status_code} {response.text[:200]}"
)
report_music_recognition("service_error", "音乐元数据服务请求失败,请稍后重试")
return _MusicBrainzResponseDecision()
try:
payload = response.json()
except (TypeError, ValueError) as err:
logger.warning(f"MusicBrainz 响应解析失败:{err}")
report_music_recognition("service_error", "音乐元数据服务返回了无效响应,请稍后重试")
return _MusicBrainzResponseDecision()
if not isinstance(payload, dict):
report_music_recognition("service_error", "音乐元数据服务返回了无效响应,请稍后重试")
return _MusicBrainzResponseDecision(
payload=payload if isinstance(payload, dict) else None
)
@classmethod
@cached(region="musicbrainz:http:v2", maxsize=get_runtime_setting('CONF').musicbrainz, ttl=get_runtime_setting('CONF').meta, skip_none=True,
empty_ttl=300, empty_if=lambda value: not value or value.get("count") == 0)
def _request_json(
cls,
path: str,
params: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
"""
请求 MusicBrainz JSON 接口并统一处理网络和响应错误。
服务端繁忙(429/5xx)属于瞬时错误,退避重试后再失败才放弃,
避免批量识别场景下把限流误判为检索零命中。
"""
attempts = cls._busy_retries + 1
for attempt in range(attempts):
if music_request_timeout() is None or cls._wait_for_rate_limit() is False:
return None
timeout = music_request_timeout(claim=False)
if timeout is None:
return None
response = cls._get_request().get_res(
f"{cls._base_url}{path}", params=params, timeout=timeout,
)
if response is None:
report_music_recognition("service_error", "音乐元数据服务连接失败,请稍后重试")
return None
try:
decision = cls._response_decision(
response, path, attempt, attempts
)
finally:
response.close()
if decision.retry_delay is not None:
if not music_wait_allowed(decision.retry_delay):
return None
time.sleep(decision.retry_delay)
continue
return decision.payload
return None
@classmethod
@cached(
region="musicbrainz:http:v2",
maxsize=get_runtime_setting('CONF').musicbrainz,
ttl=get_runtime_setting('CONF').meta,
skip_none=True,
shared_key="_request_json",
empty_ttl=300,
empty_if=lambda value: not value or value.get("count") == 0,
)
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):
if music_request_timeout() is None or await cls._async_wait_for_rate_limit() is False:
return None
timeout = music_request_timeout(claim=False)
if timeout is None:
return None
response = await AsyncRequestUtils(
headers={
"User-Agent": f"{get_runtime_setting('USER_AGENT')} (https://github.com/jxxghp/MoviePilot)",
"Accept": "application/json",
},
proxies=get_runtime_setting('PROXY'),
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params, timeout=timeout)
if response is None:
report_music_recognition("service_error", "音乐元数据服务连接失败,请稍后重试")
return None
try:
decision = cls._response_decision(
response, path, attempt, attempts
)
finally:
await response.aclose()
if decision.retry_delay is not None:
if not music_wait_allowed(decision.retry_delay):
return None
await asyncio.sleep(decision.retry_delay)
continue
return decision.payload
return None