mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
fix: 放宽音乐资源标题匹配
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user