fix: 对齐音乐识别词并补齐版本和专辑匹配证据

This commit is contained in:
jxxghp
2026-09-06 01:28:47 +08:00
parent fba0b6ae67
commit 916619f2e2
14 changed files with 280 additions and 39 deletions
+22 -13
View File
@@ -20,10 +20,11 @@ from app.application.torrent.download import TorrentHelper
from app.chain._contracts import MusicSubscribeMixinHost from app.chain._contracts import MusicSubscribeMixinHost
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.search.facade import SearchChain
from app.domain.context import Context, MediaInfo, MusicInfo from app.domain.context import Context, MediaInfo, MusicInfo
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.domain.music import match_music_resource
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas.category import ClassificationSelection from app.schemas.category import ClassificationSelection
from app.schemas.common import JsonData from app.schemas.common import JsonData
@@ -113,12 +114,6 @@ def _finalize_music_subscribe_recognition(
class MusicSubscribeMixin: class MusicSubscribeMixin:
__mixin_host_protocol__ = MusicSubscribeMixinHost
subscription_repository: SubscriptionRepository
sync_subscription_mutation_scope: "SyncSubscriptionMutationScope"
_SubscribeChain__candidate_contract_changed: Callable[
[SubscriptionSnapshot, SubscriptionSnapshot], bool
]
""" """
音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、 音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、
择优下载与完成推进。 择优下载与完成推进。
@@ -130,6 +125,13 @@ class MusicSubscribeMixin:
SubscribeChain 主体形成双向模块依赖。 SubscribeChain 主体形成双向模块依赖。
""" """
__mixin_host_protocol__ = MusicSubscribeMixinHost
subscription_repository: SubscriptionRepository
sync_subscription_mutation_scope: "SyncSubscriptionMutationScope"
_SubscribeChain__candidate_contract_changed: Callable[
[SubscriptionSnapshot, SubscriptionSnapshot], bool
]
@staticmethod @staticmethod
def _validate_music_subscribe_target( def _validate_music_subscribe_target(
mediainfo: MediaInfo, mediainfo: MediaInfo,
@@ -376,6 +378,7 @@ class MusicSubscribeMixin:
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or [] rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or []
custom_words = subscribe.custom_words.split("\n") if subscribe.custom_words else []
torrent_helper = TorrentHelper() torrent_helper = TorrentHelper()
matched: List[Context] = [] matched: List[Context] = []
for source_context in contexts or []: for source_context in contexts or []:
@@ -386,11 +389,16 @@ class MusicSubscribeMixin:
torrent = copy.copy(source_torrent) torrent = copy.copy(source_torrent)
if sites and torrent.site not in sites: if sites and torrent.site not in sites:
continue continue
if not SearchChain.matches_music_resource( meta = cast(MetaMusic, MetaInfo(
mediainfo, title=torrent.title,
torrent.title, subtitle=torrent.description,
torrent.description, custom_words=custom_words,
): mtype=MediaType.MUSIC,
))
match = match_music_resource(
mediainfo, torrent.title, torrent.description, torrent.category, meta=meta,
)
if match.status != "exact":
continue continue
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)): if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
continue continue
@@ -406,7 +414,6 @@ class MusicSubscribeMixin:
context = copy.copy(source_context) context = copy.copy(source_context)
context.torrent_info = torrent context.torrent_info = torrent
meta = MetaMusic.parse_resource(torrent.title, torrent.description)
if subscribe.best_version: if subscribe.best_version:
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则 # 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。 # 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
@@ -423,6 +430,8 @@ class MusicSubscribeMixin:
context.match_source = str(mediainfo.media_source or "title") context.match_source = str(mediainfo.media_source or "title")
context.candidate_recognized = False context.candidate_recognized = False
context.media_info_is_target = True context.media_info_is_target = True
context.match_status = "exact"
context.match_reason = match.reason
context.media_info = _apply_music_subscription_classification( context.media_info = _apply_music_subscription_classification(
context.media_info, context.media_info,
subscribe, subscribe,
+33 -10
View File
@@ -354,6 +354,12 @@ _MUSIC_DISC_DIR_RE = re.compile(
_MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P<year>(?:19|20)\d{2})\s*[)\]]") _MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P<year>(?:19|20)\d{2})\s*[)\]]")
# 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz] # 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz]
_MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)") _MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)")
_MUSIC_RECORDING_VERSION_RE = re.compile(
r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|"
r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*)[\]))】]",
re.IGNORECASE,
)
_MUSIC_VERSION_LABEL_RE = re.compile(r"^(?:录音版本|錄音版本|版本|version)\s*[:]\s*(.+)$", re.IGNORECASE)
_MUSIC_SPACES_RE = re.compile(r"\s+") _MUSIC_SPACES_RE = re.compile(r"\s+")
_MUSIC_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE) _MUSIC_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE)
# 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。 # 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。
@@ -747,15 +753,24 @@ class MetaMusic(MetaBase):
meta.year = secondary.year meta.year = secondary.year
meta.apply_audio_quality(subtitle) meta.apply_audio_quality(subtitle)
if not meta.version: if not meta.version:
version = re.search( meta.version = cls._resource_version(title, subtitle)
r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|"
r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*)[\]))】]",
title, re.I,
)
if version:
meta.version = version.group(1).strip()
return meta return meta
@staticmethod
def _resource_version(title: str, subtitle: Optional[str]) -> Optional[str]:
"""优先保留标题版本,副标题仅接受明确版本字段或独立版本标签,避免误读艺名。"""
version = _MUSIC_RECORDING_VERSION_RE.search(title)
if version:
return version.group(1).strip()
for field in re.split(r"[;\n]", subtitle or ""):
labelled = _MUSIC_VERSION_LABEL_RE.fullmatch(field.strip())
if labelled:
return labelled.group(1).strip().strip("[]()()【】").strip() or None
version = _MUSIC_RECORDING_VERSION_RE.fullmatch(field.strip())
if version:
return version.group(1).strip()
return None
@classmethod @classmethod
def from_music_info(cls, info: Any) -> "MetaMusic": def from_music_info(cls, info: Any) -> "MetaMusic":
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。""" """把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
@@ -1574,14 +1589,20 @@ class MetaMusic(MetaBase):
@property @property
def apply_words(self) -> list[str]: def apply_words(self) -> list[str]:
"""音乐当前不应用影视自定义识别词""" """返回公共元数据工厂实际应用的识别词,兼容缺少该字段的旧缓存"""
return [] return getattr(self, "_apply_words", [])
@apply_words.setter
def apply_words(self, value: Optional[list[str]]) -> None:
"""独立保存识别词记录,避免调用方或其它音乐元数据共用可变列表。"""
self._apply_words = list(value or [])
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。""" """转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。"""
return { return {
"type": self.type.value, "type": self.type.value,
"org_string": self.org_string, "org_string": self.org_string,
"apply_words": list(self.apply_words),
"title": self.title, "title": self.title,
"artists": list(self.artists), "artists": list(self.artists),
"artist": self.artist, "artist": self.artist,
@@ -1613,7 +1634,7 @@ class MetaMusic(MetaBase):
raw_type = data.get("type") raw_type = data.get("type")
if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"): if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"):
raise ValueError(f"不支持的音乐媒体类型:{raw_type}") raise ValueError(f"不支持的音乐媒体类型:{raw_type}")
return cls( meta = cls(
org_string=data.get("org_string"), org_string=data.get("org_string"),
title=data.get("title"), title=data.get("title"),
artists=_string_list(data.get("artists") or data.get("artist")), artists=_string_list(data.get("artists") or data.get("artist")),
@@ -1635,6 +1656,8 @@ class MetaMusic(MetaBase):
media_source=data.get("media_source"), media_source=data.get("media_source"),
media_id=data.get("media_id"), media_id=data.get("media_id"),
) )
meta.apply_words = data.get("apply_words")
return meta
def _build_name_result( def _build_name_result(
+17 -10
View File
@@ -14,7 +14,7 @@ from app.domain.meta.infopath import (
) )
from app.domain.meta.metaanime import MetaAnime from app.domain.meta.metaanime import MetaAnime
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, infer_audio_lossless, normalize_audio_format
from app.domain.meta.metavideo import MetaVideo from app.domain.meta.metavideo import MetaVideo
from app.domain.meta.releasegroup import ReleaseGroupsMatcher from app.domain.meta.releasegroup import ReleaseGroupsMatcher
from app.domain.meta.runtime import ( from app.domain.meta.runtime import (
@@ -502,17 +502,24 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器 :param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器
:return: MetaAnime、MetaVideo、MetaMusic :return: MetaAnime、MetaVideo、MetaMusic
""" """
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制视频解析 # 媒体类型只选择解析器;音乐同样先应用用户识别词,影视附加音轨仍可强制视频解析
audio_suffix = Path(title).suffix.lower() if title else "" audio_suffix = Path(title).suffix.lower() if title else ""
if not force_video and mtype == MediaType.MUSIC: is_audio_file = audio_suffix in get_audio_extensions()
return MetaMusic.parse_resource(title, subtitle) if not force_video and (mtype == MediaType.MUSIC or (
if not force_video and mtype not in (MediaType.MOVIE, MediaType.TV) and audio_suffix in get_audio_extensions(): mtype not in (MediaType.MOVIE, MediaType.TV) and is_audio_file
return MetaMusic( )):
org_string=title, parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
title=Path(title).stem, parsed_path = Path(parsed_title)
audio_format=audio_suffix.lstrip(".").upper() or None, music_meta = MetaMusic.parse_resource(
parse_title=True, parsed_path.stem if is_audio_file and parsed_path.suffix.lower() in get_audio_extensions() else parsed_title,
subtitle,
) )
music_meta.org_string = parsed_title
music_meta.apply_words = apply_words
if is_audio_file:
music_meta.audio_format = normalize_audio_format(audio_suffix.lstrip("."))
music_meta.audio_lossless = infer_audio_lossless(music_meta.audio_format)
return music_meta
rust_meta = None rust_meta = None
accelerator = get_metainfo_accelerator() accelerator = get_metainfo_accelerator()
if accelerator and not _requires_python_metainfo(title, custom_words): if accelerator and not _requires_python_metainfo(title, custom_words):
+5 -1
View File
@@ -174,7 +174,11 @@ def match_music_resource(
if category not in (MediaType.MUSIC, MediaType.MUSIC.value): if category not in (MediaType.MUSIC, MediaType.MUSIC.value):
return MusicMatch("candidate", "category_unknown") return MusicMatch("candidate", "category_unknown")
if music.music_type == MUSIC_ENTITY_ALBUM: if music.music_type == MUSIC_ENTITY_ALBUM:
if resource.track_number and resource.album: # 所属专辑只说明单曲归属,不能代替资源主标题证明整专范围。
primary_names = _resource_names(resource, artists)
if resource.track_number or (resource.album and not any(
music_text_key(music_base_title(item)) in primary_names for item in titles
)):
return MusicMatch("candidate", "partial_album") return MusicMatch("candidate", "partial_album")
if music.year and resource.year and str(music.year) != str(resource.year): if music.year and resource.year and str(music.year) != str(resource.year):
return MusicMatch("candidate", "year_mismatch") return MusicMatch("candidate", "year_mismatch")
+1
View File
@@ -13,6 +13,7 @@ class MusicMeta(OptionalMediaIdentityMixin, BaseModel):
type: Literal["音乐"] = "音乐" type: Literal["音乐"] = "音乐"
org_string: Optional[str] = None org_string: Optional[str] = None
apply_words: list[str] = Field(default_factory=list, description="资源解析时实际应用的自定义识别词")
title: Optional[str] = None title: Optional[str] = None
artists: list[str] = Field(default_factory=list) artists: list[str] = Field(default_factory=list)
artist: Optional[str] = None artist: Optional[str] = None
+5
View File
@@ -58,6 +58,11 @@
- `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析, - `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析,
补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。 补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。
- 音乐资源经 `MetaInfo` 工厂应用与影视相同的用户识别词,实际应用记录保存在
`apply_words` 中;RSS 重新匹配也使用订阅识别词,不修改共享资源缓存。
音频文件的真实后缀仍优先于标题中的格式声明。
- 副标题的明确版本字段或独立版本标签补充主标题缺少的录音版本;已有标题版本
不被副标题覆盖。单曲的所属专辑字段只证明归属,即使没有曲序也不代表整专资源。
- 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用 - 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用
兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。 兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。
- 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、 - 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、
+1 -1
View File
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 |
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
| 全量 mypy 历史债务 | 9,502 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | 全量 mypy 历史债务 | 9,501 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
| Ruff 历史诊断 | 542 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | Ruff 历史诊断 | 542 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | | 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
+3
View File
@@ -329,6 +329,9 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自 音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自
标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases``album_aliases` 标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases``album_aliases`
`artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。 `artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。
音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录,
旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段
不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。
音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true` 音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`
额外返回待确认资源及关联专辑:`match_status=candidate``match_reason` 描述原因, 额外返回待确认资源及关联专辑:`match_status=candidate``match_reason` 描述原因,
+4
View File
@@ -163,6 +163,10 @@ Call the gateway with this shape:
browse-only. Music recognition-cache operations are administrator-only; call browse-only. Music recognition-cache operations are administrator-only; call
`music.cache.get` before deleting one exact key, and clear all entries only `music.cache.get` before deleting one exact key, and clear all entries only
after explicit confirmation. after explicit confirmation.
- Music resource metadata records applied recognition rules in `apply_words`.
Explicit subtitle versions participate in matching. A track's `album` field
does not prove whole-album coverage, even without a track number; keep
`partial_album` candidates out of automatic downloads.
## Operation Catalog ## Operation Catalog
+3 -3
View File
@@ -1090,7 +1090,7 @@
} }
}, },
"edge_count": 8259, "edge_count": 8259,
"edge_sha256": "f5f1d4cc3a4a6a659d1b3955995b605c8cc85be099cba76488ac3410935547cf", "edge_sha256": "a380bbd37ee23f66e7195b0c143b4d5b2a25b17254c18946789b45174ec2eb92",
"edges": [ "edges": [
"app -> app.foundation", "app -> app.foundation",
"app -> app.foundation.environment", "app -> app.foundation.environment",
@@ -3849,13 +3849,13 @@
"app.chain._music -> app.chain._contracts", "app.chain._music -> app.chain._contracts",
"app.chain._music -> app.chain.download", "app.chain._music -> app.chain.download",
"app.chain._music -> app.chain.media", "app.chain._music -> app.chain.media",
"app.chain._music -> app.chain.search",
"app.chain._music -> app.chain.search.facade",
"app.chain._music -> app.domain", "app.chain._music -> app.domain",
"app.chain._music -> app.domain.context", "app.chain._music -> app.domain.context",
"app.chain._music -> app.domain.media", "app.chain._music -> app.domain.media",
"app.chain._music -> app.domain.meta", "app.chain._music -> app.domain.meta",
"app.chain._music -> app.domain.meta.metamusic", "app.chain._music -> app.domain.meta.metamusic",
"app.chain._music -> app.domain.metainfo",
"app.chain._music -> app.domain.music",
"app.chain._music -> app.runtime", "app.chain._music -> app.runtime",
"app.chain._music -> app.runtime.log", "app.chain._music -> app.runtime.log",
"app.chain._music -> app.schemas", "app.chain._music -> app.schemas",
+1 -1
View File
@@ -1288,7 +1288,7 @@
"arg-type": 4, "arg-type": 4,
"assignment": 7, "assignment": 7,
"list-item": 1, "list-item": 1,
"override": 4 "override": 3
}, },
"app/domain/meta/metavideo.py": { "app/domain/meta/metavideo.py": {
"arg-type": 2, "arg-type": 2,
+68
View File
@@ -83,3 +83,71 @@ def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtyp
assert contexts[0].media_info.media_id == target.media_id assert contexts[0].media_info.media_id == target.media_id
assert contexts[0].meta_info.media_id is None assert contexts[0].meta_info.media_id is None
assert isinstance(contexts[0].meta_info, MetaMusic) == (mtype == MediaType.MUSIC) assert isinstance(contexts[0].meta_info, MetaMusic) == (mtype == MediaType.MUSIC)
@pytest.mark.parametrize("mode", ["sync", "async", "stream"])
@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words"])
def test_music_resource_evidence_controls_shared_search_stop(monkeypatch, mode, case):
"""三种搜索入口均在完成识别词、版本与专辑范围验证后才允许停止换词。"""
chain = SearchChain()
chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False)
album_target = case == "partial_album"
target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="target",
music_type="album" if album_target else "recording",
title="叶惠美" if album_target else "晴天", artists=["周杰伦"])
calls = []
def search(**kwargs):
"""先返回待核验或需改名的资源,再提供正确身份的资源。"""
calls.append(kwargs["keyword"])
if len(calls) > 1:
return [TorrentInfo(title=f"周杰伦 - {target.title} FLAC", category=MediaType.MUSIC.value)]
return [TorrentInfo(
title="周杰伦 - 错误曲名 FLAC" if case == "recognition_words" else "周杰伦 - 晴天 FLAC",
description="专辑:叶惠美" if album_target else "版本:Live" if case == "subtitle_version" else None,
category=MediaType.MUSIC.value,
)]
async def async_search(**kwargs):
"""用相同响应驱动异步站点入口。"""
return search(**kwargs)
async def stream(**kwargs):
"""用相同响应驱动站点流式入口。"""
yield {"items": search(**kwargs), "value": 100}
async def supplement(mediainfo):
"""目标已完整,不访问外部来源。"""
return mediainfo
async def sleep(_delay):
"""跳过关键词间的测试退避。"""
monkeypatch.setattr(media, "MediaChain", lambda: SimpleNamespace(
supplement_media_info=lambda mediainfo: mediainfo, async_supplement_media_info=supplement,
))
monkeypatch.setattr(execution.time, "sleep", lambda _delay: None)
monkeypatch.setattr(execution.asyncio, "sleep", sleep)
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
chain._SearchChain__search_all_sites = search
chain._SearchChain__async_search_all_sites = async_search
chain._SearchChain__async_search_all_sites_stream = stream
params = dict(mediainfo=target, rule_groups=[], include_candidates=True,
custom_words=["错误曲名 => 晴天"] if case == "recognition_words" else None)
if mode == "sync":
contexts = chain.process(**params)
elif mode == "async":
contexts = asyncio.run(chain.async_process(**params))
else:
async def collect():
"""从完成事件读取权威结果,避免把流式预览当作精确命中。"""
return [event async for event in chain.async_process_stream(**params)]
contexts = asyncio.run(collect())[-1]["contexts"]
assert calls == (["first"] if case == "recognition_words" else ["first", "second"])
exact = [context for context in contexts if context.match_status == "exact"]
assert len(exact) == 1
assert exact[0].meta_info.title == target.title
assert all(context.media_info is None for context in contexts if context.match_status == "candidate")
if case == "recognition_words":
assert exact[0].meta_info.apply_words == ["错误曲名 => 晴天"]
+80
View File
@@ -11,7 +11,10 @@ from app.chain.search import SearchChain
from app.domain.context import Context, MusicAlbumInfo, MusicInfo 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
from app.domain.music import match_music_resource from app.domain.music import match_music_resource
from app.schemas.music import MusicMeta
from app.schemas.types import MediaType
@pytest.mark.parametrize("title", ["U2 - One Tree Hill FLAC", "U2 - Someone FLAC", "U2 - One - Tree Hill FLAC"]) @pytest.mark.parametrize("title", ["U2 - One Tree Hill FLAC", "U2 - Someone FLAC", "U2 - One - Tree Hill FLAC"])
@@ -92,6 +95,77 @@ def test_music_album_field_cannot_match_target_recording():
assert match_music_resource(wanted, "Artist - Album - 01 - Other Song FLAC").status == "rejected" assert match_music_resource(wanted, "Artist - Album - 01 - Other Song FLAC").status == "rejected"
@pytest.mark.parametrize("title", [
"周杰伦 - 晴天 FLAC",
"周杰伦 - 03 - 晴天 FLAC",
"周杰伦 - 03 - 叶惠美 FLAC",
])
def test_album_match_cannot_promote_track_from_subtitle(title):
"""即使没有曲序,单曲所属专辑字段也不能证明资源是整张专辑。"""
album = MusicInfo(music_type="album", title="叶惠美", artists=["周杰伦"])
match = match_music_resource(album, title, "专辑:叶惠美")
assert (match.status, match.reason) == ("candidate", "partial_album")
@pytest.mark.parametrize("title", ["周杰伦 - 叶惠美 FLAC", "周杰伦《叶惠美》FLAC", "Jay Chou - Ye Hui Mei FLAC"])
def test_album_match_keeps_primary_album_names_and_aliases(title):
"""整专主标题及可信别名仍可精确命中,不因副标题补充专辑字段而降级。"""
album = MusicInfo(music_type="album", title="叶惠美", title_aliases=["Ye Hui Mei"],
artists=["周杰伦"], artist_aliases=["Jay Chou"])
assert match_music_resource(album, title, "专辑:叶惠美").status == "exact"
@pytest.mark.parametrize("subtitle", ["版本:Live", "錄音版本:Live", "Version: Live", "[Live]", "演唱:周杰伦;(Live)"])
def test_resource_subtitle_version_is_matching_evidence(subtitle):
"""明确的副标题版本只可匹配同一版本,不能自动绑定为普通录音。"""
title = "周杰伦 - 晴天 FLAC"
meta = MetaMusic.parse_resource(title, subtitle)
assert meta.version == "Live"
ordinary = MusicInfo(title="晴天", artists=["周杰伦"])
assert match_music_resource(ordinary, title, subtitle).reason == "version_mismatch"
live = MusicInfo(title="晴天 (Live)", artists=["周杰伦"])
assert match_music_resource(live, title, subtitle).status == "exact"
def test_resource_title_version_has_priority_over_subtitle():
"""标题版本已有明确证据时,冲突副标题不能覆盖;艺人字段也不是版本声明。"""
assert MetaMusic.parse_resource("U2 - One (Live) FLAC", "版本:Remix").version == "Live"
assert MetaMusic.parse_resource("Song FLAC", "艺术家:Live").version is None
@pytest.mark.parametrize("mtype,suffix", [(MediaType.MUSIC, " FLAC"), (None, ".flac")])
@pytest.mark.parametrize("global_words", [False, True])
def test_music_metainfo_applies_shared_recognition_words(monkeypatch, mtype, suffix, global_words):
"""音乐名称复用影视识别词语法,音频后缀与显式类型入口行为一致。"""
words = ["错误曲名 => 晴天"]
if global_words:
monkeypatch.setattr("app.domain.meta.words.get_custom_words", lambda: words)
meta = MetaInfo(f"周杰伦 - 错误曲名{suffix}", "版本:Live", mtype=mtype,
custom_words=None if global_words else words)
assert (meta.title, meta.artists, meta.version) == ("晴天", ["周杰伦"], "Live")
assert meta.audio_format == "FLAC"
assert meta.apply_words == words
assert MetaMusic.from_dict(meta.to_dict()).apply_words == words
assert MusicMeta.model_validate(meta.to_dict()).apply_words == words
def test_legacy_music_metainfo_keeps_empty_recognition_words():
"""旧音乐缓存缺少识别词记录时仍能恢复,且实例之间不共享可变列表。"""
old = MetaMusic.from_dict({"title": "晴天"})
assert old.apply_words == []
changed = MetaMusic(title="晴天")
changed.apply_words = ["错误曲名 => 晴天"]
assert old.apply_words == []
@pytest.mark.parametrize("suffix", ["mp2", "tta", "flac"])
def test_music_recognition_words_keep_original_file_format(suffix):
"""识别词可能删掉文件后缀,但不能丢失或覆盖文件原本声明的音频格式。"""
meta = MetaInfo(f"周杰伦 - 错误曲名.{suffix}", custom_words=[f"错误曲名\\.{suffix} => 晴天"])
assert meta.title == "晴天"
assert meta.audio_format == suffix.upper()
def test_music_artist_in_subtitle_cannot_override_conflicting_title_credit(): def test_music_artist_in_subtitle_cannot_override_conflicting_title_credit():
"""另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。""" """另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。"""
music = MusicInfo(title="One", artists=["U2"]) music = MusicInfo(title="One", artists=["U2"])
@@ -146,6 +220,12 @@ def test_resource_evidence_matches_python_and_native_parser_paths(monkeypatch, p
bracketed = MetaMusic.parse_resource("【永遠・是朋友】24bit96kHz", "專輯藝人:周華健") bracketed = MetaMusic.parse_resource("【永遠・是朋友】24bit96kHz", "專輯藝人:周華健")
assert bracketed.title and "永遠" in bracketed.title assert bracketed.title and "永遠" in bracketed.title
assert bracketed.artists == ["周華健"] assert bracketed.artists == ["周華健"]
live = MetaInfo("周杰伦 - 错误曲名.flac", "版本:Live", mtype=MediaType.MUSIC,
custom_words=["错误曲名 => 晴天"])
assert (live.title, live.version, live.audio_format) == ("晴天", "Live", "FLAC")
assert live.apply_words == ["错误曲名 => 晴天"]
album = MusicInfo(music_type="album", title="叶惠美", artists=["周杰伦"])
assert match_music_resource(album, "周杰伦 - 晴天 FLAC", "专辑:叶惠美").reason == "partial_album"
@pytest.mark.parametrize("factory", [MusicInfo, MusicAlbumInfo]) @pytest.mark.parametrize("factory", [MusicInfo, MusicAlbumInfo])
+37
View File
@@ -508,6 +508,43 @@ def test_music_subscribe_matches_artist_from_resource_description():
assert len(matched) == 1 assert len(matched) == 1
def test_music_rss_applies_subscription_words_without_mutating_cache():
"""RSS 与主动搜索使用同一订阅识别词,并保持缓存中的资源原文独立。"""
subscribe = _subscribe(custom_words="错误曲名 => 晴天")
torrent = TorrentInfo(title="周杰伦 - 错误曲名 FLAC", category=MediaType.MUSIC.value)
original_meta = MetaMusic.parse_resource(torrent.title)
context = Context(torrent_info=torrent, meta_info=original_meta)
chain = SubscribeChain()
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
matched = chain._filter_music_subscribe_contexts(subscribe, _music_info(), [context])
assert len(matched) == 1
assert matched[0].meta_info.title == "晴天"
assert matched[0].meta_info.apply_words == ["错误曲名 => 晴天"]
assert matched[0].meta_info is not original_meta
assert context.meta_info.title == "错误曲名"
assert context.torrent_info.title == "周杰伦 - 错误曲名 FLAC"
@pytest.mark.parametrize("album_target", [False, True])
def test_music_rss_rejects_subtitle_version_or_partial_album(album_target):
"""RSS 不能把副标题现场版或单曲所属专辑当作可自动下载的目标。"""
target = _music_info()
if album_target:
target.music_type = MUSIC_ENTITY_ALBUM
target.title = target.album
context = Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 FLAC",
description="专辑:叶惠美" if album_target else "版本:Live",
category=MediaType.MUSIC.value,
))
chain = SubscribeChain()
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
assert chain._filter_music_subscribe_contexts(_subscribe(), target, [context]) == []
def test_album_best_version_requires_confirmed_full_coverage(): def test_album_best_version_requires_confirmed_full_coverage():
"""最高优先级的非完整专辑不得写入洗版基线或完成订阅。""" """最高优先级的非完整专辑不得写入洗版基线或完成订阅。"""
subscribe = _subscribe( subscribe = _subscribe(