From d61189129622bebd4231ae280680729a080c2882 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 13 Aug 2026 20:44:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=BE=E5=AE=BD=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=A0=87=E9=A2=98=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/search.py | 44 +++++++++++++++++++--------- tests/test_music_workflows.py | 54 ++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/app/chain/search.py b/app/chain/search.py index 061813f81..d49c1ebd4 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -8,6 +8,7 @@ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed from datetime import datetime from typing import AsyncIterator, Any, Dict, Iterable, Tuple from typing import List, Optional +from unicodedata import normalize from fastapi.concurrency import run_in_threadpool @@ -40,6 +41,7 @@ from app.utils.media import ( resolve_media_identity, ) from app.utils.string import StringUtils +from app.utils.zhconv import convert as zhconv_convert class SearchChain(ChainBase): @@ -60,17 +62,17 @@ class SearchChain(ChainBase): @classmethod def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: - """按单曲或专辑实体生成站点关键词,避免单曲优先命中所属整专。""" + """按实体生成站点关键词,先扩大名称召回,再由结果匹配校验艺术家。""" artists = music.artists or [] artist = artists[0] if artists else music.album_artist values: list[Optional[str]] = [] if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM: album = music.album or music.title - values.extend([f"{artist} {album}" if artist and album else None, album]) + values.extend([album, f"{artist} {album}" if artist and album else None]) else: values.extend([ - f"{artist} {music.title}" if artist and music.title else None, music.title, + f"{artist} {music.title}" if artist and music.title else None, ]) return cls._unique_music_texts(values) @@ -81,8 +83,8 @@ class SearchChain(ChainBase): resource_title: str, resource_description: Optional[str] = None, ) -> bool: - """校验站点资源标题同时包含目标音乐名称和已知艺术家。""" - normalized_resource = MetaMusic.compact_text( + """校验标题与副标题同时命中目标专辑/曲名和艺术家。""" + normalized_resource = cls._normalize_music_match_text( f"{resource_title or ''} {resource_description or ''}" ) if not normalized_resource: @@ -94,23 +96,37 @@ class SearchChain(ChainBase): ]) else: candidates = cls._unique_music_texts([music.title]) - if not any( - MetaMusic.compact_text(candidate) in normalized_resource - for candidate in candidates - if MetaMusic.compact_text(candidate) - ): + normalized_candidates = [ + cls._normalize_music_match_text(candidate) + for candidate in candidates + ] + if not any(candidate in normalized_resource for candidate in normalized_candidates if candidate): return False artists = cls._unique_music_texts([ music.artist, music.album_artist, *(music.artists or []), ]) - return not artists or any( - MetaMusic.compact_text(artist) in normalized_resource + normalized_artists = [ + cls._normalize_music_match_text(artist) for artist in artists - if MetaMusic.compact_text(artist) + ] + return bool(normalized_artists) and any( + artist in normalized_resource + for artist in normalized_artists + if artist ) + @staticmethod + def _normalize_music_match_text(value: Optional[str]) -> str: + """去除音乐名称干扰字符并转换为简体小写文本。""" + compact_text = "".join( + char + for char in normalize("NFKC", str(value or "")).casefold() + if char.isalnum() + ) + return zhconv_convert(compact_text, "zh-hans") + @staticmethod def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]: """按清理后的文本去重,并保留站点搜索词原始顺序。""" @@ -1469,7 +1485,7 @@ class SearchChain(ChainBase): torrents: Optional[List[TorrentInfo]], mediainfo: MusicInfo, ) -> List[TorrentInfo]: - """筛出音乐分类且标题包含目标单曲或专辑名称的站点资源。""" + """筛出音乐分类且标题、副标题匹配目标名称与艺术家的站点资源。""" return [ torrent for torrent in torrents or [] diff --git a/tests/test_music_workflows.py b/tests/test_music_workflows.py index 63622dfcf..e6f84aa72 100644 --- a/tests/test_music_workflows.py +++ b/tests/test_music_workflows.py @@ -70,8 +70,8 @@ def test_parse_query_keeps_non_artist_suffix(): assert meta.title == "思念 - 现场版" -def test_build_site_keywords_prefers_artist_album(): - """专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。""" +def test_build_site_keywords_searches_album_before_combined_artist(): + """专辑先按专辑名扩大召回,再用艺术家与专辑名组合词回退。""" info = MusicInfo( music_type="album", title="Get Lucky", @@ -80,13 +80,13 @@ def test_build_site_keywords_prefers_artist_album(): ) assert SearchChain.music_site_keywords(info) == [ - "Daft Punk Random Access Memories", "Random Access Memories", + "Daft Punk Random Access Memories", ] -def test_build_site_keywords_keeps_recording_out_of_album_search(): - """单曲订阅只按艺术家与曲名搜索,不能优先命中所属整张专辑。""" +def test_build_site_keywords_searches_track_before_combined_artist(): + """单曲先按曲名扩大召回,且关键词不能混入所属专辑名。""" info = MusicInfo( music_type="recording", title="Get Lucky", @@ -95,17 +95,18 @@ def test_build_site_keywords_keeps_recording_out_of_album_search(): ) assert SearchChain.music_site_keywords(info) == [ - "Daft Punk Get Lucky", "Get Lucky", + "Daft Punk Get Lucky", ] def test_album_resource_match_requires_selected_album_title(): - """专辑订阅只接受包含目标专辑名的站点资源,忽略大小写、空格和标点差异。""" + """专辑订阅只接受同时包含目标专辑名和艺术家的站点资源。""" album = MusicInfo( music_type="album", title="Random Access Memories", album="Random Access Memories", + artists=["Daft Punk"], names=["Random-Access Memories"], ) @@ -122,6 +123,7 @@ def test_recording_resource_match_does_not_treat_album_name_as_track_alias(): music_type="recording", title="Get Lucky", album="Random Access Memories", + artists=["Daft Punk"], names=["Get Lucky", "Random Access Memories"], ) @@ -145,6 +147,44 @@ def test_resource_match_requires_artist_when_target_artist_is_known(): assert SearchChain.matches_music_resource(recording, "晴天 FLAC") is False +def test_album_resource_match_combines_title_description_and_converts_traditional_chinese(): + """专辑名与艺术家可分处标题和副标题,繁简及干扰符号不影响匹配。""" + album = MusicInfo( + music_type="album", + title="永远是朋友", + album="永远是朋友", + artists=["周华健"], + ) + + assert SearchChain.matches_music_resource( + album, + "【永遠・是朋友】24bit/96kHz", + "專輯藝人:周華健;無損音樂", + ) is True + + +def test_recording_resource_match_combines_title_description_and_converts_traditional_chinese(): + """单曲按曲名与艺术家匹配,并统一繁简及全角符号。""" + recording = MusicInfo( + music_type="recording", + title="晴天", + artists=["周杰伦"], + ) + + assert SearchChain.matches_music_resource( + recording, + "01.晴 天[FLAC]", + "演唱:周杰倫", + ) is True + + +def test_music_resource_match_rejects_target_without_artist(): + """目标缺少艺术家时不能仅凭同名专辑或单曲放行。""" + recording = MusicInfo(music_type="recording", title="晴天") + + assert SearchChain.matches_music_resource(recording, "周杰伦 - 晴天 FLAC") is False + + def test_normalize_candidates_deduplicates_source_identity(): """同一来源和媒体 ID 的音乐候选应只保留一次。""" results = MediaChain.normalize_music_candidates(