mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix: 对齐音乐识别词并补齐版本和专辑匹配证据
This commit is contained in:
+22
-13
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
|
||||
- `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析,
|
||||
补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。
|
||||
- 音乐资源经 `MetaInfo` 工厂应用与影视相同的用户识别词,实际应用记录保存在
|
||||
`apply_words` 中;RSS 重新匹配也使用订阅识别词,不修改共享资源缓存。
|
||||
音频文件的真实后缀仍优先于标题中的格式声明。
|
||||
- 副标题的明确版本字段或独立版本标签补充主标题缺少的录音版本;已有标题版本
|
||||
不被副标题覆盖。单曲的所属专辑字段只证明归属,即使没有曲序也不代表整专资源。
|
||||
- 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用
|
||||
兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。
|
||||
- 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、
|
||||
|
||||
@@ -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 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -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` 描述原因,
|
||||
|
||||
@@ -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
@@ -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
@@ -1288,7 +1288,7 @@
|
||||
"arg-type": 4,
|
||||
"assignment": 7,
|
||||
"list-item": 1,
|
||||
"override": 4
|
||||
"override": 3
|
||||
},
|
||||
"app/domain/meta/metavideo.py": {
|
||||
"arg-type": 2,
|
||||
|
||||
@@ -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 == ["错误曲名 => 晴天"]
|
||||
|
||||
@@ -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("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健")
|
||||
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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user