fix: 对齐音乐备选来源匹配并校验录音版本证据

This commit is contained in:
jxxghp
2026-09-06 04:06:00 +08:00
parent 976cecce8f
commit a01a941c11
13 changed files with 365 additions and 67 deletions
+65 -7
View File
@@ -2,6 +2,7 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from typing import Iterable, Literal, Optional from typing import Iterable, Literal, Optional
from unicodedata import combining, normalize from unicodedata import combining, normalize
@@ -33,6 +34,9 @@ _BARE_VERSION_SUFFIX = re.compile(
) )
_TITLE_LABEL = re.compile(r"^(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|曲名|歌曲|album|title)\s*[:]\s*", re.I) _TITLE_LABEL = re.compile(r"^(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|曲名|歌曲|album|title)\s*[:]\s*", re.I)
_COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人") _COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")
_VERSION_YEAR = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
_VERSION_DATE = re.compile(r"(?<!\d)((?:19|20)\d{2})(?:[-./]|年)\s*(\d{1,2})(?:[-./]|月)\s*(\d{1,2})日?(?!\d)")
_ISRC = re.compile(r"[A-Z]{2}[A-Z0-9]{3}[0-9]{7}", re.IGNORECASE | re.ASCII)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -95,10 +99,40 @@ def music_artist_matches(music: MusicInfo, parsed_artists: Iterable[str]) -> boo
return bool(keys & {music_text_key(artist) for artist in artists}) return bool(keys & {music_text_key(artist) for artist in artists})
def music_base_title(value: Optional[str]) -> str: def music_base_title(value: Optional[str], *, preserve_editions: bool = False) -> str:
"""剥离已知发行版本后缀,保留未知括号和属于作品本身的文字""" """剥离已知版本后缀;数据源可保留发行版标签,不改变未知括号中的作品名"""
text = _VERSION_SUFFIX.sub("", _EDITION.sub("", str(value or ""))) text = str(value or "")
return _BARE_VERSION_SUFFIX.sub("", text).strip() if not preserve_editions:
text = _EDITION.sub("", text)
def strip_version(match: re.Match[str]) -> str:
"""保留发行版声明,避免被包含它的录音版本标签一并删除。"""
return match.group(0) if preserve_editions and _EDITION.search(match.group(0)) else ""
return _BARE_VERSION_SUFFIX.sub(strip_version, _VERSION_SUFFIX.sub(strip_version, text)).strip()
def music_title_matches(music: MusicInfo, title: Optional[str], *, preserve_editions: bool = False) -> bool:
"""统一全半角后比较完整名称及别名,允许数据源保持既有发行版本边界。"""
expected = music_text_key(music_base_title(normalize("NFKC", str(title or "")), preserve_editions=preserve_editions))
return bool(expected and any(
expected == music_text_key(music_base_title(normalize("NFKC", name), preserve_editions=preserve_editions))
for name in music_titles(music)
))
def _isrc_key(value: Optional[str]) -> Optional[str]:
"""校验 12 位 ISRC 结构,兼容展示前缀、空白和分隔符,不接受占位值。"""
code = re.sub(r"[\s-]+", "", str(value or ""))
if not _ISRC.fullmatch(code) and code[:4].lower() == "isrc":
code = code[4:].lstrip(":")
return code.upper() if _ISRC.fullmatch(code) else None
def music_isrc_matches(music: MusicInfo, meta: MetaMusic) -> bool:
"""只有格式有效且相同的 ISRC 才能作为优先于文本匹配的录音身份。"""
expected = _isrc_key(meta.isrc)
return bool(expected and expected == _isrc_key(music.isrc))
def _contains_artist(text: str, artist: str) -> bool: def _contains_artist(text: str, artist: str) -> bool:
@@ -141,7 +175,24 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal
def _version_markers(text: str) -> set[str]: def _version_markers(text: str) -> set[str]:
"""识别会改变录音身份的版本标记,普通发行后缀单独处理。""" """识别会改变录音身份的版本标记,普通发行后缀单独处理。"""
return {name for name, pattern in _VERSIONS.items() if re.search(pattern, text, re.I)} normalized = normalize("NFKC", text)
return {name for name, pattern in _VERSIONS.items() if re.search(pattern, normalized, re.I)}
def _version_dates(title: Optional[str], version: Optional[str]) -> tuple[set[int], set[date]]:
"""只提取明确版本字段及版本后缀中的日期,不把数字作品名或发行年份当作录制日期。"""
title_text = normalize("NFKC", str(title or ""))
text = normalize("NFKC", " ".join([
str(version or ""), *_VERSION_SUFFIX.findall(title_text), *_BARE_VERSION_SUFFIX.findall(title_text),
]))
years = {int(year) for year in _VERSION_YEAR.findall(text)}
dates: set[date] = set()
for match in _VERSION_DATE.finditer(text):
try:
dates.add(date(*(int(value) for value in match.groups())))
except ValueError:
continue
return years, dates
def music_version_matches(music: MusicInfo, meta: MetaMusic) -> bool: def music_version_matches(music: MusicInfo, meta: MetaMusic) -> bool:
@@ -150,7 +201,14 @@ def music_version_matches(music: MusicInfo, meta: MetaMusic) -> bool:
# 专辑类型描述整专版本,但单曲的所属专辑类型不能代替该录音自身的版本。 # 专辑类型描述整专版本,但单曲的所属专辑类型不能代替该录音自身的版本。
album_versions = " ".join(music.secondary_types or []) if music.music_type == MUSIC_ENTITY_ALBUM else "" album_versions = " ".join(music.secondary_types or []) if music.music_type == MUSIC_ENTITY_ALBUM else ""
expected = _version_markers(f"{target_title or ''} {music.version or ''} {album_versions}") expected = _version_markers(f"{target_title or ''} {music.version or ''} {album_versions}")
return expected == _version_markers(f"{meta.title or ''} {meta.version or ''}") if expected != _version_markers(f"{meta.title or ''} {meta.version or ''}"):
return False
expected_years, expected_dates = _version_dates(target_title, music.version)
actual_years, actual_dates = _version_dates(meta.title, meta.version)
# 多个时间值可能描述区间或重发记录,不能当作唯一录制时间互斥比较。
if len(expected_dates) == len(actual_dates) == 1 and expected_dates != actual_dates:
return False
return not (len(expected_years) == len(actual_years) == 1 and expected_years != actual_years)
def match_music_resource( def match_music_resource(
@@ -171,7 +229,7 @@ def match_music_resource(
names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM, names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM,
album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None) album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None)
titles = music_titles(music) titles = music_titles(music)
title_matched = any(music_text_key(music_base_title(item)) in names for item in titles) title_matched = any(music_title_matches(music, name) for name in names)
content = f"{title} {description}" content = f"{title} {description}"
artist_matched = music_artist_matches(music, resource.artists) if resource.artists \ artist_matched = music_artist_matches(music, resource.artists) if resource.artists \
else any(_contains_artist(content, artist) for artist in artists) else any(_contains_artist(content, artist) for artist in artists)
+15 -27
View File
@@ -14,6 +14,7 @@ from app.domain.media import is_media_source_enabled, is_media_source_selected
from app.domain.meta.metabase import MetaBase from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.domain.music import music_artist_matches, music_title_matches, music_version_matches
from app.foundation.text import convert as zhconv_convert from app.foundation.text import convert as zhconv_convert
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules._base.media import MediaAuxiliaryProviderMixin from app.modules._base.media import MediaAuxiliaryProviderMixin
@@ -294,7 +295,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
direct_result = self._direct_music_candidate(plan, candidate) direct_result = self._direct_music_candidate(plan, candidate)
if direct_result is not None: if direct_result is not None:
return direct_result return direct_result
if meta.album and meta.title: if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title:
info = self.doubanapi.music_detail(subject_id=str(candidate.media_id)) info = self.doubanapi.music_detail(subject_id=str(candidate.media_id))
album = self._douban_music_to_album(info) if info else None album = self._douban_music_to_album(info) if info else None
matched_track = self._select_douban_music_track(meta, album) matched_track = self._select_douban_music_track(meta, album)
@@ -331,7 +332,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
direct_result = self._direct_music_candidate(plan, candidate) direct_result = self._direct_music_candidate(plan, candidate)
if direct_result is not None: if direct_result is not None:
return direct_result return direct_result
if meta.album and meta.title: if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title:
info = await self.doubanapi.async_music_detail( info = await self.doubanapi.async_music_detail(
subject_id=str(candidate.media_id) subject_id=str(candidate.media_id)
) )
@@ -408,20 +409,17 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
meta: MetaMusic, meta: MetaMusic,
candidates: List[MusicInfo], candidates: List[MusicInfo],
) -> List[MusicInfo]: ) -> List[MusicInfo]:
"""按专辑标题和可用艺术家线索过滤豆瓣音乐候选""" """按专辑名称与专辑署名筛选父级候选,录音版本留待实际目标确认"""
expected_title = meta.album or meta.title expected_title = meta.album or meta.title
expected_artists = [meta.album_artist] if meta.album_artist else meta.artists
return [ return [
candidate candidate
for candidate in candidates for candidate in candidates
if cls._same_music_text(expected_title, candidate.title) if music_title_matches(candidate, expected_title, preserve_editions=True)
and ( and (
not meta.artists not expected_artists
or not candidate.artists or not candidate.artists
or any( or music_artist_matches(candidate, expected_artists)
cls._same_music_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
)
) )
] ]
@@ -432,13 +430,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
) -> Optional[MusicInfo]: ) -> Optional[MusicInfo]:
"""决定候选可直接返回,还是必须继续读取专辑曲目。""" """决定候选可直接返回,还是必须继续读取专辑曲目。"""
meta = plan.require_meta() meta = plan.require_meta()
if plan.music_type == MUSIC_ENTITY_ALBUM: if plan.music_type != MUSIC_ENTITY_ALBUM and (
return candidate (meta.album and meta.title) or plan.music_type == MUSIC_ENTITY_RECORDING
if meta.album and meta.title: ):
return None return None
if plan.music_type == MUSIC_ENTITY_RECORDING: album_meta = MetaMusic(title=meta.album or meta.title, version=meta.version)
return None return candidate if music_version_matches(candidate, album_meta) else None
return candidate
@classmethod @classmethod
def _select_douban_music_track( def _select_douban_music_track(
@@ -451,16 +448,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
return None return None
candidates = [ candidates = [
track for track in album.tracks track for track in album.tracks
if cls._same_music_text(meta.title, track.title) if music_title_matches(track, meta.title, preserve_editions=True) and music_version_matches(track, meta)
] ]
if meta.artists: if meta.artists:
candidates = [ candidates = [
track for track in candidates track for track in candidates
if any( if music_artist_matches(track, meta.artists)
cls._same_music_text(expected, actual)
for expected in meta.artists
for actual in track.artists
)
] ]
if not candidates: if not candidates:
return None return None
@@ -785,11 +778,6 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
text = cls._douban_music_text(value) text = cls._douban_music_text(value)
return int(text[:4]) if text and text[:4].isdigit() else None return int(text[:4]) if text and text[:4].isdigit() else None
@staticmethod
def _same_music_text(left: Optional[str], right: Optional[str]) -> bool:
"""使用音乐元数据紧凑文本规则比较豆瓣候选。"""
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
@staticmethod @staticmethod
def _prepare_search_names(meta: MetaBase) -> List[str]: def _prepare_search_names(meta: MetaBase) -> List[str]:
""" """
+6 -2
View File
@@ -21,7 +21,9 @@ from app.domain.meta.metamusic import MetaMusic
from app.domain.music import ( from app.domain.music import (
music_artist_matches, music_artist_matches,
music_base_title, music_base_title,
music_isrc_matches,
music_text_key, music_text_key,
music_title_matches,
music_titles, music_titles,
music_version_matches, music_version_matches,
unique_music_texts, unique_music_texts,
@@ -1092,6 +1094,8 @@ class MusicBrainzModule(_ModuleBase):
return None return None
if plan.music_type and cached_info.music_type != plan.music_type: if plan.music_type and cached_info.music_type != plan.music_type:
return None return None
if cached_info.media_id and not music_isrc_matches(cached_info, meta) and not music_version_matches(cached_info, meta):
return None
if cached_info.media_id: if cached_info.media_id:
logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}") logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}")
else: else:
@@ -1223,7 +1227,7 @@ class MusicBrainzModule(_ModuleBase):
for candidate in candidates: for candidate in candidates:
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source: if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
continue continue
if meta.isrc and cls._same_text(meta.isrc, candidate.isrc): if music_isrc_matches(candidate, meta):
# 相同 ISRC 是明确录音身份,不能被另一条纯标题命中的得分压过。 # 相同 ISRC 是明确录音身份,不能被另一条纯标题命中的得分压过。
return candidate return candidate
score = 0 score = 0
@@ -1237,7 +1241,7 @@ class MusicBrainzModule(_ModuleBase):
elif ( elif (
bare_title bare_title
and artist_match and artist_match
and any(cls._same_text(bare_title, music_base_title(title)) for title in titles) and music_title_matches(candidate, clean_title)
): ):
score += 2 score += 2
title_match = True title_match = True
+18 -25
View File
@@ -10,6 +10,7 @@ from app.domain.context import (
from app.domain.media import is_media_source_selected from app.domain.media import is_media_source_selected
from app.domain.meta.metabase import MetaBase from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.music import music_artist_matches, music_isrc_matches, music_title_matches, music_version_matches
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.runtime.cache import cached from app.runtime.cache import cached
from app.runtime.log import logger from app.runtime.log import logger
@@ -483,9 +484,9 @@ class TheAudioDbModule(_ModuleBase):
@staticmethod @staticmethod
def _album_search_params(meta: MetaMusic) -> Optional[dict[str, str]]: def _album_search_params(meta: MetaMusic) -> Optional[dict[str, str]]:
"""从音乐元数据归一化专辑搜索参数""" """专辑查询优先使用专辑署名,不把合辑中的单曲表演者当作专辑艺人"""
album_name = meta.album or meta.title album_name = meta.album or meta.title
artist = meta.artists[0] if meta.artists else meta.album_artist artist = meta.album_artist or (meta.artists[0] if meta.artists else None)
if not album_name or not artist: if not album_name or not artist:
return None return None
return {"a": album_name, "s": artist} return {"a": album_name, "s": artist}
@@ -511,41 +512,37 @@ class TheAudioDbModule(_ModuleBase):
for item in self._entities(payload, "artists", "artist") for item in self._entities(payload, "artists", "artist")
] ]
@classmethod @staticmethod
def _select_track( def _select_track(
cls,
meta: MetaMusic, meta: MetaMusic,
candidates: list[MusicInfo], candidates: list[MusicInfo],
) -> Optional[MusicInfo]: ) -> Optional[MusicInfo]:
"""按曲名和可用艺术家线索选择可信单曲候选。""" """复用统一音乐证据确认单曲,明确 ISRC 优先于名称候选。"""
identity = next((candidate for candidate in candidates if music_isrc_matches(candidate, meta)), None)
if identity is not None:
return identity
for candidate in candidates: for candidate in candidates:
if not cls._same_text(meta.title, candidate.title): if not music_title_matches(candidate, meta.title, preserve_editions=True) or not music_version_matches(candidate, meta):
continue continue
if meta.artists and not any( if meta.artists and not music_artist_matches(candidate, meta.artists):
cls._same_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue continue
return candidate return candidate
return None return None
@classmethod @staticmethod
def _select_album( def _select_album(
cls,
meta: MetaMusic, meta: MetaMusic,
candidates: list[MusicAlbumInfo], candidates: list[MusicAlbumInfo],
) -> Optional[MusicAlbumInfo]: ) -> Optional[MusicAlbumInfo]:
"""按专辑名和可用艺术家线索选择可信专辑候选""" """将来源专辑投影到统一音乐模型,按专辑名、专辑署名与版本确认"""
expected_title = meta.album or meta.title expected_title = meta.album or meta.title
expected_artists = [meta.album_artist] if meta.album_artist else meta.artists
album_meta = MetaMusic(title=expected_title, version=meta.version)
for candidate in candidates: for candidate in candidates:
if not cls._same_text(expected_title, candidate.title): music = candidate.to_music_info()
if not music_title_matches(music, expected_title, preserve_editions=True) or not music_version_matches(music, album_meta):
continue continue
if meta.artists and not any( if expected_artists and not music_artist_matches(music, expected_artists):
cls._same_text(expected, actual)
for expected in meta.artists
for actual in candidate.artists
):
continue continue
return candidate return candidate
return None return None
@@ -589,6 +586,7 @@ class TheAudioDbModule(_ModuleBase):
metadata_category=" / ".join(genres), metadata_category=" / ".join(genres),
genres=genres, genres=genres,
names=cls._unique_texts([title, item.get("strTrackAlternate")]), names=cls._unique_texts([title, item.get("strTrackAlternate")]),
title_aliases=cls._unique_texts([item.get("strTrackAlternate")]),
detail_link=f"{cls._detail_url}/track/{media_id}", detail_link=f"{cls._detail_url}/track/{media_id}",
raw_data={ raw_data={
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")), "musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
@@ -874,8 +872,3 @@ class TheAudioDbModule(_ModuleBase):
seen.add(identity) seen.add(identity)
results.append(text) results.append(text)
return results return results
@staticmethod
def _same_text(left: Optional[str], right: Optional[str]) -> bool:
"""使用音乐元数据紧凑文本规则比较标题和艺术家。"""
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
+1 -1
View File
@@ -755,7 +755,7 @@ flowchart LR
| 指标 | 当前值 | | 指标 | 当前值 |
|---|---:| |---|---:|
| Python 模块 | 973 | | Python 模块 | 973 |
| 内部导入边 | 8,259 | | 内部导入边 | 8,261 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 | | Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment | | Direct egress | 53(债务已清零,53 条精确 containment |
+10
View File
@@ -78,6 +78,16 @@
- MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。 - MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。
单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。 单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。
专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。 专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。
- TheAudioDB 与豆瓣音乐也复用名称、署名和版本规则;TheAudioDB 的 `strTrackAlternate`
投影为曲名别名,不能混入专辑名称。专辑查询及父专辑确认优先使用 `album_artist`
单曲仍按自己的表演者确认,不因合辑署名不同而跳过其曲目。
备选来源保留既有发行版名称约束,不因录音版本后缀归一化而将豪华版合并成普通版。
- 录音版本双方各自提供唯一有效年份或日期且冲突时,不确认成同一版本;支持全半角、
数字分隔日期及中文日期。普通数字曲名、缺失日期、无效日期与多时间值不被臆断为冲突。
已缓存的无显式 ID 名称识别结果也重新检查版本,已核验的相同 ISRC 保持身份优先级。
- ISRC 优先级仅适用于符合 [ISRC 官方结构](https://isrc.ifpi.org/isrc-standard/isrc-structure)
的代码,兼容大小写、展示前缀及分隔符,不能把占位文本相等当成身份相等。
代码内的年份是分配参考年,不用于推断录制日期。
- 自动识别在某个检索式返回的候选全部不匹配时继续尝试后续检索式;手动元数据目录 - 自动识别在某个检索式返回的候选全部不匹配时继续尝试后续检索式;手动元数据目录
浏览仍保留原始候选。Python/Rust 标题解析进入共同包装层时均保留 `[Live]` 等版本证据。 浏览仍保留原始候选。Python/Rust 标题解析进入共同包装层时均保留 `[Live]` 等版本证据。
- 音乐识别缓存按请求实体与来源身份或完整标题、署名、专辑、年份、版本、ISRC 编码, - 音乐识别缓存按请求实体与来源身份或完整标题、署名、专辑、年份、版本、ISRC 编码,
+1 -1
View File
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 | | 指标 | 当前值 | 解释 |
|---|---:|---| |---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 976 / 8,259 | `dependency-baseline.json` 当前快照 | | 宿主 Python 模块 / 内部依赖边 | 976 / 8,261 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
+3
View File
@@ -332,6 +332,9 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录, 音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录,
旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段 旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段
不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。 不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。
这些名称、艺名和版本规则也适用于显式选择的 TheAudioDB 与豆瓣音乐;查询专辑或确认
单曲所属专辑时优先使用专辑艺人,不能覆盖单曲的表演者。版本字段中双方明确且唯一的
年份、录制日期冲突会使资源进入 `version_mismatch` 待确认项,缺少日期不单独造成淘汰。
音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true` 音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`
额外返回待确认资源及关联专辑:`match_status=candidate``match_reason` 描述原因, 额外返回待确认资源及关联专辑:`match_status=candidate``match_reason` 描述原因,
+4
View File
@@ -170,6 +170,10 @@ Call the gateway with this shape:
Explicit subtitle versions participate in matching. A track's `album` field Explicit subtitle versions participate in matching. A track's `album` field
does not prove whole-album coverage, even without a track number; keep does not prove whole-album coverage, even without a track number; keep
`partial_album` candidates out of automatic downloads. `partial_album` candidates out of automatic downloads.
- TheAudioDB and Douban Music use the same name, artist, and version evidence
rules. Album lookup uses the album credit without replacing the track's
performer. Conflicting explicit recording dates are version mismatches;
missing dates alone do not reject a candidate.
## Operation Catalog ## Operation Catalog
+4 -2
View File
@@ -1089,8 +1089,8 @@
"runtime_only": true "runtime_only": true
} }
}, },
"edge_count": 8259, "edge_count": 8261,
"edge_sha256": "a380bbd37ee23f66e7195b0c143b4d5b2a25b17254c18946789b45174ec2eb92", "edge_sha256": "062c4385a99cd29502ce65387bda0f768399c824f293db9b2d90e108f8c340b3",
"edges": [ "edges": [
"app -> app.foundation", "app -> app.foundation",
"app -> app.foundation.environment", "app -> app.foundation.environment",
@@ -6214,6 +6214,7 @@
"app.modules.douban -> app.domain.meta.metabase", "app.modules.douban -> app.domain.meta.metabase",
"app.modules.douban -> app.domain.meta.metamusic", "app.modules.douban -> app.domain.meta.metamusic",
"app.modules.douban -> app.domain.metainfo", "app.modules.douban -> app.domain.metainfo",
"app.modules.douban -> app.domain.music",
"app.modules.douban -> app.foundation", "app.modules.douban -> app.foundation",
"app.modules.douban -> app.foundation.text", "app.modules.douban -> app.foundation.text",
"app.modules.douban -> app.modules", "app.modules.douban -> app.modules",
@@ -7228,6 +7229,7 @@
"app.modules.theaudiodb -> app.domain.meta", "app.modules.theaudiodb -> app.domain.meta",
"app.modules.theaudiodb -> app.domain.meta.metabase", "app.modules.theaudiodb -> app.domain.meta.metabase",
"app.modules.theaudiodb -> app.domain.meta.metamusic", "app.modules.theaudiodb -> app.domain.meta.metamusic",
"app.modules.theaudiodb -> app.domain.music",
"app.modules.theaudiodb -> app.modules", "app.modules.theaudiodb -> app.modules",
"app.modules.theaudiodb -> app.runtime", "app.modules.theaudiodb -> app.runtime",
"app.modules.theaudiodb -> app.runtime.cache", "app.modules.theaudiodb -> app.runtime.cache",
+40 -1
View File
@@ -13,7 +13,7 @@ from app.domain.context import Context, MusicAlbumInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.meta.runtime import get_metainfo_accelerator from app.domain.meta.runtime import get_metainfo_accelerator
from app.domain.metainfo import MetaInfo, MetaInfoPath from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.domain.music import match_music_resource from app.domain.music import match_music_resource, music_isrc_matches, music_version_matches
from app.schemas.music import MusicMeta from app.schemas.music import MusicMeta
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -134,6 +134,45 @@ def test_resource_title_version_has_priority_over_subtitle():
assert MetaMusic.parse_resource("Song FLAC", "艺术家:Live").version is None assert MetaMusic.parse_resource("Song FLAC", "艺术家:Live").version is None
@pytest.mark.parametrize("expected,actual,matched", [
("Live 1999", "Live 2000", False),
("Live 1999-01-02", "Live 1999.01.03", False),
("Live 1999-01-02", "Live 1999/1/2", True),
("Live 1999", "Live 1999-01-02", True),
("Live", "Live 1999", True),
("Live 1999", "Live", True),
("mix 1999", "mix 2000", False),
("Live 1999-02-31", "Live 1999-03-01", True),
("Live 1999-01-02", "Live 1999-01-02", True),
("Live 1999年1月2日", "Live 1999-01-03", False),
("Live 1999-2001", "Live 2000", True),
("Live 1999-01-01 to 1999-01-03", "Live 1999-01-02", True),
])
def test_music_version_checks_only_explicit_conflicting_dates(expected, actual, matched):
"""同类录音的明确日期或年份冲突仍须排除;缺失、部分日期或非法日期不能凭空补全。"""
target = MusicInfo(title="1999", artists=["Artist"], version=expected)
meta = MetaMusic(title="1999", artists=["Artist"], version=actual)
assert music_version_matches(target, meta) is matched
def test_music_resource_rejects_different_dated_live_recording():
"""版本日期可来自标题括号,不将两场同名现场录音自动绑定成同一作品。"""
target = MusicInfo(title="Song (Live 2001-05-02)", artists=["Artist"])
assert match_music_resource(target, "Artist - Song (Live 2001-05-03) FLAC").reason == "version_mismatch"
@pytest.mark.parametrize("code", [None, "", "Unknown", "N/A", "0", "---", "USABC260001", "UŚABC2600001"])
def test_invalid_isrc_cannot_be_strong_identity(code):
"""空值、占位值与格式错误不能因为文本相同而绕过名称和版本匹配。"""
assert music_isrc_matches(MusicInfo(isrc=code), MetaMusic(isrc=code)) is False
@pytest.mark.parametrize("code", ["USABC2600001", "us-abc-26-00001", "ISRC US-ABC-26-00001"])
def test_valid_isrc_accepts_standard_display_format(code):
"""标准代码、大小写和用于展示的前缀分隔格式应指向同一录音身份。"""
assert music_isrc_matches(MusicInfo(isrc="USABC2600001"), MetaMusic(isrc=code)) is True
@pytest.mark.parametrize("mtype,suffix", [(MediaType.MUSIC, " FLAC"), (None, ".flac")]) @pytest.mark.parametrize("mtype,suffix", [(MediaType.MUSIC, " FLAC"), (None, ".flac")])
@pytest.mark.parametrize("global_words", [False, True]) @pytest.mark.parametrize("global_words", [False, True])
def test_music_metainfo_applies_shared_recognition_words(monkeypatch, mtype, suffix, global_words): def test_music_metainfo_applies_shared_recognition_words(monkeypatch, mtype, suffix, global_words):
+176 -1
View File
@@ -6,8 +6,9 @@ import pytest
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain from app.chain.scraping import ScrapingChain
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo from app.domain.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.music import match_music_resource
from app.modules.douban import DoubanModule from app.modules.douban import DoubanModule
from app.modules.theaudiodb import TheAudioDbModule from app.modules.theaudiodb import TheAudioDbModule
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType from app.schemas.types import MediaRecognizeType, MediaSource, MediaType
@@ -48,6 +49,157 @@ def test_theaudiodb_module_maps_track_and_album(monkeypatch):
assert module.get_subtype() == MediaRecognizeType.TheAudioDB assert module.get_subtype() == MediaRecognizeType.TheAudioDB
def _selected_music_candidate(source, music_type, meta, candidate):
"""使用各来源真实确认入口,保留专辑与曲目各自的数据结构。"""
if source == MediaSource.TheAudioDB:
if music_type == "recording":
return TheAudioDbModule._select_track(meta, [candidate])
album = MusicAlbumInfo.from_dict(candidate.to_dict())
return TheAudioDbModule._select_album(meta, [album])
if music_type == "recording":
return DoubanModule._select_douban_music_track(meta, MusicAlbumInfo(tracks=[candidate]))
candidates = DoubanModule._matching_music_candidates(meta, [candidate])
plan = DoubanModule._music_recognition_plan(meta, source, None, "album")
return DoubanModule._direct_music_candidate(plan, candidates[0]) if candidates else None
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("title,artists,candidate_title,candidate_artists", [
("永遠是朋友", ["周華健"], "永远是朋友", ["周华健"]),
("Example Work", ["AC", "DC"], "Example Work", ["AC/DC"]),
("Example Work", ["Beyonce"], "Example Work", ["Beyoncé"]),
("Fine Day", ["Jay Chou"], "晴天", ["周杰倫"]),
])
def test_alternate_sources_share_music_name_and_artist_rules(source, music_type, title, artists,
candidate_title, candidate_artists):
"""备选来源确认同样支持繁简、变音符、复合艺名和来自同一实体的可信别名。"""
meta = MetaMusic(title=title, artists=artists)
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=candidate_artists,
title_aliases=["Fine Day"], artist_aliases=["Jay Chou"])
result = _selected_music_candidate(source, music_type, meta, candidate)
assert result and result.media_id == "candidate"
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("candidate_title,matched", [
("Example Work", False), ("Example Work (Live)", True), ("Example Work(Live)", True),
])
def test_alternate_sources_verify_recording_versions(source, music_type, candidate_title, matched):
"""标题中的版本与独立版本字段可以互认,但现场版不能被确认成普通录音。"""
meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live")
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=["Artist"])
assert bool(_selected_music_candidate(source, music_type, meta, candidate)) is matched
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("requested,candidate_title", [
("Example Work (Deluxe Edition)", "Example Work"),
("Example Work", "Example Work (Deluxe Edition)"),
("Example Work (Live Deluxe Edition)", "Example Work (Live)"),
])
def test_alternate_sources_keep_existing_edition_boundaries(source, music_type, requested, candidate_title):
"""共用录音版本规则时不能放宽备选来源原有的发行版名称约束。"""
candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type,
title=candidate_title, artists=["Artist"])
meta = MetaMusic(title=requested, artists=["Artist"])
assert _selected_music_candidate(source, music_type, meta, candidate) is None
def test_theaudiodb_preserves_source_track_alias_for_all_matching():
"""TheAudioDB 已提供的曲名别名应同时用于元数据确认和资源搜索匹配。"""
candidate = TheAudioDbModule._track_to_info({
"idTrack": "1", "strTrack": "晴天", "strTrackAlternate": "Fine Day", "strArtist": "Artist",
})
assert candidate.title_aliases == ["Fine Day"]
assert TheAudioDbModule._select_track(MetaMusic(title="Fine Day", artists=["Artist"]), [candidate]) is candidate
assert match_music_resource(candidate, "Artist - Fine Day FLAC").status == "exact"
def test_theaudiodb_album_query_and_matching_use_album_artist():
"""合辑的专辑艺人用于专辑检索和确认,单曲查询则保持独立表演者署名。"""
meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists")
album = MusicAlbumInfo(title="Sampler", artists=["Various Artists"])
assert TheAudioDbModule._track_search_params(meta) == {"t": "Song", "s": "Performer"}
assert TheAudioDbModule._album_search_params(meta) == {"a": "Sampler", "s": "Various Artists"}
assert TheAudioDbModule._select_album(meta, [album]) is album
def test_theaudiodb_isrc_identity_precedes_title_matches():
"""明确的 ISRC 录音身份必须优先于首条同名候选。"""
meta = MetaMusic(title="Song", artists=["Artist"], isrc="USABC2600001")
first = MusicInfo(title="Song", artists=["Artist"], media_source="theaudiodb", media_id="wrong")
exact = MusicInfo(title="Alternate Name", artists=["Other Credit"], media_source="theaudiodb",
media_id="correct", isrc="USABC2600001")
assert TheAudioDbModule._select_track(meta, [first, exact]) is exact
def test_theaudiodb_placeholder_isrc_does_not_override_music_evidence():
"""外部数据中的相同 ISRC 占位值不能让另一首作品抢占正确候选。"""
meta = MetaMusic(title="Song", artists=["Artist"], isrc="N/A")
first = MusicInfo(title="Other Song", artists=["Other Artist"], isrc="N/A")
correct = MusicInfo(title="Song", artists=["Artist"])
assert TheAudioDbModule._select_track(meta, [first, correct]) is correct
@pytest.mark.asyncio
@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic])
@pytest.mark.parametrize("music_type", ["recording", "album"])
@pytest.mark.parametrize("async_mode", [False, True])
async def test_alternate_source_entrypoints_select_the_matching_version(source, music_type, async_mode):
"""同步异步入口都跳过首条普通版本,并从真实响应投影中确认后续现场版本。"""
meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live")
if music_type == "album":
meta.album = meta.title
if source == MediaSource.TheAudioDB:
module = TheAudioDbModule()
payload = {
"track": [{"idTrack": "wrong", "strTrack": "Example Work", "strArtist": "Artist"},
{"idTrack": "right", "strTrack": "Example Work (Live)", "strArtist": "Artist"}],
"album": [{"idAlbum": "wrong", "strAlbum": "Example Work", "strArtist": "Artist"},
{"idAlbum": "right", "strAlbum": "Example Work (Live)", "strArtist": "Artist"}],
}
module._request_json = Mock(return_value=payload)
module._async_request_json = AsyncMock(return_value=payload)
expected_id = "right"
else:
module = DoubanModule()
if music_type == "recording":
meta.album = "Sampler"
search = {"items": [{"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}]}]}
expected_id = "parent:2"
else:
search = {"items": [
{"id": "wrong", "title": "Example Work", "artists": [{"name": "Artist"}]},
{"id": "right", "title": "Example Work (Live)", "artists": [{"name": "Artist"}]},
]}
expected_id = "right"
detail = {"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}],
"songs": [{"title": "Example Work"}, {"title": "Example Work (Live)"}]}
module.doubanapi = Mock(
music_search=Mock(return_value=search), music_detail=Mock(return_value=detail),
async_music_search=AsyncMock(return_value=search), async_music_detail=AsyncMock(return_value=detail),
)
if async_mode:
result = await module.async_recognize_media(meta=meta, media_source=source, music_type=music_type)
else:
result = module.recognize_media(meta=meta, media_source=source, music_type=music_type)
assert result and result.media_id == expected_id
assert result.music_type == music_type
if source == MediaSource.TheAudioDB:
assert module._request_json.call_count == (0 if async_mode else 1)
assert module._async_request_json.await_count == (1 if async_mode else 0)
else:
assert module.doubanapi.music_search.call_count == (0 if async_mode else 1)
assert module.doubanapi.async_music_search.await_count == (1 if async_mode else 0)
assert module.doubanapi.music_detail.call_count == (1 if not async_mode and music_type == "recording" else 0)
assert module.doubanapi.async_music_detail.await_count == (1 if async_mode and music_type == "recording" else 0)
def test_theaudiodb_module_ignores_other_sources(monkeypatch): def test_theaudiodb_module_ignores_other_sources(monkeypatch):
"""显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。""" """显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。"""
module = TheAudioDbModule() module = TheAudioDbModule()
@@ -376,6 +528,29 @@ def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch):
assert result.album == "范特西" assert result.album == "范特西"
@pytest.mark.asyncio
@pytest.mark.parametrize("async_mode", [False, True])
async def test_douban_compilation_lookup_preserves_performer_evidence(async_mode):
"""先按合辑署名确认所属专辑,再按实际表演者确认曲目,不用合辑署名覆盖单曲。"""
module = DoubanModule()
search_result = {"items": [{"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}]}]}
detail = {"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}],
"songs": [{"title": "Song", "artist_names": ["Performer"]}]}
module.doubanapi = Mock(
music_search=Mock(return_value=search_result), music_detail=Mock(return_value=detail),
async_music_search=AsyncMock(return_value=search_result), async_music_detail=AsyncMock(return_value=detail),
)
meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists")
if async_mode:
result = await module.async_recognize_media(meta=meta, media_source=MediaSource.DoubanMusic,
music_type="recording")
else:
result = module.recognize_media(meta=meta, media_source=MediaSource.DoubanMusic, music_type="recording")
assert result and result.media_id == "1:1"
assert result.artists == ["Performer"]
assert meta.artists == ["Performer"]
def test_douban_music_mapping_keeps_legacy_attrs_tracks(): def test_douban_music_mapping_keeps_legacy_attrs_tracks():
"""豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。""" """豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。"""
album = DoubanModule._douban_music_to_album({ album = DoubanModule._douban_music_to_album({
+22
View File
@@ -531,3 +531,25 @@ def test_shared_recognition_replaces_entity_scoped_negative_cache(async_mode):
assert result is True assert result is True
assert cache.get(meta, music_type="recording").media_id == "rec-1" assert cache.get(meta, music_type="recording").media_id == "rec-1"
assert cache.get(meta).media_id == "rec-1" assert cache.get(meta).media_id == "rec-1"
@pytest.mark.parametrize("async_mode", [False, True])
@pytest.mark.parametrize("isrc_identity", [False, True])
def test_cached_recording_rechecks_explicit_version_conflicts(monkeypatch, async_mode, isrc_identity):
"""旧正缓存不能绕过版本冲突确认;已核验的同一 ISRC 则保留身份优先级。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
isrc = "USABC2600001" if isrc_identity else None
meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live 2001-05-02", isrc=isrc)
cached = _music_info(title="Example Work", artists=["Artist"], version="Live 2001-05-03", isrc=isrc)
fresh = _music_info(title="Example Work", artists=["Artist"], version=meta.version, media_id="fresh")
cache.update(meta, cached, music_type="recording")
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[fresh]))
monkeypatch.setattr(module, "_async_search_recordings", AsyncMock(return_value=[fresh]))
if async_mode:
result = asyncio.run(module.async_recognize_media(meta=meta, music_type="recording"))
assert module._async_search_recordings.await_count == (0 if isrc_identity else 1)
else:
result = module.recognize_media(meta=meta, music_type="recording")
assert module._search_recordings.call_count == (0 if isrc_identity else 1)
assert result.media_id == (cached.media_id if isrc_identity else fresh.media_id)