Files
MoviePilot/app/modules/musicbrainz/__init__.py

1546 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Any, Iterable, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool
from requests import Session
from app.core.cache import cached
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
MusicRelease,
)
from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
from app.utils.zhconv import convert as zhconv_convert
class MusicBrainzModule(_ModuleBase):
"""通过 MusicBrainz 提供音乐元数据搜索和详情识别。"""
_source = "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: MusicBrainzCache = None
# 全局复用 HTTP 会话keep-alive 省去每次请求的 DNS+TLS 握手(约 6s → 0.4s
_session: Optional[Session] = None
_session_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",
)
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:
"""停止模块,退出前持久化识别缓存。"""
if self.cache:
try:
self.cache.save()
except Exception as err:
logger.error(f"保存音乐识别缓存失败:{str(err)}")
def scheduler_job(self) -> None:
"""定时任务每10分钟持久化一次音乐识别缓存。"""
if self.cache:
self.cache.save()
def clear_cache(self) -> None:
"""响应全局缓存清理事件,清空音乐识别缓存。"""
logger.info("开始清除音乐识别缓存 ...")
if self.cache:
self.cache.clear()
logger.info("音乐识别缓存清除完成")
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_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) -> list[MusicInfo]:
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
normalized_limit = max(1, min(limit, 100))
recordings = self._search_recordings(meta, limit=normalized_limit)
albums = self._search_albums(meta, limit=normalized_limit)
artists = self._search_artists(meta, limit=normalized_limit)
return self._interleave_results(
recordings,
albums,
artists,
limit=normalized_limit,
)
def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按音频标签条件搜索 Recording供全局搜索和文件识别复用。"""
for query in self._recording_queries(meta):
payload = self._request_json(
"/recording",
params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"},
)
results = [
info
for item in (payload or {}).get("recordings") or []
if (info := self._recording_to_info(item))
]
if results:
return results
return []
@classmethod
def _recording_queries(cls, meta: MetaMusic) -> list[str]:
"""构造 Recording 检索式阶梯,由严到宽逐级放宽避免零命中。
条目括号多为全角、艺术家署名存在变体(如外文艺名),精确 AND 条件容易零命中;
放宽后由候选评分负责收紧(已知艺术家时必须艺术家命中),不会产生错误身份。
"""
title = cls._search_title(meta.title)
if not title:
return []
artist = meta.artists[0] if meta.artists else None
# 括号内的影视 tie-in、版本说明多为半角与条目全角写法不一致准备去注释曲名兜底
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._escape_query(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,
]:
if query and query not in queries:
queries.append(query)
return queries
@classmethod
def _strip_parenthetical(cls, value: 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):
remainder = re.sub(r"^[的之]\s*", "", text[len(artist):]).strip()
if remainder:
return remainder
return text
def _search_albums(self, meta: MetaMusic, limit: int) -> 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 = [
album.to_music_info()
for item in (payload or {}).get("release-groups") or []
if (album := self._release_group_to_album(item))
]
if results:
return results
return []
@classmethod
def _album_queries(cls, meta: MetaMusic) -> list[str]:
"""构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。"""
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(title)} AND artist:"{cls._escape_query(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._escape_query(artist)}"'
if artist and bare_title and bare_title != title else None,
f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:"{cls._escape_query(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,
]:
if query and query not in queries:
queries.append(query)
return queries
def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按用户输入中的艺术家部分搜索 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.to_music_info()
for item in (payload or {}).get("artists") or []
if (artist := self._artist_to_info(item, include_raw=True))
]
@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,
) -> Optional[MusicAlbumInfo]:
"""按目录线索和曲目特征把本地音频集合对位到 MusicBrainz 发行版本。
适用于无标签整专目录:用专辑名、歌手搜索候选发行版本,再用曲目数、
总时长和逐曲时长相似度打分,选出最可信的版本并返回其曲目表。
"""
if not tracks:
return None
best_album: Optional[MusicAlbumInfo] = None
best_score = 0.0
for release in self._search_release_candidates(meta, tracks, limit=limit):
release_id = release.get("id")
if not release_id:
continue
detail = self._request_json(
f"/release/{release_id}",
params={"inc": "recordings+media+artist-credits", "fmt": "json"},
)
if not detail:
continue
summary = self._release_track_summary(detail)
score = self._score_release(meta, tracks, detail, summary)
if score > best_score:
best_score = score
best_album = self._release_to_album(detail)
# 得分低于阈值时宁可不匹配,避免把曲目写到错误的专辑上
if best_score < self._album_match_threshold:
return None
return best_album
_album_match_threshold = 60.0
def _search_release_candidates(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int,
) -> list[dict[str, Any]]:
"""按专辑名和曲名线索搜索候选发行版本,多个查询按命中顺序去重。"""
releases: list[dict[str, Any]] = []
seen: set[str] = set()
for query in self._release_queries(meta, tracks):
payload = self._request_json(
"/release",
params={"query": query, "limit": max(1, min(limit, 25)), "fmt": "json"},
)
for item in (payload or {}).get("releases") or []:
release_id = item.get("id")
if release_id and release_id not in seen:
seen.add(release_id)
releases.append(item)
if len(releases) >= limit:
break
return releases[:limit]
@classmethod
def _release_queries(cls, meta: MetaMusic, tracks: list[MetaMusic]) -> list[str]:
"""构造专辑搜索表达式:优先专辑名+歌手,无专辑线索时用曲名兜底。"""
queries: list[str] = []
album_title = 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 track.title.strip().isdigit()]
)[: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]],
) -> float:
"""给候选发行版本打分0-100综合标题、歌手、曲目数和时长相似度。"""
local_count = len(tracks)
release_count = len(summary)
if not release_count:
return 0.0
# 曲目数差异过大直接排除,避免单曲误命中整专或反之
diff = abs(local_count - release_count)
if diff > max(4, int(local_count * 0.5)):
return 0.0
# 本地文件比发行版本多出的曲目无法被覆盖,超出容忍范围视为错误候选
if local_count > release_count and diff > max(1, int(release_count * 0.25)):
return 0.0
score = 0.0
# 标题相似度:专辑目录名或文件标签中的专辑名/曲名
title_hints = cls._unique_texts([meta.album, meta.title])
title_sim = max(
(cls._text_similarity(hint, detail.get("title")) for hint in title_hints),
default=0.0,
)
artist_names = cls._artist_credits(detail.get("artist-credit"))[0]
if meta.artists and artist_names:
artist_sim = max(
cls._text_similarity(meta.artists[0], name) for name in artist_names
)
score += 40 * title_sim + 15 * artist_sim
else:
# 缺少歌手线索时把权重让给标题
score += 50 * title_sim
# 曲目数:完全一致是最强信号
if diff == 0:
score += 15
elif diff == 1:
score += 8
elif diff <= max(2, int(local_count * 0.15)):
score += 2
# 曲名重合度:部分曲目目录(只下载了整专的一部分)依靠曲名对位确认
release_titles = {cls._match_text(item["title"]) for item in summary}
named_tracks = [track for track in tracks if track.title and not track.title.strip().isdigit()]
if named_tracks and release_titles:
overlap = sum(
1 for track in named_tracks if cls._match_text(track.title) in release_titles
)
score += 15 * overlap / len(named_tracks)
# 总时长:无损整专 rip 的总时长与 MusicBrainz 记录高度接近
local_total = sum(track.duration or 0 for track in tracks)
release_total = sum(item["length"] or 0 for item in summary)
local_durations = [track.duration for track in tracks if track.duration]
if local_durations and release_total:
delta = abs(local_total - release_total) / max(local_total, release_total)
if delta <= 0.02:
score += 15
elif delta <= 0.05:
score += 10
elif delta <= 0.10:
score += 5
# 逐曲时长对位:曲目数一致时逐首比较
if diff == 0 and len(local_durations) == local_count:
similarities = []
for track, item in zip(
sorted(tracks, key=lambda item: (item.disc_number or 1, item.track_number or 0)),
summary,
):
if track.duration and item["length"]:
similarities.append(cls._duration_similarity(track.duration, item["length"]))
if similarities:
score += 15 * sum(similarities) / len(similarities)
return score
@staticmethod
def _duration_similarity(left: int, right: 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:
"""移除大小写、空白、标点和繁简差异,生成相似度比较使用的紧凑文本。"""
text = str(value or "").casefold()
try:
# 候选比对统一简体,避免条目繁体写法造成失配
text = zhconv_convert(text, "zh-hans")
except Exception: # pylint: disable=broad-except
pass
return re.sub(r"[\W_]+", "", text, flags=re.UNICODE)
@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(
source=cls._source,
# 优先使用 Release Group ID与专辑详情和封面入口保持一致
media_id=str(group_id or release_id),
title=str(title),
artists=artists,
artist_ids=artist_ids,
album_type=release_group.get("primary-type"),
secondary_types=[str(item) for item in release_group.get("secondary-types") or []],
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)},
)
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 album
def recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
# 非音乐请求交给影视识别模块,不占用识别管线
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
return None
# 无 MetaMusic 元数据时仅响应本数据源的详情识别请求
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
return self.recognize_music(source, str(mediaid))
return None
# 识别缓存命中直接响应,避免重复搜索占用 MusicBrainz 限流配额
cache_enabled = bool(kwargs.get("cache", True))
if cache_enabled and self.cache:
cached_info = self.cache.get(meta)
if cached_info:
if cached_info.media_id:
logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}")
else:
logger.info(f"{meta.title} 使用音乐识别缓存:无法识别")
cached_info.recognize_cache_hit = True
return cached_info
# 携带数据源与原生 ID 的请求优先按详情识别
resolved_source = source or meta.media_source
if resolved_source and (mediaid or meta.media_id):
info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
if info:
self._update_recognize_cache(meta, info)
return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
candidates = self._search_recordings(meta, limit=10)
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source)
# 整专/单曲发行类资源在 Recording 检索无果时,回退按专辑实体识别;
# 专辑挑选要求标题与艺术家同时命中,无艺术家线索时回退检索必然无果,
# 直接跳过避免浪费限流配额(批量识别场景可减少约半数请求)
if not matched and meta.artists:
albums = self._search_albums(meta, limit=10)
matched = self._select_album_candidate(meta, albums)
result = matched or self._info_from_meta(meta)
# 无远端身份的兑底结果同样入缓存,避免批量识别时反复搜索同一文件
self._update_recognize_cache(meta, result)
return result
def _update_recognize_cache(self, meta: MetaMusic, info: Optional[MusicInfo]) -> None:
"""识别完成后把结果写入本地识别缓存,未挂载缓存时静默跳过。"""
if self.cache:
self.cache.update(meta, info)
def update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""回填音乐本地识别缓存,共享识别成功后避免重复回查。"""
if not meta or not mediainfo:
return None
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
return None
if mediainfo.source != self._source:
return None
self._update_recognize_cache(meta, mediainfo)
return True
async def async_update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""异步回填音乐本地识别缓存。"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
async def async_recognize_media(
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""同步分发到音乐识别的异步版本,避免阻塞共享事件循环。"""
return await run_in_threadpool(
self.recognize_media,
meta,
mtype=mtype,
source=source,
mediaid=mediaid,
**kwargs,
)
@classmethod
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(source).casefold()
# 资源标题携带的音质标记先剥离,再与候选曲名比对;
# 曲名开头的艺术家署名前缀是命名习惯,用主体名比对
clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists)
# 条目的影视 tie-in 注释多为全角括号,与资源半角注释无法精确相等,
# 去括号后的主体曲名一致视为弱匹配,且需艺术家同时命中才采信;
# 卷号后缀Vol. 3是发行分卷标记条目本体不含卷号
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
title_match = False
# 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配
artist_match = bool(meta.artists) and any(
cls._same_text(artist_name, candidate_artist)
for artist_name in meta.artists
for candidate_artist in candidate.artists
)
if clean_title and cls._same_text(clean_title, candidate.title):
score += 4
title_match = True
elif (
bare_title
and artist_match
and (
cls._same_text(bare_title, cls._strip_parenthetical(candidate.title))
# 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配
or cls._same_text(bare_title, cls._main_title(candidate.title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(candidate.title))
# 条目「愛情電影主題曲 雲且留住」这类「主体名 补充说明」结构,首段一致视为弱匹配
or (
len(cls._match_text(bare_title)) >= 3
and cls._same_text(bare_title, cls._lead_token(candidate.title))
)
# 条目带额外前缀/后缀完整包含资源主体名(好莱坞原声带类),长文本包含视为弱匹配
or (
len(cls._match_text(bare_title)) >= 6
and cls._match_text(bare_title) in cls._match_text(candidate.title)
)
# 资源标题带演出后缀S.H.E十七音乐会条目本体一致视为弱匹配
or (
cls._performance_title(bare_title)
and cls._same_text(cls._performance_title(bare_title), candidate.title)
)
)
):
score += 2
title_match = True
if artist_match:
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
isrc_match = bool(meta.isrc) and cls._same_text(meta.isrc, candidate.isrc)
if isrc_match:
score += 5
# 同名多版本(如不同年份的重发单曲)靠发行年份消歧
if meta.year and candidate.year and int(meta.year) == int(candidate.year):
score += 1
# 已知艺术家时艺术家未命中的候选不能采信ISRC 精确身份除外),
# 兜住宽检索阶梯下同名异曲的误配CJK 逐字 OR 检索召回宽,
# 标题未命中的候选同样不能仅凭艺术家署名得分ISRC 除外)
if (meta.artists and not artist_match and not isrc_match) or (
not title_match and not isrc_match
):
score = 0
ranked.append((score, candidate))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]:
"""单曲检索未命中时,从专辑候选中挑选高置信目标。
专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回,
避免把音轨身份安到错误专辑上。
"""
clean_title = cls._strip_artist_prefix(
cls._search_title(meta.album or meta.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[int, MusicInfo]] = []
for album in albums:
score = 0
album_title = album.title or album.album
artist_match = bool(meta.artists) and any(
cls._same_text(artist_name, candidate_artist)
for artist_name in meta.artists
for candidate_artist in album.artists
)
title_match = False
# 资源带卷号时候选卷号不一致(含其他分卷)直接排除,避免 Vol.1 误配 Vol.3
if meta_volume and cls._volume_number(album_title) not in (None, meta_volume):
pass
elif cls._same_text(clean_title, album_title):
score += 4
title_match = 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
# 标题与艺术家缺一不可,仅有标题相似不能采信
ranked.append((score if title_match and artist_match else 0, album))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""音乐识别无候选时,把元数据转换为可展示的最小信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
title=meta.title,
artists=list(meta.artists),
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
disc_number=meta.disc_number,
track_number=meta.track_number,
total_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
@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
# MusicBrainz 中文条目以简体为主,资源标题可能是繁体,比对前统一转简体
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"\b(?:single|ep|album)\b",
re.IGNORECASE,
)
@classmethod
def _search_title(cls, value: Optional[str]) -> str:
"""剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。"""
text = cls._quality_token_pattern.sub(" ", str(value or ""))
# 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语
text = text.replace("_", " ")
# 规格剥离后可能残留悬空分隔符,统一修剪
text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", cls._normalize_text(text))
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB整体剔除
text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text)
# 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取),
# 反复剥离尾部年份场景命名可能重复携带Live At Montreux 2011 2011
text = cls._normalize_text(text)
while True:
# 仅剔除空白分隔的尾部年份纯年份标题1999无前导空白不受影响
stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text)
if stripped == text:
break
text = stripped
return cls._normalize_text(text)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。"""
if source != self._source or not media_id:
return None
payload = self._request_json(
f"/recording/{media_id}",
params={
"inc": "artists+releases+release-groups+isrcs+genres",
"fmt": "json",
},
)
if payload:
return self._recording_to_info(payload)
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。"""
if source != self._source or not media_id:
return None
payload = self._request_json(
f"/release-group/{media_id}",
params={
"inc": "artists+releases+media+genres+tags+ratings",
"fmt": "json",
},
)
if not payload:
return None
album = self._release_group_to_album(payload)
if not album:
return None
album.releases = self._release_variants(payload.get("releases") or [])
album.tracks = self._album_tracks(album, payload.get("releases") or [])
return album
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""按 MusicBrainz Artist ID 获取标准化艺术家详情。"""
if 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,
source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""按 MusicBrainz Artist ID 分页浏览该艺术家的专辑、EP 和单曲。"""
if 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))
]
# MusicBrainz 浏览接口不支持排序,只能在当前页内按发行日期倒序,保证首页是最新作品
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,
source: str,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""按 MusicBrainz 艺术家关系返回可继续浏览的关联艺术家。"""
if 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)
if title:
clauses.append(f"recording:{cls._query_phrase(title)}")
if meta.artists:
clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"')
if meta.album:
clauses.append(f'release:"{cls._escape_query(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()
# 中日韩字符Lucene 标准分词器不会切分连续 CJK短语检索对中文标题永远零命中
_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]) -> Optional[str]:
"""构造适配 Lucene 分词的检索表达式。
无 CJK 的普通文本返回带引号短语;含 CJK 的文本拆为词元后用 OR 交集检索,
MusicBrainz 索引中连续 CJK 是单一词元,逐字 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)}"'
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"))
category_parts = [release_group.get("primary-type")]
category_parts.extend(release_group.get("secondary-types") or [])
return MusicInfo(
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,
album_type=release_group.get("primary-type"),
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,
category=" / ".join(str(part) for part in category_parts if part),
genres=cls._names_of(recording.get("genres")),
names=[name for name in (title, album) if name],
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 MusicAlbumInfo(
source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists,
artist_ids=artist_ids,
album_type=release_group.get("primary-type"),
secondary_types=[str(item) for item in release_group.get("secondary-types") or []],
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
@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]]) -> 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:
return next((release for release in releases if release.get("id")), {})
official = [release for release in candidates if release.get("status") == "Official"]
return min(
official or candidates,
key=lambda release: cls._date_sort_key(release.get("date")),
)
@classmethod
def _album_tracks(
cls,
album: MusicAlbumInfo,
releases: list[dict[str, Any]],
) -> list[MusicInfo]:
"""读取专辑代表性发行版本的曲目,作为专辑内音乐列表。"""
release = cls._select_track_release(releases)
if not release.get("id"):
return []
payload = cls._request_json(
f"/release/{release['id']}",
params={"inc": "recordings+artist-credits", "fmt": "json"},
)
tracks: list[MusicInfo] = []
for medium in (payload or {}).get("media") or []:
for track in medium.get("tracks") or []:
info = cls._track_to_info(album, medium, track)
if info:
tracks.append(info)
return tracks
@classmethod
def _track_to_info(
cls,
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 MusicInfo(
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),
album=album.title,
album_artist=album.artist or None,
album_id=album.media_id,
album_type=album.album_type,
year=album.year,
release_date=recording.get("first-release-date") or 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")),
cover_url=album.cover_url,
version=recording.get("disambiguation") or None,
category=album.category,
names=[str(title)],
detail_link=f"{cls._detail_url}/{media_id}",
)
@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
return min(
candidates,
key=lambda release: 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 _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(
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 = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/")
return f"{base}/release-group/{release_group_id}/front-500"
@classmethod
def _get_session(cls) -> Session:
"""懒创建并复用全局 HTTP 会话,批量识别时避免重复建连。"""
if cls._session is None:
with cls._session_lock:
if cls._session is None:
cls._session = Session()
return cls._session
@classmethod
def _wait_for_rate_limit(cls) -> None:
"""串行控制 MusicBrainz 公共接口的最小请求间隔。"""
with cls._request_lock:
now = time.monotonic()
remaining = cls._request_interval - (now - cls._last_request_at)
if remaining > 0:
time.sleep(remaining)
cls._last_request_at = time.monotonic()
@classmethod
@cached(maxsize=settings.CONF.musicbrainz, ttl=settings.CONF.meta, skip_none=True)
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):
cls._wait_for_rate_limit()
response = RequestUtils(
headers={
"User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)",
"Accept": "application/json",
},
proxies=settings.PROXY,
session=cls._get_session(),
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
if response is None:
return None
status_code = response.status_code
try:
if status_code == 404:
# 单曲与专辑共用同一套 ID 入口404 属于正常的探测结果
logger.debug(f"MusicBrainz 资源不存在:{path}")
# 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。
return {}
if status_code == 429 or status_code >= 500:
logger.warning(
f"MusicBrainz 服务繁忙:{status_code} {response.text[:200]}"
)
if attempt < attempts - 1:
time.sleep(cls._busy_backoff * (2 ** attempt))
continue
return None
if status_code != 200:
logger.warning(
f"MusicBrainz 请求失败:{status_code} {response.text[:200]}"
)
return None
return response.json()
except (TypeError, ValueError) as err:
logger.warning(f"MusicBrainz 响应解析失败:{err}")
return None
finally:
response.close()
return None