fix: 放宽音乐资源标题匹配

This commit is contained in:
jxxghp
2026-08-13 20:57:15 +08:00
parent 23a90991fc
commit d611891296
2 changed files with 77 additions and 21 deletions
+30 -14
View File
@@ -8,6 +8,7 @@ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed
from datetime import datetime from datetime import datetime
from typing import AsyncIterator, Any, Dict, Iterable, Tuple from typing import AsyncIterator, Any, Dict, Iterable, Tuple
from typing import List, Optional from typing import List, Optional
from unicodedata import normalize
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@@ -40,6 +41,7 @@ from app.utils.media import (
resolve_media_identity, resolve_media_identity,
) )
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.zhconv import convert as zhconv_convert
class SearchChain(ChainBase): class SearchChain(ChainBase):
@@ -60,17 +62,17 @@ class SearchChain(ChainBase):
@classmethod @classmethod
def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""单曲或专辑实体生成站点关键词,避免单曲优先命中所属整专""" """按实体生成站点关键词,先扩大名称召回,再由结果匹配校验艺术家"""
artists = music.artists or [] artists = music.artists or []
artist = artists[0] if artists else music.album_artist artist = artists[0] if artists else music.album_artist
values: list[Optional[str]] = [] values: list[Optional[str]] = []
if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM: if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM:
album = music.album or music.title 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: else:
values.extend([ values.extend([
f"{artist} {music.title}" if artist and music.title else None,
music.title, music.title,
f"{artist} {music.title}" if artist and music.title else None,
]) ])
return cls._unique_music_texts(values) return cls._unique_music_texts(values)
@@ -81,8 +83,8 @@ class SearchChain(ChainBase):
resource_title: str, resource_title: str,
resource_description: Optional[str] = None, resource_description: Optional[str] = None,
) -> bool: ) -> bool:
"""校验站点资源标题同时包含目标音乐名称和已知艺术家。""" """校验标题与副标题同时命中目标专辑/曲名和艺术家。"""
normalized_resource = MetaMusic.compact_text( normalized_resource = cls._normalize_music_match_text(
f"{resource_title or ''} {resource_description or ''}" f"{resource_title or ''} {resource_description or ''}"
) )
if not normalized_resource: if not normalized_resource:
@@ -94,23 +96,37 @@ class SearchChain(ChainBase):
]) ])
else: else:
candidates = cls._unique_music_texts([music.title]) candidates = cls._unique_music_texts([music.title])
if not any( normalized_candidates = [
MetaMusic.compact_text(candidate) in normalized_resource cls._normalize_music_match_text(candidate)
for candidate in candidates for candidate in candidates
if MetaMusic.compact_text(candidate) ]
): if not any(candidate in normalized_resource for candidate in normalized_candidates if candidate):
return False return False
artists = cls._unique_music_texts([ artists = cls._unique_music_texts([
music.artist, music.artist,
music.album_artist, music.album_artist,
*(music.artists or []), *(music.artists or []),
]) ])
return not artists or any( normalized_artists = [
MetaMusic.compact_text(artist) in normalized_resource cls._normalize_music_match_text(artist)
for artist in artists 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 @staticmethod
def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]: def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]:
"""按清理后的文本去重,并保留站点搜索词原始顺序。""" """按清理后的文本去重,并保留站点搜索词原始顺序。"""
@@ -1469,7 +1485,7 @@ class SearchChain(ChainBase):
torrents: Optional[List[TorrentInfo]], torrents: Optional[List[TorrentInfo]],
mediainfo: MusicInfo, mediainfo: MusicInfo,
) -> List[TorrentInfo]: ) -> List[TorrentInfo]:
"""筛出音乐分类且标题包含目标单曲或专辑名称的站点资源。""" """筛出音乐分类且标题、副标题匹配目标名称与艺术家的站点资源。"""
return [ return [
torrent torrent
for torrent in torrents or [] for torrent in torrents or []
+47 -7
View File
@@ -70,8 +70,8 @@ def test_parse_query_keeps_non_artist_suffix():
assert meta.title == "思念 - 现场版" assert meta.title == "思念 - 现场版"
def test_build_site_keywords_prefers_artist_album(): def test_build_site_keywords_searches_album_before_combined_artist():
"""专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲""" """专辑先按专辑名扩大召回,再用艺术家与专辑名组合词回退"""
info = MusicInfo( info = MusicInfo(
music_type="album", music_type="album",
title="Get Lucky", title="Get Lucky",
@@ -80,13 +80,13 @@ def test_build_site_keywords_prefers_artist_album():
) )
assert SearchChain.music_site_keywords(info) == [ assert SearchChain.music_site_keywords(info) == [
"Daft Punk Random Access Memories",
"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( info = MusicInfo(
music_type="recording", music_type="recording",
title="Get Lucky", title="Get Lucky",
@@ -95,17 +95,18 @@ def test_build_site_keywords_keeps_recording_out_of_album_search():
) )
assert SearchChain.music_site_keywords(info) == [ assert SearchChain.music_site_keywords(info) == [
"Daft Punk Get Lucky",
"Get Lucky", "Get Lucky",
"Daft Punk Get Lucky",
] ]
def test_album_resource_match_requires_selected_album_title(): def test_album_resource_match_requires_selected_album_title():
"""专辑订阅只接受包含目标专辑名的站点资源,忽略大小写、空格和标点差异""" """专辑订阅只接受同时包含目标专辑名和艺术家的站点资源。"""
album = MusicInfo( album = MusicInfo(
music_type="album", music_type="album",
title="Random Access Memories", title="Random Access Memories",
album="Random Access Memories", album="Random Access Memories",
artists=["Daft Punk"],
names=["Random-Access Memories"], names=["Random-Access Memories"],
) )
@@ -122,6 +123,7 @@ def test_recording_resource_match_does_not_treat_album_name_as_track_alias():
music_type="recording", music_type="recording",
title="Get Lucky", title="Get Lucky",
album="Random Access Memories", album="Random Access Memories",
artists=["Daft Punk"],
names=["Get Lucky", "Random Access Memories"], 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 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,
"【永遠・是朋友】24bit96kHz",
"專輯藝人:周華健;無損音樂",
) 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(): def test_normalize_candidates_deduplicates_source_identity():
"""同一来源和媒体 ID 的音乐候选应只保留一次。""" """同一来源和媒体 ID 的音乐候选应只保留一次。"""
results = MediaChain.normalize_music_candidates( results = MediaChain.normalize_music_candidates(