diff --git a/app/domain/music.py b/app/domain/music.py index 488a68fb0..f8626d2ff 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -7,8 +7,9 @@ from copy import deepcopy from dataclasses import dataclass from datetime import date from fnmatch import fnmatchcase +from functools import partial from pathlib import Path -from typing import Any, Generator, Iterable, Literal, Optional +from typing import Any, Callable, Generator, Iterable, Literal, Optional from unicodedata import combining, normalize from app.domain.context import Context, MusicAlbumInfo, MusicInfo @@ -725,6 +726,27 @@ def music_text_key(value: Optional[str]) -> str: return str(zhconv_convert("".join(char for char in text if char.isalnum() and not combining(char)), "zh-hans")) +def _music_name_key( + value: Optional[str], names: set[str], key: Callable[[Optional[str]], str], +) -> str: + """ + 只有括号两侧均为同一实体的可信名称时,才把双语排版归并为其中一个名称。 + + :param value: 待核验的完整名称,不剥离未知括号说明 + :param names: 同一作品或艺术家已确认的名称比较键 + :param key: 对应名称类型的归一化方法,作品名可保留发行版约束 + :return: 已确认的名称比较键;缺少任一侧证据时保留完整名称的比较键 + """ + text = normalize("NFKC", str(value or "")).strip() + original = key(text) + if original in names: + return original + bilingual = re.fullmatch(r"([^()]+?)\s*\(([^()]+)\)", text) + if bilingual and all(key(part) in names for part in bilingual.groups()): + return key(bilingual.group(1)) + return original + + def music_titles(music: MusicInfo, *, album: bool = False) -> list[str]: """ 返回同一作品的可信名称,单曲绝不消费兼容 names 中的专辑名。 @@ -755,14 +777,15 @@ def music_artists(music: MusicInfo) -> list[str]: def music_artist_matches(music: MusicInfo, parsed_artists: Iterable[str]) -> bool: """ - 使用同实体署名和别名核验解析艺人,兼容完整艺名被分隔符拆成多个片段。 + 使用同实体署名和别名核验艺人,兼容已确认的双语排版和被分隔的完整艺名。 """ artists = music_artists(music) parsed = unique_music_texts(parsed_artists) - keys = {music_text_key(artist) for artist in parsed} + artist_keys = {music_text_key(artist) for artist in artists} + keys = {_music_name_key(artist, artist_keys, music_text_key) for artist in parsed} if len(parsed) > 1 and any(any(separator in artist for separator in ("/", "&", ",")) for artist in artists): keys.add(music_text_key(" / ".join(parsed))) - return bool(keys & {music_text_key(artist) for artist in artists}) + return bool(keys & artist_keys) def music_credit_conflicts(music: MusicInfo, meta: MetaMusic) -> bool: @@ -894,13 +917,12 @@ def _music_title_key(value: Optional[str], *, preserve_editions: bool = False) - def music_title_matches(music: MusicInfo, title: Optional[str], *, preserve_editions: bool = False) -> bool: """ - 统一全半角后比较完整名称及别名,纯符号名称也保留其身份。 + 比较完整名称及可信双语别名,保留纯符号名称、未知括号和发行版的身份约束。 """ - expected = _music_title_key(title, preserve_editions=preserve_editions) - return bool(expected and any( - expected == _music_title_key(name, preserve_editions=preserve_editions) - for name in music_titles(music) - )) + key = partial(_music_title_key, preserve_editions=preserve_editions) + names = {key(name) for name in music_titles(music)} + expected = _music_name_key(title, names, key) + return bool(expected and expected in names) def music_album_matches(music: MusicInfo, album: Optional[str]) -> bool: @@ -1026,12 +1048,21 @@ def _contains_artist(text: str, artist: str) -> bool: def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = False, - album_suffixes: Optional[list[str]] = None) -> list[str]: + album_suffixes: Optional[list[str]] = None, + titles: Optional[list[str]] = None) -> list[str]: """ - 复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。 + 提取作品片段,核验双语名称并去掉已确认的首尾艺术家署名。 + + :param primary: 资源自身解析出的作品与专辑证据 + :param artists: 目标艺术家的正式署名与已确认别名 + :param album: 是否额外比较资源的所属专辑名 + :param album_suffixes: 单曲尾段可剥离的已确认所属专辑名 + :param titles: 同一作品的可信名称,仅用于核验双语排版 + :return: 可用于完整名称比较的作品片段,不生成未知别名 """ names: list[str] = [] artist_keys = [(item, music_text_key(item)) for item in artists if item] + title_keys = {_music_title_key(title) for title in titles or []} suffix_keys = {music_text_key(item) for item in album_suffixes or []} for value in (primary.title, primary.album if album else None): if not value: @@ -1043,7 +1074,7 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal variants.append(parts[0]) for part in variants: name = music_base_title(_TITLE_LABEL.sub("", part.strip())) - key = _music_title_key(name) + key = _music_name_key(name, title_keys, _music_title_key) if not key: continue names.append(key) @@ -1140,16 +1171,18 @@ def match_music_resource( return MusicMatch("rejected", "performance_mismatch") artists = music_artists(music) albums = music_titles(music, album=True) - names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM, - album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None) titles = music_titles(music) + names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM, + album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None, + titles=titles) title_matched = any(music_title_matches(music, name) for name in names) content = f"{title} {description}" artist_matched = music_artist_evidence_matches(music, resource) if resource.artists or any(music_credit_values(resource).values()) \ else any(_contains_artist(content, artist) for artist in artists) if not title_matched: + album_names = _resource_names(resource, artists, titles=albums) if music.music_type != MUSIC_ENTITY_ALBUM and artist_matched and any( - _music_title_key(item) in names + _music_title_key(item) in album_names for item in music_titles(music, album=True) ): return MusicMatch("album", "related_album") @@ -1162,7 +1195,7 @@ def match_music_resource( return MusicMatch("candidate", "category_unknown") if music.music_type == MUSIC_ENTITY_ALBUM: # 所属专辑只说明单曲归属,不能代替资源主标题证明整专范围。 - primary_names = _resource_names(resource, artists) + primary_names = _resource_names(resource, artists, titles=titles) if resource.track_number or (resource.album and not any( _music_title_key(item) in primary_names for item in titles )): diff --git a/docs/testing.md b/docs/testing.md index 3c060f726..41efb7786 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -141,6 +141,10 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)): 音乐缺分类时使用“音乐”、已有分类优先,以及自动管理开关和指定路径的传递。 覆盖豆瓣音乐、MusicBrainz、无显式身份、专辑、单曲与艺术家合集,且下载器分类兜底不回写媒体信息。 +`tests/test_music_bilingual_matching.py` 在 Python/Rust 解析、同步/异步/流式搜索和订阅复核中, +验证双语作品名及艺人署名只有在括号两侧均属于同一实体的可信名称时才匹配; +未知别名、副标题、不同录音版本及歌曲所属专辑继续保留各自的身份边界。 + 普通电视剧首次下载的整包优先回归见 `tests/test_subscribe_paged_search.py`:订阅没有下载记录, 且最新媒体库缺失全部目标集时,先收集所选搜索范围内的候选再尝试完整包;单页策略仍只查每个 来源的第一页。整包继续通过规则过滤、优先级门槛和种子文件完整覆盖校验,无可用整包时按集 diff --git a/tests/test_music_bilingual_matching.py b/tests/test_music_bilingual_matching.py new file mode 100644 index 000000000..17b901a28 --- /dev/null +++ b/tests/test_music_bilingual_matching.py @@ -0,0 +1,164 @@ +""" +双语作品名与艺人署名在音乐资源搜索和订阅复核中的匹配边界。 +""" + +import asyncio +from copy import deepcopy +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.chain.search import SearchChain +from app.chain.subscribe.facade import SubscribeChain +from app.domain.context import Context, MusicInfo, TorrentInfo +from app.domain.music import match_music_resource, music_artist_matches, music_title_matches +from app.schemas.types import MediaType +from tests.test_music_resource_names import music_engine as music_engine +from tests.test_music_subscribe import _subscribe + + +@pytest.mark.usefixtures("music_engine") +@pytest.mark.parametrize("music_type,title,artist,aliases,resource", [ + ( + "recording", "晴天", "周杰伦", ["Jay Chou"], + "周杰伦 (Jay Chou) - 晴天 FLAC", + ), + ( + "album", "江南多雨时", "程响", ["Cheng Xiang"], + "程响 (Cheng Xiang) - 江南多雨时 [2020] [单曲专辑] FLAC / Lossless / WEB / 2020", + ), + ( + "album", "âge 20th Archives Song Archives", "Various Artists", [], + "群星 (Various Artists) - âge 20th Archives Song Archives [2020] [合辑] " + "FLAC / Lossless / Log (100%) / Cue / CD / 2020", + ), + ( + "album", "南泥湾", "Various Artists", [], + "群星 (Various Artists) - 南泥湾 (Nanni Bay) [1990] [专辑] " + "FLAC / Lossless / Log (100%) / Cue / CD / 自抓 / 1990", + ), +]) +def test_bilingual_music_resource_matches_verified_names(music_type, title, artist, aliases, resource): + """ + 双语署名各部分均属于已确认实体时,同步搜索与订阅复核应保留资源。 + """ + target = MusicInfo( + media_source="musicbrainz", media_id="target", music_type=music_type, + title=title, title_aliases=["Nanni Bay"] if title == "南泥湾" else [], + artists=[artist], artist_aliases=aliases, + ) + snapshot = deepcopy(target.to_dict()) + torrent = TorrentInfo(title=resource, category=MediaType.MUSIC.value, site=1) + + contexts = SearchChain()._build_music_contexts([torrent], target, rule_groups=[]) + + assert len(contexts) == 1 + assert contexts[0].match_status == "exact" + assert contexts[0].meta_info.media_id is None + assert len(SubscribeChain()._filter_music_subscribe_contexts( + _subscribe(name=title, music_type=music_type), target, [Context(torrent_info=torrent)], + )) == 1 + assert target.to_dict() == snapshot + + +@pytest.mark.parametrize("credit", ["周杰伦 (Jay Chou)", "Jay Chou(周杰倫)", "周杰伦(JAY CHOU)"]) +def test_bilingual_artist_credit_requires_verified_aliases(credit): + """ + 中文与外文署名均须由同一目标的正式名称或来源别名确认。 + """ + target = MusicInfo(title="晴天", artists=["周杰伦"], artist_aliases=["Jay Chou"]) + + assert music_artist_matches(target, [credit]) + assert not music_artist_matches(MusicInfo(title="晴天", artists=["周杰伦"]), [credit]) + assert not music_artist_matches(target, ["另一位歌手 (Jay Chou)"]) + + +@pytest.mark.parametrize("title", ["南泥湾 (Nanni Bay)", "Nanni Bay(南泥灣)"]) +def test_bilingual_title_requires_both_verified_names(title): + """ + 仅当括号两侧均为可信名称时允许组合名称,未知副标题继续保留作品边界。 + """ + target = MusicInfo(music_type="album", title="南泥湾", title_aliases=["Nanni Bay"]) + + assert music_title_matches(target, title) + assert not music_title_matches(MusicInfo(music_type="album", title="南泥湾"), title) + assert not music_title_matches(target, "另一张专辑 (Nanni Bay)") + + +@pytest.mark.parametrize("artist", ["Artist (2)", "Sun O)))"]) +def test_complete_artist_name_keeps_its_literal_parentheses(artist): + """ + 来源正式署名自身包含括号时,完整名称仍优先于双语格式解释。 + """ + target = MusicInfo(title="Song", artists=[artist]) + + assert music_artist_matches(target, [artist]) + assert not music_artist_matches(target, ["Artist"]) + + +@pytest.mark.parametrize("mode", ["sync", "async", "stream"]) +def test_bilingual_resource_survives_all_music_search_entries(monkeypatch, mode): + """ + 三个搜索入口在真实解析和匹配后都应返回双语署名资源,只替换站点请求。 + """ + chain = SearchChain() + target = MusicInfo(title="晴天", artists=["周杰伦"], artist_aliases=["Jay Chou"]) + torrent = TorrentInfo(title="周杰伦 (Jay Chou) - 晴天 FLAC", category=MediaType.MUSIC.value) + monkeypatch.setattr(chain, "_SearchChain__search_all_sites", Mock(return_value=[torrent])) + monkeypatch.setattr(chain, "_SearchChain__async_search_all_sites", AsyncMock(return_value=[torrent])) + + async def site_stream(**_kwargs): + """ + 从离线站点返回原始资源,保留后续真实搜索处理。 + """ + yield {"items": [torrent], "stage": "searching", "value": 100} + + async def collect(): + """ + 调用异步或流式入口,读取最终的匹配上下文。 + """ + if mode == "async": + return await chain.async_process(target, rule_groups=[]) + events = [event async for event in chain.async_process_stream(target, rule_groups=[])] + assert events[-1]["total_items"] == 1 + return events[-1]["contexts"] + + monkeypatch.setattr(chain, "_SearchChain__async_search_all_sites_stream", site_stream) + + contexts = chain.process(target, rule_groups=[]) if mode == "sync" else asyncio.run(collect()) + + assert len(contexts) == 1 + assert contexts[0].match_status == "exact" + assert contexts[0].meta_info.media_id is None + + +@pytest.mark.usefixtures("music_engine") +@pytest.mark.parametrize("title,reason", [ + ("周杰伦 (Other Singer) - 晴天 FLAC", "artist_unverified"), + ("Other Singer (Jay Chou) - 晴天 FLAC", "artist_unverified"), + ("周杰伦 (Jay Chou) - 晴天 (Unknown Subtitle) FLAC", "title_mismatch"), + ("周杰伦 (Jay Chou) - 晴天 (叶惠美) FLAC", "title_mismatch"), + ("周杰伦 (Jay Chou) - 晴天 (Live) FLAC", "version_mismatch"), + ("周杰伦 (Jay Chou) - 叶惠美 FLAC", "related_album"), +]) +def test_bilingual_credit_preserves_automatic_matching_limits(title, reason): + """ + 双语排版兼容不能放行不同艺人、未知副标题、现场版或仅有关联的整专。 + """ + target = MusicInfo(title="晴天", artists=["周杰伦"], artist_aliases=["Jay Chou"], album="叶惠美") + + result = match_music_resource(target, title) + + assert result.status != "exact" + assert result.reason == reason + + +def test_bilingual_related_album_keeps_recording_identity_separate(): + """ + 专辑名的双语别名只证明单曲归属,不能让整专取得单曲的精确身份。 + """ + target = MusicInfo(title="晴天", artists=["周杰伦"], album="叶惠美", album_aliases=["Ye Hui Mei"]) + + result = match_music_resource(target, "周杰伦 - 叶惠美 (Ye Hui Mei) FLAC") + + assert (result.status, result.reason) == ("album", "related_album")