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.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search.facade import SearchChain
from app.domain.context import Context, MediaInfo, MusicInfo
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
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.schemas.category import ClassificationSelection
from app.schemas.common import JsonData
@@ -113,12 +114,6 @@ def _finalize_music_subscribe_recognition(
class MusicSubscribeMixin:
__mixin_host_protocol__ = MusicSubscribeMixinHost
subscription_repository: SubscriptionRepository
sync_subscription_mutation_scope: "SyncSubscriptionMutationScope"
_SubscribeChain__candidate_contract_changed: Callable[
[SubscriptionSnapshot, SubscriptionSnapshot], bool
]
"""
音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、
择优下载与完成推进。
@@ -130,6 +125,13 @@ class MusicSubscribeMixin:
SubscribeChain 主体形成双向模块依赖。
"""
__mixin_host_protocol__ = MusicSubscribeMixinHost
subscription_repository: SubscriptionRepository
sync_subscription_mutation_scope: "SyncSubscriptionMutationScope"
_SubscribeChain__candidate_contract_changed: Callable[
[SubscriptionSnapshot, SubscriptionSnapshot], bool
]
@staticmethod
def _validate_music_subscribe_target(
mediainfo: MediaInfo,
@@ -376,6 +378,7 @@ class MusicSubscribeMixin:
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
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()
matched: List[Context] = []
for source_context in contexts or []:
@@ -386,11 +389,16 @@ class MusicSubscribeMixin:
torrent = copy.copy(source_torrent)
if sites and torrent.site not in sites:
continue
if not SearchChain.matches_music_resource(
mediainfo,
torrent.title,
torrent.description,
):
meta = cast(MetaMusic, MetaInfo(
title=torrent.title,
subtitle=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
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
continue
@@ -406,7 +414,6 @@ class MusicSubscribeMixin:
context = copy.copy(source_context)
context.torrent_info = torrent
meta = MetaMusic.parse_resource(torrent.title, torrent.description)
if subscribe.best_version:
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
@@ -423,6 +430,8 @@ class MusicSubscribeMixin:
context.match_source = str(mediainfo.media_source or "title")
context.candidate_recognized = False
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,
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*[)\]]")
# 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz]
_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_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE)
# 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。
@@ -747,15 +753,24 @@ class MetaMusic(MetaBase):
meta.year = secondary.year
meta.apply_audio_quality(subtitle)
if not meta.version:
version = re.search(
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()
meta.version = cls._resource_version(title, subtitle)
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
def from_music_info(cls, info: Any) -> "MetaMusic":
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
@@ -1574,14 +1589,20 @@ class MetaMusic(MetaBase):
@property
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]:
"""转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。"""
return {
"type": self.type.value,
"org_string": self.org_string,
"apply_words": list(self.apply_words),
"title": self.title,
"artists": list(self.artists),
"artist": self.artist,
@@ -1613,7 +1634,7 @@ class MetaMusic(MetaBase):
raw_type = data.get("type")
if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"):
raise ValueError(f"不支持的音乐媒体类型:{raw_type}")
return cls(
meta = cls(
org_string=data.get("org_string"),
title=data.get("title"),
artists=_string_list(data.get("artists") or data.get("artist")),
@@ -1635,6 +1656,8 @@ class MetaMusic(MetaBase):
media_source=data.get("media_source"),
media_id=data.get("media_id"),
)
meta.apply_words = data.get("apply_words")
return meta
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.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.releasegroup import ReleaseGroupsMatcher
from app.domain.meta.runtime import (
@@ -502,17 +502,24 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器
:return: MetaAnime、MetaVideo、MetaMusic
"""
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制视频解析
# 媒体类型只选择解析器;音乐同样先应用用户识别词,影视附加音轨仍可强制视频解析
audio_suffix = Path(title).suffix.lower() if title else ""
if not force_video and mtype == MediaType.MUSIC:
return MetaMusic.parse_resource(title, subtitle)
if not force_video and mtype not in (MediaType.MOVIE, MediaType.TV) and audio_suffix in get_audio_extensions():
return MetaMusic(
org_string=title,
title=Path(title).stem,
audio_format=audio_suffix.lstrip(".").upper() or None,
parse_title=True,
is_audio_file = audio_suffix in get_audio_extensions()
if not force_video and (mtype == MediaType.MUSIC or (
mtype not in (MediaType.MOVIE, MediaType.TV) and is_audio_file
)):
parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
parsed_path = Path(parsed_title)
music_meta = MetaMusic.parse_resource(
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
accelerator = get_metainfo_accelerator()
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):
return MusicMatch("candidate", "category_unknown")
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")
if music.year and resource.year and str(music.year) != str(resource.year):
return MusicMatch("candidate", "year_mismatch")
+1
View File
@@ -13,6 +13,7 @@ class MusicMeta(OptionalMediaIdentityMixin, BaseModel):
type: Literal["音乐"] = "音乐"
org_string: Optional[str] = None
apply_words: list[str] = Field(default_factory=list, description="资源解析时实际应用的自定义识别词")
title: Optional[str] = None
artists: list[str] = Field(default_factory=list)
artist: Optional[str] = None
+5
View File
@@ -58,6 +58,11 @@
- `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析,
补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。
- 音乐资源经 `MetaInfo` 工厂应用与影视相同的用户识别词,实际应用记录保存在
`apply_words` 中;RSS 重新匹配也使用订阅识别词,不修改共享资源缓存。
音频文件的真实后缀仍优先于标题中的格式声明。
- 副标题的明确版本字段或独立版本标签补充主标题缺少的录音版本;已有标题版本
不被副标题覆盖。单曲的所属专辑字段只证明归属,即使没有曲序也不代表整专资源。
- 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用
兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。
- 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、
+1 -1
View File
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 |
| 长方法 | 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` |
| 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
+3
View File
@@ -329,6 +329,9 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自
标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases``album_aliases`
`artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。
音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录,
旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段
不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。
音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`
额外返回待确认资源及关联专辑:`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
`music.cache.get` before deleting one exact key, and clear all entries only
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
+3 -3
View File
@@ -1090,7 +1090,7 @@
}
},
"edge_count": 8259,
"edge_sha256": "f5f1d4cc3a4a6a659d1b3955995b605c8cc85be099cba76488ac3410935547cf",
"edge_sha256": "a380bbd37ee23f66e7195b0c143b4d5b2a25b17254c18946789b45174ec2eb92",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -3849,13 +3849,13 @@
"app.chain._music -> app.chain._contracts",
"app.chain._music -> app.chain.download",
"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.context",
"app.chain._music -> app.domain.media",
"app.chain._music -> app.domain.meta",
"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.log",
"app.chain._music -> app.schemas",
+1 -1
View File
@@ -1288,7 +1288,7 @@
"arg-type": 4,
"assignment": 7,
"list-item": 1,
"override": 4
"override": 3
},
"app/domain/meta/metavideo.py": {
"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].meta_info.media_id is None
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.meta.metamusic import MetaMusic
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.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"])
@@ -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"
@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():
"""另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。"""
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", "專輯藝人:周華健")
assert bracketed.title and "永遠" in bracketed.title
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])
+37
View File
@@ -508,6 +508,43 @@ def test_music_subscribe_matches_artist_from_resource_description():
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():
"""最高优先级的非完整专辑不得写入洗版基线或完成订阅。"""
subscribe = _subscribe(