From fba0b6ae678141724d308244cf190fd78b61d982 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 00:48:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E4=B8=8E=E5=BD=B1=E8=A7=86=E7=9A=84=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8C=B9=E9=85=8D=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/policy/mcp.py | 2 + .../policy/resources/api_mcp_schema.json | 18 + app/api/endpoints/download.py | 12 +- app/api/endpoints/media.py | 16 +- app/api/endpoints/search.py | 6 + app/application/music/catalog.py | 25 +- app/application/search/state.py | 4 + app/application/torrent/download.py | 2 +- app/chain/_music.py | 53 +- app/chain/base.py | 2 +- app/chain/download/selection.py | 13 +- app/chain/media/catalog.py | 28 +- app/chain/media/contract.py | 15 + app/chain/media/recognition.py | 4 +- app/chain/media/search.py | 39 +- app/chain/search/__init__.py | 5 +- app/chain/search/cache.py | 4 + app/chain/search/contract.py | 9 +- app/chain/search/execution.py | 221 ++++++ app/chain/search/facade.py | 49 +- app/chain/search/media.py | 675 ++---------------- app/chain/search/music.py | 355 +++------ app/chain/search/plan.py | 40 +- app/chain/search/result.py | 90 ++- app/chain/search/title.py | 10 +- app/chain/subscribe/contract.py | 2 + app/chain/subscribe/metadata.py | 45 ++ app/chain/subscribe/search.py | 65 +- app/chain/torrents.py | 12 +- app/domain/context.py | 33 +- app/domain/meta/metamusic.py | 56 +- app/domain/metainfo.py | 26 +- app/domain/music.py | 187 +++++ app/modules/musicbrainz/__init__.py | 136 +++- app/schemas/context.py | 2 + app/schemas/music.py | 5 + docs/architecture-overview.md | 2 +- docs/architecture/media-search-design.md | 92 +++ docs/architecture/optimization-checklist.md | 6 +- docs/mcp-api.md | 13 +- docs/rules/05-architecture.md | 10 +- skills/moviepilot-api/SKILL.md | 3 +- .../architecture/concurrency-baseline.json | 12 +- .../architecture/dependency-baseline.json | 52 +- .../fixtures/architecture/mypy-baseline.json | 12 +- .../fixtures/architecture/ruff-baseline.json | 12 - .../startup-performance-baseline.json | 6 +- tests/test_chain_vertical_slices.py | 6 +- tests/test_media_compat.py | 2 + tests/test_media_search_pipeline.py | 85 +++ tests/test_media_search_source_selection.py | 9 +- tests/test_music_catalog_service.py | 9 + tests/test_music_download.py | 22 + tests/test_music_endpoint.py | 19 +- tests/test_music_matching.py | 192 +++++ tests/test_music_recognize_routing.py | 16 + tests/test_music_search.py | 96 ++- tests/test_music_search_api.py | 59 ++ tests/test_music_subscribe.py | 74 +- tests/test_musicbrainz_module.py | 82 ++- tests/test_search_architecture.py | 1 + tests/test_search_compat.py | 9 + tests/test_search_sync_async_parity.py | 85 +-- 63 files changed, 1951 insertions(+), 1301 deletions(-) create mode 100644 app/chain/search/execution.py create mode 100644 app/domain/music.py create mode 100644 docs/architecture/media-search-design.md create mode 100644 tests/test_media_search_pipeline.py create mode 100644 tests/test_music_matching.py create mode 100644 tests/test_music_search_api.py diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 62283ca1d..2d9ea150f 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -348,9 +348,11 @@ FIELD_DESCRIPTIONS = { "modify_time": "Storage item modification timestamp.", "mtype": "MoviePilot media type or subscription-history category required by the operation.", "music_type": "Music identity level: recording, album, or artist where supported.", + "include_candidates": "Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.", "name": "Human-readable name of the site, storage item, subscription, or rule group.", "new_name": "Replacement name for the existing filter-rule group.", "new_rule_id": "Replacement stable ID for the existing custom filter rule.", + "next_run_at": "Next scheduled subscription search time. Null when no future execution is planned.", "note": "Structured auxiliary metadata stored with the record.", "operation": "Setting update mode: replace, merge_dict, upsert_list_item, or remove_list_item.", "operation_id": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index bd48e9f8e..120c28c0a 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -3673,6 +3673,18 @@ "description": "Human-readable workflow, provider, or execution error message.", "title": "Error" }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Next scheduled subscription search time. Null when no future execution is planned.", + "title": "Next Run At" + }, "phase": { "description": "Current phase of a subscription execution.", "title": "Phase", @@ -9607,6 +9619,12 @@ "description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.", "title": "Count" }, + "include_candidates": { + "default": false, + "description": "Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.", + "title": "Include Candidates", + "type": "boolean" + }, "media_source": { "$ref": "#/$defs/MediaSource", "description": "Metadata source identifier. Preserve the exact value returned with media_id." diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 3692af340..b48d6bd86 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -25,7 +25,6 @@ from app.chain.media import MediaChain from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo from app.domain.media import is_music_media_source, normalize_music_type from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData @@ -162,11 +161,8 @@ def _resolve_add_media( ) if is_music and not normalized_music_type: normalized_music_type = MUSIC_ENTITY_RECORDING - metainfo = ( - MetaMusic.parse_query(torrent_in.title) - if is_music - else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) - ) + metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description, + mtype=MediaType.MUSIC if is_music else None) if media_source and media_id: mediainfo = MediaChain().recognize_media( meta=metainfo, @@ -232,12 +228,10 @@ def download( """ if isinstance(media_in, _SchemaMusicInfo): mediainfo = MusicInfo.from_dict(media_in.model_dump()) - metainfo = MetaMusic.from_music_info(mediainfo) - metainfo.org_string = torrent_in.title else: - metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) + metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description, mtype=mediainfo.type) # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 45837e280..1e5d7f8e1 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -322,16 +322,12 @@ async def search( source_selection = selected_sources or None media_chain = MediaChain() - if type == "music" or any(is_music_media_source(source) for source in selected_sources): - # 音乐搜索统一入口,与影视搜索共用 /media/search - music_search_params = {"query": title, "limit": count} - # 未指定来源时由 MediaChain 使用默认 MusicBrainz 来源。 - if source_selection: - music_search_params["media_source"] = source_selection - music_infos = await media_chain.async_search_music(**music_search_params) - return [info.to_dict() for info in music_infos] if music_infos else [] - if type == "media": - _, medias = await media_chain.async_search(title=title, media_source=source_selection) + is_music = type == "music" or any(is_music_media_source(source) for source in selected_sources) + if type == "media" or is_music: + _, medias = await media_chain.async_search( + title=title, media_source=source_selection, + **({"mtype": MediaType.MUSIC, "limit": count} if is_music else {}), + ) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections(name=title, media_source=source_selection) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index ab8da8919..c072a0d86 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -405,6 +405,7 @@ async def search_by_id_stream( season: Optional[str] = None, sites: Optional[str] = None, music_type: Optional[str] = None, + include_candidates: bool = False, _: _SchemaTokenPayload = Depends(verify_resource_token), ) -> Any: """ @@ -426,6 +427,8 @@ async def search_by_id_stream( if not search_params: yield {"type": "error", "success": False, "message": message} return + if include_candidates: + search_params["include_candidates"] = True torrents = SearchChain().async_search_by_id_stream( **search_params, mtype=media_type, @@ -457,6 +460,7 @@ async def search_by_id( season: Optional[str] = None, sites: Optional[str] = None, music_type: Optional[str] = None, + include_candidates: bool = False, _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None, @@ -474,6 +478,8 @@ async def search_by_id( ) if not search_params: return _SchemaResponse(success=False, message=message) + if include_candidates: + search_params["include_candidates"] = True torrents = await SearchChain().async_search_by_id( **search_params, mtype=media_type, diff --git a/app/application/music/catalog.py b/app/application/music/catalog.py index c048594e0..4699e07e5 100644 --- a/app/application/music/catalog.py +++ b/app/application/music/catalog.py @@ -1,6 +1,7 @@ """多来源音乐目录搜索应用服务。""" import asyncio +from itertools import zip_longest from typing import Any, Callable, Iterable, Optional from app.domain.context import MusicInfo @@ -77,41 +78,45 @@ class MusicCatalogService: def search( self, - query: str, + query: str | MetaMusic, limit: int = 20, media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """顺序搜索一个或多个音乐来源,隔离单一来源失败。""" - meta = MetaMusic.parse_query(query) + meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query) candidates = [] for source in self.search_sources(media_source): chain = self._source_resolver(source) if not chain: continue try: - candidates.extend(chain.search_music(meta, limit=limit)) + candidates.append(chain.search_music(meta, limit=limit)) except Exception as error: self._warning(f"音乐来源 {source} 搜索失败:{str(error)}") - return self.normalize_candidates(candidates, limit=limit) + return self.merge_sources(candidates, limit=limit) async def async_search( self, - query: str, + query: str | MetaMusic, limit: int = 20, media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """并行搜索一个或多个音乐来源,隔离单一来源失败。""" - meta = MetaMusic.parse_query(query) + meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query) searches = [] for source in self.search_sources(media_source): chain = self._source_resolver(source) if chain: searches.append(self._async_search_source(chain, source, meta, limit)) source_results = await asyncio.gather(*searches) if searches else [] - return self.normalize_candidates( - [candidate for results in source_results for candidate in results], - limit=limit, - ) + return self.merge_sources(source_results, limit=limit) + + @classmethod + def merge_sources(cls, groups: list[list[MusicInfo]], limit: int) -> list[MusicInfo]: + """先按来源独立去重再轮询合并,避免首个来源占满全局条数上限。""" + normalized = [cls.normalize_candidates(group) for group in groups] + candidates = [item for row in zip_longest(*normalized) for item in row if item is not None] + return cls.normalize_candidates(candidates, limit=limit) async def _async_search_source( self, diff --git a/app/application/search/state.py b/app/application/search/state.py index cf4e38f7e..5b6ea7388 100644 --- a/app/application/search/state.py +++ b/app/application/search/state.py @@ -43,6 +43,8 @@ def normalize_search_params( } if params.get("music_type"): normalized["music_type"] = str(params["music_type"]) + if str(params.get("include_candidates", "")).casefold() in ("true", "1"): + normalized["include_candidates"] = "true" return normalized if normalized["keyword"] or media_id else None @@ -83,6 +85,7 @@ class SearchStateService: sites: Optional[List[int]] = None, music_type: Optional[str] = None, result_type: Optional[str] = "torrent", + include_candidates: bool = False, ) -> Optional[Dict[str, str]]: """把公开搜索参数构造成可持久化的兼容字典。""" return normalize_search_params( @@ -99,6 +102,7 @@ class SearchStateService: "sites": stringify_sites(sites), "music_type": music_type, "result_type": result_type or "torrent", + "include_candidates": include_candidates, } ) diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index 784fc061d..987f2a044 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -365,7 +365,7 @@ class TorrentHelper: _torrent = _context.torrent_info _media = _context.media_info # 标题 - _title = str(_media.title).ljust(200, ' ') + _title = str(_media.title if _media else _meta.name).ljust(200, ' ') # 站点优先级 _site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0') # 站点上传量 diff --git a/app/chain/_music.py b/app/chain/_music.py index 14354eabf..0f541264d 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -406,9 +406,7 @@ class MusicSubscribeMixin: context = copy.copy(source_context) context.torrent_info = torrent - meta = MetaMusic.from_music_info(mediainfo) - meta.org_string = torrent.title - meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) + meta = MetaMusic.parse_resource(torrent.title, torrent.description) if subscribe.best_version: # 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则 # 优先级时再回退到规范化音质分数,确保零配置也能自动升级。 @@ -513,53 +511,8 @@ class MusicSubscribeMixin: subscribe: SubscriptionSnapshot, execution_context: Optional[SubscriptionExecutionContext] = None, ) -> None: - """复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。""" - self._ensure_music_execution_active(execution_context) - target = self._prepare_music_subscribe(subscribe) - if not target: - return - subscribe, mediainfo, _ = target - self._ensure_music_execution_active(execution_context) - - sites = self.get_sub_sites(subscribe) - 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 [] - keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo) - if not keywords: - keywords = [subscribe.name] - - searchchain = SearchChain() - contexts: List[Context] = [] - if execution_context: - execution_context.report_phase("searching") - for keyword in keywords: - self._ensure_music_execution_active(execution_context) - contexts = searchchain.search_by_title( - title=keyword, - sites=sites, - mtype=MediaType.MUSIC, - rule_groups=rule_groups, - ) - self._ensure_music_execution_active(execution_context) - contexts = self._filter_music_subscribe_contexts( - subscribe=subscribe, - mediainfo=mediainfo, - contexts=contexts, - ) - if contexts: - break - - if not contexts: - logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源") - return - - self._download_music_subscribe( - subscribe, - mediainfo, - contexts, - execution_context=execution_context, - ) + """兼容音乐订阅入口,主动搜索、站点预算和结果处理统一由订阅搜索 owner 编排。""" + self._process_search_subscription(subscribe, None, execution_context=execution_context) def _match_music_subscribe( self, diff --git a/app/chain/base.py b/app/chain/base.py index 84d004434..62b11c466 100644 --- a/app/chain/base.py +++ b/app/chain/base.py @@ -752,7 +752,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met self, rule_groups: List[str], torrent_list: List[TorrentInfo], - mediainfo: MediaInfo = None, + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, ) -> List[TorrentInfo]: """ 过滤种子资源 diff --git a/app/chain/download/selection.py b/app/chain/download/selection.py index 5ff6365a0..0c001970a 100644 --- a/app/chain/download/selection.py +++ b/app/chain/download/selection.py @@ -34,6 +34,14 @@ def _new_torrent_helper() -> TorrentHelper: return factory() +def _confirmed_batch_contexts(contexts: List[Context]) -> List[Context]: + """排除待人工确认的资源,覆盖原始候选及插件替换候选两个入口。""" + confirmed = [context for context in contexts if getattr(context, "match_status", None) in (None, "exact")] + if len(confirmed) != len(contexts): + logger.warning(f"跳过 {len(contexts) - len(confirmed)} 个待人工确认的资源,不参与自动批量下载") + return confirmed + + class DownloadSelectionOwner(_DownloadOwnerBase): """下载候选规范化、排序和媒体选择 owner。""" @@ -120,6 +128,9 @@ class DownloadSelectionOwner(_DownloadOwnerBase): :return: 排序后的上下文和本轮失败冷却记录;资源选择事件仍可替换上下文列表 """ + contexts = _confirmed_batch_contexts(contexts) + if not contexts: + return [], {} logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}") event_data = ResourceSelectionEventData( contexts=contexts, @@ -135,7 +146,7 @@ class DownloadSelectionOwner(_DownloadOwnerBase): f"items (source: {event_data.source})" ) contexts = event_data.updated_contexts - contexts = _new_torrent_helper().sort_torrents(contexts) + contexts = _new_torrent_helper().sort_torrents(_confirmed_batch_contexts(contexts)) active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = { fingerprint: failure for fingerprint, failure in self._active_download_failure_fingerprints( diff --git a/app/chain/media/catalog.py b/app/chain/media/catalog.py index 49a53b8b6..d02605a52 100644 --- a/app/chain/media/catalog.py +++ b/app/chain/media/catalog.py @@ -19,8 +19,10 @@ from app.foundation.text import convert as zhconv_convert from app.runtime.log import logger from app.schemas.media import normalize_media_source, resolve_media_identity from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, MediaSource, MediaSourceSelection, + MediaType, ) @@ -100,7 +102,8 @@ class MediaCatalogOwner(_MediaOwnerBase): media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。""" - return self._music_catalog().search(query, limit, media_source) + _, candidates = self.search(title=query, media_source=media_source, mtype=MediaType.MUSIC, limit=limit) + return cast(list[MusicInfo], candidates) async def async_search_music( self, @@ -109,11 +112,8 @@ class MediaCatalogOwner(_MediaOwnerBase): media_source: Optional[MediaSourceSelection] = None, ) -> list[MusicInfo]: """并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。""" - return await self._music_catalog().async_search( - query, - limit, - media_source, - ) + _, candidates = await self.async_search(title=query, media_source=media_source, mtype=MediaType.MUSIC, limit=limit) + return cast(list[MusicInfo], candidates) @classmethod def _validate_music_result( @@ -160,6 +160,22 @@ class MediaCatalogOwner(_MediaOwnerBase): if not updates: return info simplified = deepcopy(info) + for field_name, alias_field in ( + ("title", "title_aliases"), + ("album", "album_aliases"), + ("artists", "artist_aliases"), + ("album_artist", "artist_aliases"), + ("names", "title_aliases"), + ): + if field_name not in updates: + continue + if field_name in ("album_artist", "names") and info.music_type != MUSIC_ENTITY_ALBUM: + continue + original = getattr(info, field_name) + originals = original if isinstance(original, list) else [original] + setattr(simplified, alias_field, list(dict.fromkeys([ + *(getattr(simplified, alias_field, None) or []), *originals, + ]))) for field_name, value in updates.items(): setattr(simplified, field_name, value) return simplified diff --git a/app/chain/media/contract.py b/app/chain/media/contract.py index 593317997..e83c9ae71 100644 --- a/app/chain/media/contract.py +++ b/app/chain/media/contract.py @@ -50,6 +50,7 @@ class _ProjectionEventManagerPort(Protocol): if TYPE_CHECKING: + from app.application.music.catalog import MusicCatalogService class _MediaOwnerBase: """声明各媒体 owner 组合后可依赖的精确静态合同。""" @@ -268,6 +269,20 @@ if TYPE_CHECKING: """同步搜索媒体候选。""" ... + def _music_catalog(self) -> MusicCatalogService: + """提供音乐来源 ABI 的目录适配器。""" + ... + + def search(self, title: str, media_source: Optional[MediaSourceSelection] = None, + mtype: Optional[MediaType] = None, limit: int = 20) -> tuple[Optional[MetaBase], list[MediaInfo] | list[MusicInfo]]: + """通过共用入口搜索所有媒体类型。""" + ... + + async def async_search(self, title: str, media_source: Optional[MediaSourceSelection] = None, + mtype: Optional[MediaType] = None, limit: int = 20) -> tuple[Optional[MetaBase], list[MediaInfo] | list[MusicInfo]]: + """通过共用入口异步搜索所有媒体类型。""" + ... + async def async_search_medias( self, meta: MetaBase, diff --git a/app/chain/media/recognition.py b/app/chain/media/recognition.py index 7bda4f305..82c9886fb 100644 --- a/app/chain/media/recognition.py +++ b/app/chain/media/recognition.py @@ -101,7 +101,7 @@ class MediaRecognitionOwner(_MediaOwnerBase): "media_source": self._music_primary_source, "meta": meta, "cache": cache, - "music_type": MUSIC_ENTITY_RECORDING, + "music_type": module_kwargs.get("music_type") or MUSIC_ENTITY_RECORDING, }, ) return _NativeRecognitionPlan( @@ -182,7 +182,7 @@ class MediaRecognitionOwner(_MediaOwnerBase): @staticmethod def _has_remote_identity(result: Optional[MediaInfo]) -> bool: """音乐识别仅在取得远端来源身份后视为完整命中。""" - return bool(result and result.media_source) + return bool(result and result.media_source and result.media_id) @staticmethod def _accepted_recognition( diff --git a/app/chain/media/search.py b/app/chain/media/search.py index 8d6fac37e..7f4f4f866 100644 --- a/app/chain/media/search.py +++ b/app/chain/media/search.py @@ -7,14 +7,19 @@ from app.chain.media.contract import _MediaOwnerBase from app.domain import title as title_rules from app.domain.context import ( MediaInfo, + MusicInfo, ) from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.runtime.log import logger from app.schemas.types import ( MediaSourceSelection, + MediaType, ) +CatalogResults = List[MediaInfo] | List[MusicInfo] + @dataclass(frozen=True, slots=True) class _MediaSearchRequest: @@ -24,8 +29,10 @@ class _MediaSearchRequest: meta: MetaBase -def _build_media_search_request(title: str) -> _MediaSearchRequest: +def _build_media_search_request(title: str, mtype: Optional[MediaType] = None) -> _MediaSearchRequest: """将搜索文本一次性投影为规范元数据,避免双入口规则漂移。""" + if mtype == MediaType.MUSIC: + return _MediaSearchRequest(content=title, meta=MetaInfo(title=title, mtype=mtype)) mtype, _, season_num, episode_num, year, content = title_rules.parse_search_keyword(title) content = content or title meta = MetaInfo(content) @@ -44,8 +51,8 @@ def _build_media_search_request(title: str) -> _MediaSearchRequest: def _finish_media_search( request: _MediaSearchRequest, - medias: Optional[List[MediaInfo]], -) -> Tuple[MetaBase, List[MediaInfo]]: + medias: Optional[CatalogResults], +) -> Tuple[MetaBase, CatalogResults]: """统一空结果与成功结果投影,并保持既有日志语义。""" if not medias: logger.warn(f"{request.meta.name} 没有找到对应的媒体信息!") @@ -58,8 +65,9 @@ class MediaSearchOwner(_MediaOwnerBase): """媒体搜索入口 owner。""" def search( - self, title: str, media_source: Optional[MediaSourceSelection] = None - ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: + self, title: str, media_source: Optional[MediaSourceSelection] = None, + mtype: Optional[MediaType] = None, limit: int = 20, + ) -> Tuple[Optional[MetaBase], CatalogResults]: """ 搜索媒体/人物信息 @@ -67,17 +75,16 @@ class MediaSearchOwner(_MediaOwnerBase): :param media_source: 请求级搜索数据源 :return: 识别元数据,媒体信息列表 """ - request = _build_media_search_request(title) + request = _build_media_search_request(title, mtype) logger.info(f"开始搜索媒体信息:{request.meta.name}") - medias: Optional[List[MediaInfo]] = self.search_medias( - meta=request.meta, - media_source=media_source, - ) + medias = self._music_catalog().search(request.meta, limit=limit, media_source=media_source) \ + if isinstance(request.meta, MetaMusic) else self.search_medias(meta=request.meta, media_source=media_source) return _finish_media_search(request, medias) async def async_search( - self, title: str, media_source: Optional[MediaSourceSelection] = None - ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: + self, title: str, media_source: Optional[MediaSourceSelection] = None, + mtype: Optional[MediaType] = None, limit: int = 20, + ) -> Tuple[Optional[MetaBase], CatalogResults]: """ 搜索媒体/人物信息(异步版本) @@ -85,10 +92,8 @@ class MediaSearchOwner(_MediaOwnerBase): :param media_source: 请求级搜索数据源 :return: 识别元数据,媒体信息列表 """ - request = _build_media_search_request(title) + request = _build_media_search_request(title, mtype) logger.info(f"开始搜索媒体信息:{request.meta.name}") - medias: Optional[List[MediaInfo]] = await self.async_search_medias( - meta=request.meta, - media_source=media_source, - ) + medias = await self._music_catalog().async_search(request.meta, limit=limit, media_source=media_source) \ + if isinstance(request.meta, MetaMusic) else await self.async_search_medias(meta=request.meta, media_source=media_source) return _finish_media_search(request, medias) diff --git a/app/chain/search/__init__.py b/app/chain/search/__init__.py index 8f5241937..bfe3fe3e1 100644 --- a/app/chain/search/__init__.py +++ b/app/chain/search/__init__.py @@ -1,7 +1,10 @@ """搜索 Chain 的惰性稳定公开入口。""" from importlib import import_module -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from app.chain.search.facade import SearchChain _EXPORTS = { "SearchChain": ("app.chain.search.facade", "SearchChain"), diff --git a/app/chain/search/cache.py b/app/chain/search/cache.py index 0d97ca810..cb2eff3fc 100644 --- a/app/chain/search/cache.py +++ b/app/chain/search/cache.py @@ -60,6 +60,7 @@ class SearchCacheOwner(_SearchOwnerBase): sites: Optional[List[int]] = None, music_type: Optional[str] = None, result_type: Optional[str] = "torrent", + include_candidates: bool = False, ) -> None: """ 保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 @@ -77,6 +78,7 @@ class SearchCacheOwner(_SearchOwnerBase): sites=sites, music_type=music_type, result_type=result_type, + include_candidates=include_candidates, ) async def async_save_last_search_params( @@ -94,6 +96,7 @@ class SearchCacheOwner(_SearchOwnerBase): sites: Optional[List[int]] = None, music_type: Optional[str] = None, result_type: Optional[str] = "torrent", + include_candidates: bool = False, ) -> None: """ 异步保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 @@ -111,6 +114,7 @@ class SearchCacheOwner(_SearchOwnerBase): sites=sites, music_type=music_type, result_type=result_type, + include_candidates=include_candidates, ) def last_search_params(self) -> Optional[Dict[str, str]]: diff --git a/app/chain/search/contract.py b/app/chain/search/contract.py index ef3bd7600..7810ebc09 100644 --- a/app/chain/search/contract.py +++ b/app/chain/search/contract.py @@ -1,10 +1,11 @@ """SearchChain owner 的静态组合合同。""" -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Awaitable, Callable from app.chain.base import ChainBase if TYPE_CHECKING: + from app.domain.context import Context class _SearchOwnerBase(ChainBase): """向类型检查器声明稳定 Facade 上的跨 owner 能力。""" @@ -53,7 +54,7 @@ if TYPE_CHECKING: _normalize_ai_indices: Callable[..., Any] _normalize_music_match_text: Callable[..., Any] _normalize_search_params: Callable[..., Any] - _parse_result: Callable[..., Any] + _parse_result: Callable[..., list[Context]] _parse_subtitle_result: Callable[..., Any] _prepare_params: Callable[..., Any] _process_music: Callable[..., Any] @@ -77,7 +78,7 @@ if TYPE_CHECKING: async_last_search_params: Callable[..., Any] async_last_search_results: Callable[..., Any] async_last_subtitle_search_results: Callable[..., Any] - async_process: Callable[..., Any] + async_process: Callable[..., Awaitable[list[Context]]] async_process_stream: Callable[..., Any] async_save_last_search_params: Callable[..., Any] async_search_by_id: Callable[..., Any] @@ -96,7 +97,7 @@ if TYPE_CHECKING: last_search_results: Callable[..., Any] matches_music_resource: Callable[..., Any] music_site_keywords: Callable[..., Any] - process: Callable[..., Any] + process: Callable[..., list[Context]] record_subscription_site_budget_failure: Callable[..., Any] consume_subscription_site_budget_failures: Callable[..., Any] record_subscription_site_budget_deferred: Callable[..., Any] diff --git a/app/chain/search/execution.py b/app/chain/search/execution.py new file mode 100644 index 000000000..ba641a4ff --- /dev/null +++ b/app/chain/search/execution.py @@ -0,0 +1,221 @@ +"""所有媒体类型共用的资源搜索状态机与同步、异步 I/O 驱动。""" + +import asyncio +import random +import time +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Callable, Dict, Generator, Iterator, List, Literal, Optional, cast + +from app.chain.media import MediaChain +from app.chain.search.contract import _SearchOwnerBase +from app.chain.search.plan import SearchPlanOwner +from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo +from app.domain.metainfo import MetaInfo +from app.runtime.execution import run_in_threadpool +from app.runtime.log import logger +from app.schemas.mediaserver import NotExistMediaInfo + + +@dataclass(frozen=True, slots=True) +class MediaSearchPlan: + """冻结搜索业务输入,媒体类型只在关键词和匹配策略中产生差异。""" + + mediainfo: MediaInfo | MusicInfo + keyword: Optional[str] = None + no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None + sites: Optional[List[int]] = None + rule_groups: Optional[List[str]] = None + area: Optional[str] = "title" + custom_words: Optional[List[str]] = None + filter_params: Optional[Dict[str, str]] = None + include_candidates: bool = False + candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None + + +@dataclass(frozen=True, slots=True) +class _SearchStep: + """请求驱动器执行一次 I/O 或 CPU 操作,不在外壳重复媒体业务决策。""" + + kind: Literal["recognize", "supplement", "search", "parse"] + params: Dict[str, Any] + search_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class _SearchOutcome: + """记录最终结果、原始候选数量及各阶段诊断。""" + + contexts: List[Context] + candidate_count: int = 0 + counts: Dict[str, int] = field(default_factory=dict) + recognition_failed: bool = False + + +def _result_params(plan: MediaSearchPlan, mediainfo: MediaInfo | MusicInfo, + torrents: List[TorrentInfo], season_episodes: Any, counts: Counter[str]) -> Dict[str, Any]: + """为每次完整过滤建立统一参数,计数只描述当前累计候选而不重复叠加。""" + counts.clear() + return { + "torrents": torrents, "mediainfo": mediainfo, "keyword": plan.keyword, + "rule_groups": plan.rule_groups, "season_episodes": season_episodes, + "custom_words": plan.custom_words, "filter_params": plan.filter_params, + "include_candidates": plan.include_candidates, "diagnostics": counts, + "candidate_filter": plan.candidate_filter, + } + + +def _search_resolution(owner: _SearchOwnerBase, plan: MediaSearchPlan) -> Generator[_SearchStep, Any, _SearchOutcome]: + """统一准备、换词、最终过滤和提前停止,音乐没有独立的搜索循环。""" + mediainfo = SearchPlanOwner._prepare_media_input(owner._copy_media_input(plan.mediainfo)) + logger.info(f"开始搜索资源,关键词:{plan.keyword or mediainfo.title} ...") + if SearchPlanOwner._needs_media_details(mediainfo): + mediainfo = yield _SearchStep("recognize", { + "mtype": mediainfo.type, **owner._media_recognize_kwargs(mediainfo), + }) + if not mediainfo: + return _SearchOutcome([], recognition_failed=True) + mediainfo = (yield _SearchStep("supplement", {"mediainfo": mediainfo})) or mediainfo + prepare_params = { + "mediainfo": mediainfo, "keyword": plan.keyword, "no_exists": plan.no_exists, + } + if plan.include_candidates: + prepare_params["include_candidates"] = True + season_episodes, keywords = owner._prepare_params(**prepare_params) + torrents: List[TorrentInfo] = [] + contexts: List[Context] = [] + counts: Counter[str] = Counter() + parsed = False + for index, keyword in enumerate(keywords): + batch = yield _SearchStep("search", { + "mediainfo": mediainfo, "keyword": keyword, "sites": plan.sites, "area": plan.area, + }, search_count=index) + if not batch: + continue + torrents.extend(batch) + contexts = yield _SearchStep("parse", _result_params(plan, mediainfo, torrents, season_episodes, counts)) + parsed = True + confirmed = any(getattr(context, "match_status", None) in (None, "exact") for context in contexts) + if confirmed and not owner.runtime_config.search_multiple_name: + logger.info(f"共搜索到 {len(contexts)} 个可用资源,停止搜索") + break + if not parsed: + contexts = yield _SearchStep("parse", _result_params(plan, mediainfo, torrents, season_episodes, counts)) + return _SearchOutcome(contexts, candidate_count=len(torrents), counts=dict(counts)) + + +def _candidate_contexts(mediainfo: MediaInfo | MusicInfo, torrents: List[TorrentInfo]) -> List[Context]: + """原始预览仅展示资源自身解析信息,不提前绑定未经匹配的目标媒体。""" + return [Context( + meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mediainfo.type), + torrent_info=torrent, resource_source="search", media_info_is_target=False, + match_status="candidate", match_reason="unverified", + ) for torrent in torrents] + + +class SearchExecutionOwner: + """通过相同的业务状态机驱动三种 I/O 模式,不按媒体类型重复实现。""" + + @staticmethod + def run(owner: _SearchOwnerBase, plan: MediaSearchPlan, media_chain: MediaChain) -> List[Context]: + """同步驱动统一状态机,网络请求和结果解析均在当前同步调用中执行。""" + flow = _search_resolution(owner, plan) + response: Any = None + while True: + try: + step = flow.send(response) + except StopIteration as completed: + outcome = cast(_SearchOutcome, completed.value) + SearchExecutionOwner._log_outcome(outcome) + return outcome.contexts + if step.kind == "recognize": + response = media_chain.recognize_media(**step.params) + elif step.kind == "supplement": + response = media_chain.supplement_media_info(**step.params) + elif step.kind == "search": + if step.search_count: + time.sleep(random.randint(1, 10)) + response = owner._SearchChain__search_all_sites(**step.params) or [] + else: + response = owner._parse_result(**step.params) + + @staticmethod + def _log_outcome(outcome: _SearchOutcome) -> None: + """以一致口径报告搜索失败或原始召回与最终保留数量。""" + if outcome.recognition_failed: + logger.error("媒体信息识别失败!") + else: + logger.info(f"搜索返回 {outcome.candidate_count} 个候选,保留 {len(outcome.contexts)} 个,过滤匹配统计:{outcome.counts}") + + @staticmethod + async def _provider_events(owner: _SearchOwnerBase, step: _SearchStep, + streaming: bool) -> AsyncIterator[Dict[str, Any]]: + """只在 I/O 适配层区分普通请求和站点事件流。""" + if step.search_count: + await asyncio.sleep(random.randint(1, 10)) + if streaming: + async for event in owner._SearchChain__async_search_all_sites_stream(**step.params): + yield event + else: + yield {"items": await owner._SearchChain__async_search_all_sites(**step.params) or []} + + @staticmethod + async def events(owner: _SearchOwnerBase, plan: MediaSearchPlan, media_chain: MediaChain, + streaming: bool = True) -> AsyncIterator[Dict[str, Any]]: + """异步和 SSE 复用同一驱动器,只有是否向调用方发送中间进度不同。""" + flow = _search_resolution(owner, plan) + response: Any = None + raw_count = 0 + while True: + try: + step = flow.send(response) + except StopIteration as completed: + for event in SearchExecutionOwner._completion_events(cast(_SearchOutcome, completed.value), streaming): + yield event + return + if step.kind == "recognize": + response = await media_chain.async_recognize_media(**step.params) + elif step.kind == "supplement": + response = await media_chain.async_supplement_media_info(**step.params) + elif step.kind == "search": + batch: List[TorrentInfo] = [] + async for event in SearchExecutionOwner._provider_events(owner, step, streaming): + items = event.pop("items", []) or [] + batch.extend(items) + raw_count += len(items) + if streaming: + previews = await run_in_threadpool(_candidate_contexts, step.params["mediainfo"], items) + yield { + **event, "type": "append", "stage": "searching", + "items": [context.to_dict() for context in previews], + "total_items": raw_count, "candidate_items": raw_count, + } + response = batch + else: + if streaming: + yield { + "type": "progress", "stage": "filtering", "value": 98, + "text": f"正在过滤匹配 {len(step.params['torrents'])} 个候选资源 ...", + } + response = await run_in_threadpool(owner._parse_result, **step.params) + @staticmethod + def _completion_events(outcome: _SearchOutcome, streaming: bool) -> Iterator[Dict[str, Any]]: + """只在状态机正常完成后统一发布结果,失败和提前关闭不会伪造完成状态。""" + SearchExecutionOwner._log_outcome(outcome) + if outcome.recognition_failed: + yield {"type": "error", "success": False, "message": "媒体信息识别失败"} + return + summary = { + "items": [context.to_dict() for context in outcome.contexts], + "total_items": len(outcome.contexts), "candidate_items": outcome.candidate_count, + "match_counts": outcome.counts, + } + if streaming: + yield { + **summary, "type": "replace", "stage": "filtered", "value": 100, + "text": f"过滤匹配完成,共 {len(outcome.contexts)} 个资源", + } + yield { + **summary, "type": "done", "stage": "done", "contexts": outcome.contexts, + "text": f"搜索完成,共 {len(outcome.contexts)} 个资源", + } diff --git a/app/chain/search/facade.py b/app/chain/search/facade.py index de58a1f71..8138ac464 100644 --- a/app/chain/search/facade.py +++ b/app/chain/search/facade.py @@ -20,7 +20,7 @@ from app.chain.search.result import SearchResultOwner from app.chain.search.site import SearchSiteOwner from app.chain.search.subtitle import SearchSubtitleOwner from app.chain.search.title import SearchTitleOwner -from app.domain.context import Context, MediaInfo, SubtitleInfo +from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo from app.runtime.events import Event, eventmanager from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import EventType, MediaSource, MediaType @@ -184,29 +184,8 @@ class SearchChain(ChainBase): results=results, ) - def search_by_id( - self, - media_source: MediaSource, - media_id: str, - mtype: Optional[MediaType] = None, - area: Optional[str] = "title", - season: Optional[int] = None, - sites: Optional[list[int]] = None, - cache_local: bool = False, - music_type: Optional[str] = None, - ) -> list[Context]: - """通过稳定 Facade 执行同步精确媒体搜索。""" - return SearchMediaOwner.search_by_id( - cast(SearchMediaOwner, self), - media_source=media_source, - media_id=media_id, - mtype=mtype, - area=area, - season=season, - sites=sites, - cache_local=cache_local, - music_type=music_type, - ) + # 复用 owner 的方法描述符和完整运行时签名,避免重复维护身份搜索参数。 + search_by_id = cast(Callable[..., list[Context]], SearchMediaOwner.search_by_id) def search_by_title( self, @@ -334,6 +313,7 @@ class SearchChain(ChainBase): sites: Optional[list[int]] = None, cache_local: bool = False, music_type: Optional[str] = None, + include_candidates: bool = False, ) -> list[Context]: """通过稳定 Facade 执行异步精确媒体搜索。""" return await SearchMediaOwner.async_search_by_id( @@ -346,6 +326,7 @@ class SearchChain(ChainBase): sites=sites, cache_local=cache_local, music_type=music_type, + include_candidates=include_candidates, ) async def async_search_by_title( @@ -400,6 +381,7 @@ class SearchChain(ChainBase): sites: Optional[list[int]] = None, cache_local: bool = False, music_type: Optional[str] = None, + include_candidates: bool = False, ) -> AsyncIterator[dict[str, Any]]: """通过稳定 Facade 流式执行精确媒体搜索。""" async for event in SearchMediaOwner.async_search_by_id_stream( @@ -412,6 +394,7 @@ class SearchChain(ChainBase): sites=sites, cache_local=cache_local, music_type=music_type, + include_candidates=include_candidates, ): yield event _prepare_params = staticmethod(SearchPlanOwner._prepare_params) @@ -426,7 +409,7 @@ class SearchChain(ChainBase): def process( self, - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None, sites: Optional[list[int]] = None, @@ -434,6 +417,8 @@ class SearchChain(ChainBase): area: Optional[str] = "title", custom_words: Optional[list[str]] = None, filter_params: Optional[dict[str, str]] = None, + include_candidates: bool = False, + candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None, ) -> list[Context]: """通过稳定 Facade 调用精确媒体搜索 owner,保留公开类型合同。""" return SearchMediaOwner.process( @@ -446,11 +431,13 @@ class SearchChain(ChainBase): area=area, custom_words=custom_words, filter_params=filter_params, + include_candidates=include_candidates, + candidate_filter=candidate_filter, ) async def async_process( self, - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None, sites: Optional[list[int]] = None, @@ -458,6 +445,8 @@ class SearchChain(ChainBase): area: Optional[str] = "title", custom_words: Optional[list[str]] = None, filter_params: Optional[dict[str, str]] = None, + include_candidates: bool = False, + candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None, ) -> list[Context]: """通过稳定 Facade 执行异步媒体搜索编排。""" return await SearchMediaOwner.async_process( @@ -470,11 +459,13 @@ class SearchChain(ChainBase): area=area, custom_words=custom_words, filter_params=filter_params, + include_candidates=include_candidates, + candidate_filter=candidate_filter, ) async def async_process_stream( self, - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None, sites: Optional[list[int]] = None, @@ -482,6 +473,8 @@ class SearchChain(ChainBase): area: Optional[str] = "title", custom_words: Optional[list[str]] = None, filter_params: Optional[dict[str, str]] = None, + include_candidates: bool = False, + candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None, ) -> AsyncIterator[dict[str, Any]]: """通过稳定 Facade 流式执行媒体搜索编排。""" async for event in SearchMediaOwner.async_process_stream( @@ -494,6 +487,8 @@ class SearchChain(ChainBase): area=area, custom_words=custom_words, filter_params=filter_params, + include_candidates=include_candidates, + candidate_filter=candidate_filter, ): yield event _build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes) diff --git a/app/chain/search/media.py b/app/chain/search/media.py index f6dff4ee5..c8c3629f5 100644 --- a/app/chain/search/media.py +++ b/app/chain/search/media.py @@ -1,13 +1,9 @@ """精确媒体搜索与同步异步编排 owner。""" -import asyncio -import random -import time from dataclasses import dataclass from typing import ( Any, AsyncIterator, - Awaitable, Callable, Dict, Generator, @@ -19,10 +15,9 @@ from typing import ( ) from app.chain.media import MediaChain -from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase -from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo -from app.domain.metainfo import MetaInfo -from app.runtime.execution import run_in_threadpool +from app.chain.search.contract import _SearchOwnerBase +from app.chain.search.execution import MediaSearchPlan, SearchExecutionOwner +from app.domain.context import Context, MediaInfo, MusicInfo from app.runtime.log import logger from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.mediaserver import NotExistMediaInfo @@ -40,6 +35,7 @@ def _build_id_search_params( season: Optional[int], sites: Optional[List[int]], music_type: Optional[str], + include_candidates: bool = False, ) -> Tuple[Dict[str, Any], Dict[str, Any]]: """构造同步、异步和流式 ID 搜索共享的识别与缓存参数。""" recognition_params = { @@ -54,6 +50,8 @@ def _build_id_search_params( "season": season, "sites": sites, } + if include_candidates: + cache_params["include_candidates"] = True return recognition_params, cache_params @@ -146,6 +144,8 @@ def _id_search_resolution( "sites": sites, "area": area, "no_exists": _build_missing_media_map(mediainfo, season), + **({"include_candidates": True} if isinstance(mediainfo, MusicInfo) + and cache_params.get("include_candidates") else {}), } )), ) @@ -154,294 +154,6 @@ def _id_search_resolution( return _IdSearchResult(contexts=contexts) -def _normalize_media_search_input(mediainfo: MediaInfo) -> MediaInfo: - """归一化非 TMDB 输入标题,并保留调用方对象由外层复制的所有权约束。""" - if not mediainfo.tmdb_id: - meta = MetaInfo(title=mediainfo.title) - mediainfo.title = meta.name - mediainfo.season = cast(int, meta.begin_season) - return mediainfo - - -def _should_stop_keyword_search( - search_multiple_name: bool, - torrents: List[TorrentInfo], -) -> bool: - """统一判断首个有效关键字结果是否终止后续搜索。""" - return not search_multiple_name and bool(torrents) - - -@dataclass(frozen=True, slots=True) -class _KeywordSearchRequest: - """描述共享关键字状态机交给真实 I/O 边界的一次请求。""" - - keyword: str - search_count: int - - -@dataclass(frozen=True, slots=True) -class _KeywordSearchResult: - """冻结关键字状态机聚合结果及其提前停止决策。""" - - torrents: List[TorrentInfo] - stopped_early: bool - - -_KeywordSearchResolution = Generator[ - _KeywordSearchRequest, List[TorrentInfo], _KeywordSearchResult -] - - -def _keyword_search_resolution( - keywords: List[str], - search_multiple_name: bool, -) -> _KeywordSearchResolution: - """统一推进关键字顺序、结果聚合和首个有效结果短路。""" - torrents: List[TorrentInfo] = [] - for search_count, keyword in enumerate(keywords): - torrents.extend( - (yield _KeywordSearchRequest( - keyword=keyword, - search_count=search_count, - )) - ) - if _should_stop_keyword_search(search_multiple_name, torrents): - return _KeywordSearchResult(torrents=torrents, stopped_early=True) - return _KeywordSearchResult(torrents=torrents, stopped_early=False) - - -def _run_keyword_search_sync( - resolution: _KeywordSearchResolution, - execute: Callable[[_KeywordSearchRequest], List[TorrentInfo]], -) -> _KeywordSearchResult: - """通过同步 provider 驱动共享关键字状态机。""" - try: - request = next(resolution) - except StopIteration as outcome: - return cast(_KeywordSearchResult, outcome.value) - while True: - try: - request = resolution.send(execute(request)) - except StopIteration as outcome: - return cast(_KeywordSearchResult, outcome.value) - - -async def _run_keyword_search_async( - resolution: _KeywordSearchResolution, - execute: Callable[[_KeywordSearchRequest], Awaitable[List[TorrentInfo]]], -) -> _KeywordSearchResult: - """通过异步 provider 驱动共享关键字状态机。""" - try: - request = next(resolution) - except StopIteration as outcome: - return cast(_KeywordSearchResult, outcome.value) - while True: - try: - request = resolution.send(await execute(request)) - except StopIteration as outcome: - return cast(_KeywordSearchResult, outcome.value) - - -@dataclass(frozen=True, slots=True) -class _MediaProcessPlan: - """冻结媒体资源处理入口的业务输入。""" - - mediainfo: MediaInfo - keyword: Optional[str] - no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] - sites: Optional[List[int]] - rule_groups: Optional[List[str]] - area: Optional[str] - custom_words: Optional[List[str]] - filter_params: Optional[Dict[str, str]] - - -@dataclass(frozen=True, slots=True) -class _MediaMusicProcessRequest: - """请求执行音乐资源搜索外壳。""" - - params: Dict[str, Any] - - -@dataclass(frozen=True, slots=True) -class _MediaRecognizeRequest: - """请求补齐缺失名称的媒体信息。""" - - params: Dict[str, Any] - - -@dataclass(frozen=True, slots=True) -class _MediaSupplementRequest: - """请求聚合已启用媒体来源的附加信息。""" - - mediainfo: MediaInfo - - -@dataclass(frozen=True, slots=True) -class _MediaKeywordProcessRequest: - """请求按共享关键字计划执行 provider I/O。""" - - mediainfo: MediaInfo - keywords: List[str] - sites: Optional[List[int]] - area: Optional[str] - search_multiple_name: bool - - -@dataclass(frozen=True, slots=True) -class _MediaParseRequest: - """请求在同步或线程池 CPU 外壳中解析搜索结果。""" - - params: Dict[str, Any] - - -@dataclass(frozen=True, slots=True) -class _MediaLogRequest: - """请求记录共享状态机决定的运行日志。""" - - message: str - - -@dataclass(frozen=True, slots=True) -class _MediaProcessResult: - """冻结媒体处理结果与识别失败状态。""" - - contexts: List[Context] - recognition_failed: bool = False - - -_MediaProcessRequest = Union[ - _MediaMusicProcessRequest, - _MediaRecognizeRequest, - _MediaSupplementRequest, - _MediaKeywordProcessRequest, - _MediaParseRequest, - _MediaLogRequest, -] -_MediaProcessResolution = Generator[ - _MediaProcessRequest, object, _MediaProcessResult -] - - -def _media_process_resolution( - plan: _MediaProcessPlan, - search_multiple_name: Callable[[], bool], - copy_media: Callable[[MediaInfo], MediaInfo], - recognize_kwargs: Callable[[MediaInfo], Dict[str, Any]], - prepare_params: Callable[..., Tuple[Optional[Dict[int, List[int]]], List[str]]], -) -> _MediaProcessResolution: - """统一媒体处理的类型路由、识别、补充、搜索和解析状态。""" - if plan.mediainfo.type == MediaType.MUSIC: - contexts = cast( - List[Context], - (yield _MediaMusicProcessRequest( - params={ - "mediainfo": cast(MusicInfo, plan.mediainfo), - "keyword": plan.keyword, - "sites": plan.sites, - "rule_groups": plan.rule_groups, - "filter_params": plan.filter_params, - } - )), - ) - return _MediaProcessResult(contexts=contexts) - - mediainfo = _normalize_media_search_input(copy_media(plan.mediainfo)) - yield _MediaLogRequest( - message=f"开始搜索资源,关键词:{plan.keyword or mediainfo.title} ..." - ) - if not mediainfo.names: - recognized_media = cast( - Optional[MediaInfo], - (yield _MediaRecognizeRequest( - params={ - "mtype": mediainfo.type, - **recognize_kwargs(mediainfo), - } - )), - ) - if not recognized_media: - return _MediaProcessResult(contexts=[], recognition_failed=True) - mediainfo = recognized_media - - mediainfo = cast( - Optional[MediaInfo], - (yield _MediaSupplementRequest(mediainfo=mediainfo)), - ) or mediainfo - season_episodes, keywords = prepare_params( - mediainfo=mediainfo, - keyword=plan.keyword, - no_exists=plan.no_exists, - ) - outcome = cast( - _KeywordSearchResult, - (yield _MediaKeywordProcessRequest( - mediainfo=mediainfo, - keywords=keywords, - sites=plan.sites, - area=plan.area, - search_multiple_name=search_multiple_name(), - )), - ) - if outcome.stopped_early: - yield _MediaLogRequest( - message=f"共搜索到 {len(outcome.torrents)} 个资源,停止搜索" - ) - contexts = cast( - List[Context], - (yield _MediaParseRequest( - params=_build_result_params( - torrents=outcome.torrents, - mediainfo=mediainfo, - keyword=plan.keyword, - rule_groups=plan.rule_groups, - season_episodes=season_episodes, - custom_words=plan.custom_words, - filter_params=plan.filter_params, - ) - )), - ) - return _MediaProcessResult(contexts=contexts) - - -def _build_result_params( - torrents: List[TorrentInfo], - mediainfo: MediaInfo, - keyword: Optional[str], - rule_groups: Optional[List[str]], - season_episodes: Optional[Dict[int, List[int]]], - custom_words: Optional[List[str]], - filter_params: Optional[Dict[str, str]], -) -> Dict[str, Any]: - """构造同步、异步和流式结果解析共享的参数快照。""" - return { - "torrents": torrents, - "mediainfo": mediainfo, - "keyword": keyword, - "rule_groups": rule_groups, - "season_episodes": season_episodes, - "custom_words": custom_words, - "filter_params": filter_params, - } - - -def _build_candidate_contexts( - mediainfo: MediaInfo, - torrents: List[TorrentInfo], -) -> List[Context]: - """将流式站点候选映射为尚未精确过滤的搜索上下文。""" - return [ - Context( - meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), - media_info=mediainfo, - torrent_info=torrent, - resource_source="search", - media_info_is_target=True, - ) - for torrent in torrents - ] - - class SearchMediaOwner(_SearchOwnerBase): """精确媒体搜索与同步异步编排 owner。""" @@ -499,6 +211,7 @@ class SearchMediaOwner(_SearchOwnerBase): sites: Optional[List[int]] = None, cache_local: bool = False, music_type: Optional[str] = None, + include_candidates: bool = False, ) -> List[Context]: """ 根据数据源媒体 ID 搜索资源,精确匹配,不过滤本地存在的资源 @@ -519,6 +232,7 @@ class SearchMediaOwner(_SearchOwnerBase): season=season, sites=sites, music_type=music_type, + include_candidates=include_candidates, ) result = SearchMediaOwner._run_id_search_sync( self, @@ -548,6 +262,7 @@ class SearchMediaOwner(_SearchOwnerBase): sites: Optional[List[int]] = None, cache_local: bool = False, music_type: Optional[str] = None, + include_candidates: bool = False, ) -> List[Context]: """ 根据数据源媒体 ID 异步搜索资源,精确匹配,不过滤本地存在的资源 @@ -568,6 +283,7 @@ class SearchMediaOwner(_SearchOwnerBase): season=season, sites=sites, music_type=music_type, + include_candidates=include_candidates, ) result = await SearchMediaOwner._run_id_search_async( self, @@ -597,6 +313,7 @@ class SearchMediaOwner(_SearchOwnerBase): sites: Optional[List[int]] = None, cache_local: bool = False, music_type: Optional[str] = None, + include_candidates: bool = False, ) -> AsyncIterator[Dict[str, Any]]: """ 根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果 @@ -609,6 +326,7 @@ class SearchMediaOwner(_SearchOwnerBase): season=season, sites=sites, music_type=music_type, + include_candidates=include_candidates, ) if cache_local: self.cancel_ai_recommend() @@ -623,7 +341,11 @@ class SearchMediaOwner(_SearchOwnerBase): no_exists = _build_missing_media_map(mediainfo, season) contexts: List[Context] = [] - async for event in self.async_process_stream(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists): + candidate_params: Dict[str, Any] = {"include_candidates": True} if include_candidates else {} + async for event in self.async_process_stream( + mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists, + **candidate_params, + ): if event.get("type") == "done": contexts = event.get("contexts") or [] event = {key: value for key, value in event.items() if key != "contexts"} @@ -632,335 +354,58 @@ class SearchMediaOwner(_SearchOwnerBase): if cache_local: await self._async_save_results(contexts) - def _run_media_process_sync( - self, resolution: _MediaProcessResolution - ) -> _MediaProcessResult: - """用同步 provider 与 CPU 外壳驱动共享媒体处理状态机。""" - response: object = None - while True: - try: - request = resolution.send(response) - except StopIteration as completed: - return cast(_MediaProcessResult, completed.value) - if isinstance(request, _MediaMusicProcessRequest): - response = self._process_music(**request.params) - elif isinstance(request, _MediaRecognizeRequest): - response = MediaChain().recognize_media(**request.params) - elif isinstance(request, _MediaSupplementRequest): - response = MediaChain().supplement_media_info(request.mediainfo) - elif isinstance(request, _MediaKeywordProcessRequest): - - def execute_search( - keyword_request: _KeywordSearchRequest, - ) -> List[TorrentInfo]: - """执行共享关键字请求的同步站点 I/O。""" - if keyword_request.search_count > 0: - logger.info( - f"已搜索 {keyword_request.search_count} 次," - "强制休眠 1-10 秒 ..." - ) - time.sleep(random.randint(1, 10)) - return ( - self._SearchChain__search_all_sites( - mediainfo=request.mediainfo, - keyword=keyword_request.keyword, - sites=request.sites, - area=request.area, - ) - or [] - ) - - response = _run_keyword_search_sync( - _keyword_search_resolution( - request.keywords, request.search_multiple_name - ), - execute_search, - ) - elif isinstance(request, _MediaParseRequest): - response = self._parse_result(**request.params) - else: - logger.info(request.message) - response = None - - async def _run_media_process_async( - self, resolution: _MediaProcessResolution - ) -> _MediaProcessResult: - """用异步 provider 与线程池 CPU 外壳驱动共享媒体处理状态机。""" - response: object = None - while True: - try: - request = resolution.send(response) - except StopIteration as completed: - return cast(_MediaProcessResult, completed.value) - if isinstance(request, _MediaMusicProcessRequest): - response = await self._async_process_music(**request.params) - elif isinstance(request, _MediaRecognizeRequest): - response = await MediaChain().async_recognize_media( - **request.params - ) - elif isinstance(request, _MediaSupplementRequest): - response = await MediaChain().async_supplement_media_info( - request.mediainfo - ) - elif isinstance(request, _MediaKeywordProcessRequest): - - async def execute_search( - keyword_request: _KeywordSearchRequest, - ) -> List[TorrentInfo]: - """执行共享关键字请求的异步站点 I/O。""" - if keyword_request.search_count > 0: - logger.info( - f"已搜索 {keyword_request.search_count} 次," - "强制休眠 1-10 秒 ..." - ) - await asyncio.sleep(random.randint(1, 10)) - return ( - await self._SearchChain__async_search_all_sites( - mediainfo=request.mediainfo, - keyword=keyword_request.keyword, - sites=request.sites, - area=request.area, - ) - or [] - ) - - response = await _run_keyword_search_async( - _keyword_search_resolution( - request.keywords, request.search_multiple_name - ), - execute_search, - ) - elif isinstance(request, _MediaParseRequest): - response = await run_in_threadpool( - self._parse_result, **request.params - ) - else: - logger.info(request.message) - response = None - def process( - self, - mediainfo: MediaInfo, - keyword: Optional[str] = None, + self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - area: Optional[str] = "title", - custom_words: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None, + area: Optional[str] = "title", custom_words: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False, + candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None, ) -> List[Context]: - """ - 根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源 - :param mediainfo: 媒体信息 - :param keyword: 搜索关键词 - :param no_exists: 缺失的媒体信息 - :param sites: 站点ID列表,为空时搜索所有站点 - :param rule_groups: 过滤规则组名称列表 - :param area: 搜索范围,title or imdbid - :param custom_words: 自定义识别词列表 - :param filter_params: 过滤参数 - """ - result = SearchMediaOwner._run_media_process_sync( - self, - _media_process_resolution( - plan=_MediaProcessPlan( - mediainfo=mediainfo, - keyword=keyword, - no_exists=no_exists, - sites=sites, - rule_groups=rule_groups, - area=area, - custom_words=custom_words, - filter_params=filter_params, - ), - search_multiple_name=( - lambda: self.runtime_config.search_multiple_name - ), - copy_media=self._copy_media_input, - recognize_kwargs=self._media_recognize_kwargs, - prepare_params=self._prepare_params, - ) + """通过所有媒体共用的状态机同步搜索,人工候选必须显式请求。""" + return SearchExecutionOwner.run( + self, MediaSearchPlan( + mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites, + rule_groups=rule_groups, area=area, custom_words=custom_words, + filter_params=filter_params, include_candidates=include_candidates, + candidate_filter=candidate_filter, + ), MediaChain(), ) - if result.recognition_failed: - logger.error("媒体信息识别失败!") - return result.contexts async def async_process( - self, - mediainfo: MediaInfo, - keyword: Optional[str] = None, + self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - area: Optional[str] = "title", - custom_words: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None, + area: Optional[str] = "title", custom_words: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False, + candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None, ) -> List[Context]: - """ - 根据媒体信息异步搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源 - :param mediainfo: 媒体信息 - :param keyword: 搜索关键词 - :param no_exists: 缺失的媒体信息 - :param sites: 站点ID列表,为空时搜索所有站点 - :param rule_groups: 过滤规则组名称列表 - :param area: 搜索范围,title or imdbid - :param custom_words: 自定义识别词列表 - :param filter_params: 过滤参数 - """ - result = await SearchMediaOwner._run_media_process_async( - self, - _media_process_resolution( - plan=_MediaProcessPlan( - mediainfo=mediainfo, - keyword=keyword, - no_exists=no_exists, - sites=sites, - rule_groups=rule_groups, - area=area, - custom_words=custom_words, - filter_params=filter_params, - ), - search_multiple_name=( - lambda: self.runtime_config.search_multiple_name - ), - copy_media=self._copy_media_input, - recognize_kwargs=self._media_recognize_kwargs, - prepare_params=self._prepare_params, - ) + """异步执行同一搜索状态机,站点查询使用非流式异步端口。""" + plan = MediaSearchPlan( + mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites, + rule_groups=rule_groups, area=area, custom_words=custom_words, + filter_params=filter_params, include_candidates=include_candidates, + candidate_filter=candidate_filter, ) - if result.recognition_failed: - logger.error("媒体信息识别失败!") - return result.contexts + async for event in SearchExecutionOwner.events(self, plan, MediaChain(), streaming=False): + if event["type"] == "done": + return cast(List[Context], event["contexts"]) + return [] async def async_process_stream( - self, - mediainfo: MediaInfo, - keyword: Optional[str] = None, + self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - area: Optional[str] = "title", - custom_words: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None, + area: Optional[str] = "title", custom_words: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False, + candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None, ) -> AsyncIterator[Dict[str, Any]]: - """ - 根据媒体信息渐进式搜索种子资源,先返回站点候选,再返回过滤匹配后的最终结果 - """ - - if mediainfo.type == MediaType.MUSIC: - async for event in self._async_process_music_stream( - mediainfo=cast(MusicInfo, mediainfo), - keyword=keyword, - sites=sites, - rule_groups=rule_groups, - filter_params=filter_params, - ): - yield event - return - - mediainfo = _normalize_media_search_input(self._copy_media_input(mediainfo)) - logger.info(f"开始渐进式搜索资源,关键词:{keyword or mediainfo.title} ...") - - # 补充媒体信息 - if not mediainfo.names: - recognized_media = await MediaChain().async_recognize_media( - mtype=mediainfo.type, - **self._media_recognize_kwargs(mediainfo), - ) - if not recognized_media: - logger.error("媒体信息识别失败!") - yield {"type": "error", "success": False, "message": "媒体信息识别失败"} - return - mediainfo = recognized_media - - mediainfo = cast( - MediaInfo, - await MediaChain().async_supplement_media_info(mediainfo) or mediainfo, + """逐站点发布统一搜索进度,并以同一结果处理器完成过滤与匹配。""" + plan = MediaSearchPlan( + mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites, + rule_groups=rule_groups, area=area, custom_words=custom_words, + filter_params=filter_params, include_candidates=include_candidates, + candidate_filter=candidate_filter, ) - - # 准备搜索参数 - season_episodes, keywords = self._prepare_params(mediainfo=mediainfo, keyword=keyword, no_exists=no_exists) - - candidate_contexts: List[Context] = [] - resolution = _keyword_search_resolution( - keywords, self.runtime_config.search_multiple_name - ) - outcome = _KeywordSearchResult(torrents=[], stopped_early=False) - try: - request = next(resolution) - except StopIteration as completed: - outcome = cast(_KeywordSearchResult, completed.value) - else: - while True: - if request.search_count > 0: - logger.info( - f"已搜索 {request.search_count} 次,强制休眠 1-10 秒 ..." - ) - await asyncio.sleep(random.randint(1, 10)) - search_results: List[TorrentInfo] = [] - async for event in self._SearchChain__async_search_all_sites_stream( - mediainfo=mediainfo, - keyword=request.keyword, - sites=sites, - area=area, - ): - result = event.pop("items", []) or [] - search_results.extend(result) - batch_contexts = _build_candidate_contexts(mediainfo, result) - candidate_contexts.extend(batch_contexts) - yield { - **event, - "type": "append", - "stage": "searching", - "items": [ - cast(Any, context).to_dict() - for context in batch_contexts - ], - "total_items": len(candidate_contexts), - } - try: - request = resolution.send(search_results) - except StopIteration as completed: - outcome = cast(_KeywordSearchResult, completed.value) - break - if outcome.stopped_early: - logger.info(f"共搜索到 {len(outcome.torrents)} 个资源,停止搜索") - - yield { - "type": "progress", - "stage": "filtering", - "value": 98, - "text": f"正在过滤匹配 {len(outcome.torrents)} 个候选资源 ...", - } - - contexts = await run_in_threadpool( - self._parse_result, - **_build_result_params( - torrents=outcome.torrents, - mediainfo=mediainfo, - keyword=keyword, - rule_groups=rule_groups, - season_episodes=season_episodes, - custom_words=custom_words, - filter_params=filter_params, - ), - ) - final_items = [context.to_dict() for context in contexts] - yield { - "type": "replace", - "stage": "filtered", - "value": 100, - "text": f"过滤匹配完成,共 {len(contexts)} 个资源", - "items": final_items, - "total_items": len(contexts), - "candidate_items": len(candidate_contexts), - } - yield { - "type": "done", - "stage": "done", - "text": f"搜索完成,共 {len(contexts)} 个资源", - "items": final_items, - "total_items": len(contexts), - "candidate_items": len(candidate_contexts), - "contexts": contexts, - } + async for event in SearchExecutionOwner.events(self, plan, MediaChain()): + yield event diff --git a/app/chain/search/music.py b/app/chain/search/music.py index 86872a496..3af1b2f0c 100644 --- a/app/chain/search/music.py +++ b/app/chain/search/music.py @@ -1,307 +1,130 @@ """音乐关键词、资源匹配与搜索编排 owner。""" -import asyncio -import random -import re -import time -from typing import Any, AsyncIterator, Callable, Dict, Iterable, List, Optional, cast -from unicodedata import normalize +import copy +from collections import Counter +from typing import Any, AsyncIterator, Dict, Iterable, List, Optional -from app.application.configuration import ( - get_configured_system_config, -) -from app.application.torrent.download import TorrentHelper -from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase +from app.chain.search.contract import _SearchOwnerBase from app.domain.context import Context, MusicInfo, TorrentInfo from app.domain.meta.metamusic import MetaMusic -from app.foundation.text import convert as zhconv_convert -from app.runtime.execution import run_in_threadpool -from app.schemas.types import ( - MUSIC_ENTITY_ALBUM, - MediaType, - SystemConfigKey, +from app.domain.music import ( + match_music_resource, + music_artists, + music_base_title, + music_text_key, + music_titles, + unique_music_texts, ) +from app.foundation.text import convert as zhconv_convert +from app.schemas.types import MUSIC_ENTITY_ALBUM + +_MAX_MUSIC_KEYWORDS = 12 class SearchMusicOwner(_SearchOwnerBase): - """音乐关键词、资源匹配与搜索编排 owner。""" + """音乐搜索共用严格身份规则,人工候选通过显式参数单独开放。""" @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([album, f"{artist} {album}" if artist and album else None]) - else: - values.extend( - [ - music.title, - f"{artist} {music.title}" if artist and music.title else None, - ] - ) + """依次查询主名称、艺术家组合和可信别名,保留原文且不单搜艺术家。""" + info = MusicInfo.from_meta(music) if isinstance(music, MetaMusic) else music + artists = music_artists(info) + artist = info.artists[0] if info.artists else info.album_artist + titles = music_titles(info) + values: list[Optional[str]] = list(titles) + for title in titles: + values.append(f"{artist} {title}" if artist else None) + for title in titles: + base = music_base_title(title) + if base != title: + values.extend([base, f"{artist} {base}" if artist else None]) + if titles: + values.extend(f"{alias} {titles[0]}" for alias in artists if alias != artist) search_values: list[Optional[str]] = [] for value in values: - search_values.extend( - [ - zhconv_convert(value, "zh-hans") if value else None, - value, - ] - ) - return cls._unique_music_texts(search_values) + search_values.extend([zhconv_convert(value, "zh-hans") if value else None, value]) + return cls._unique_music_texts(search_values)[:_MAX_MUSIC_KEYWORDS] @classmethod def matches_music_resource( - cls, - music: MusicInfo, - resource_title: str, - resource_description: Optional[str] = None, + cls, music: MusicInfo, resource_title: str, resource_description: Optional[str] = None, ) -> bool: - """校验标题与副标题同时命中目标专辑/曲名和艺术家。""" - normalized_resource = cls._normalize_music_match_text(f"{resource_title or ''} {resource_description or ''}") - if not normalized_resource: - return False - if music.music_type == MUSIC_ENTITY_ALBUM: - candidates = cls._unique_music_texts( - [ - music.album or music.title, - *(music.names or []), - ] - ) - else: - candidates = cls._unique_music_texts([music.title]) - 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 []), - ] - ) - normalized_artists = [cls._normalize_music_match_text(artist) for artist in artists] - return bool(normalized_artists) and any( - artist in normalized_resource for artist in normalized_artists if artist - ) + """只返回可用于自动订阅的精确身份命中,不放行待确认或关联专辑。""" + return match_music_resource(music, resource_title, resource_description).status == "exact" @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 str(zhconv_convert(compact_text, "zh-hans")) + """保留公开门面的名称归一化契约。""" + return music_text_key(value) @staticmethod def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]: - """按清理后的文本去重,并保留站点搜索词原始顺序。""" - results: list[str] = [] - seen: set[str] = set() - for value in values: - normalized = re.sub(r"\s+", " ", str(value or "")).strip() - identity = normalized.casefold() - if not normalized or identity in seen: - continue - seen.add(identity) - results.append(normalized) - return results + """保留公开门面的有序关键词去重契约。""" + return unique_music_texts(values) + + @staticmethod + def _music_keywords(mediainfo: MusicInfo, keyword: Optional[str], include_candidates: bool) -> list[str]: + """人工单曲搜索在严格关键词之后追加所属专辑,仍由匹配结果区分实体。""" + if keyword: + return [keyword] + keywords = SearchMusicOwner.music_site_keywords(mediainfo) + if include_candidates and mediainfo.music_type != MUSIC_ENTITY_ALBUM and mediainfo.album: + album = copy.copy(mediainfo) + album.music_type = MUSIC_ENTITY_ALBUM + album.title = mediainfo.album + album.title_aliases = list(mediainfo.album_aliases or []) + album.names = [] + keywords.extend(SearchMusicOwner.music_site_keywords(album)) + return unique_music_texts(keywords) def _build_music_contexts( - self, - torrents: List[TorrentInfo], - mediainfo: MusicInfo, - rule_groups: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + self, torrents: List[TorrentInfo], mediainfo: MusicInfo, + rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None, + include_candidates: bool = False, diagnostics: Optional[Counter[str]] = None, ) -> List[Context]: - """过滤音乐分类资源并组装携带目标音乐身份的下载上下文。""" - torrents = self._matching_music_torrents(torrents, mediainfo) - if filter_params: - torrents = [torrent for torrent in torrents if TorrentHelper.filter_torrent(torrent, filter_params)] - if rule_groups is None: - rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or [] - if rule_groups and torrents: - filter_torrents = cast( - Callable[..., List[TorrentInfo]], - self.filter_torrents, - ) - torrents = ( - filter_torrents( - rule_groups=rule_groups, - torrent_list=torrents, - mediainfo=mediainfo, - ) - or [] - ) - - contexts: List[Context] = [] - for torrent in torrents: - meta = MetaMusic.from_music_info(mediainfo) - meta.org_string = torrent.title - meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) - contexts.append( - Context( - torrent_info=torrent, - media_info=mediainfo, - meta_info=meta, - resource_source="search", - match_source=str(mediainfo.media_source or "title"), - candidate_recognized=False, - media_info_is_target=True, - ) - ) - return cast( - List[Context], - self._remove_duplicate(TorrentHelper.sort_torrents(contexts)), + """保留音乐兼容入口,全部结果交给通用资源过滤、匹配和上下文构造流程。""" + return self._parse_result( + torrents=torrents, mediainfo=mediainfo, rule_groups=rule_groups, + filter_params=filter_params, include_candidates=include_candidates, diagnostics=diagnostics, ) @staticmethod - def _matching_music_torrents( - torrents: Optional[List[TorrentInfo]], - mediainfo: MusicInfo, - ) -> List[TorrentInfo]: - """筛出音乐分类且标题、副标题匹配目标名称与艺术家的站点资源。""" - return [ - torrent - for torrent in torrents or [] - if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value) - and SearchMusicOwner.matches_music_resource( - mediainfo, - torrent.title, - torrent.description, - ) - ] + def _matching_music_torrents(torrents: Optional[List[TorrentInfo]], mediainfo: MusicInfo) -> List[TorrentInfo]: + """保留严格音乐候选筛选入口;底层匹配消费资源解析结果。""" + return [torrent for torrent in torrents or [] if match_music_resource( + mediainfo, torrent.title, torrent.description, torrent.category, + ).status == "exact"] def _process_music( - self, - mediainfo: MusicInfo, - keyword: Optional[str] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None, + rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None, + include_candidates: bool = False, ) -> List[Context]: - """按音乐元数据生成站点关键词并执行同步资源搜索。""" - keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo) - torrents: List[TorrentInfo] = [] - for index, search_word in enumerate(keywords or [mediainfo.title]): - if index: - time.sleep(random.randint(1, 10)) - matched_torrents = self._matching_music_torrents( - self._SearchChain__search_all_sites( - keyword=search_word, - mediainfo=mediainfo, - sites=sites, - mtype=MediaType.MUSIC, - ), - mediainfo, - ) - torrents.extend(matched_torrents) - if matched_torrents and not self.runtime_config.search_multiple_name: - break - return self._build_music_contexts( - torrents=torrents, - mediainfo=mediainfo, - rule_groups=rule_groups, - filter_params=filter_params, + """兼容同步音乐入口,不再拥有独立站点查询和停止循环。""" + return self.process( + mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups, + filter_params=filter_params, include_candidates=include_candidates, ) async def _async_process_music( - self, - mediainfo: MusicInfo, - keyword: Optional[str] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None, + rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None, + include_candidates: bool = False, ) -> List[Context]: - """按音乐元数据生成站点关键词并执行异步资源搜索。""" - keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo) - torrents: List[TorrentInfo] = [] - for index, search_word in enumerate(keywords or [mediainfo.title]): - if index: - await asyncio.sleep(random.randint(1, 10)) - matched_torrents = self._matching_music_torrents( - await self._SearchChain__async_search_all_sites( - keyword=search_word, - mediainfo=mediainfo, - sites=sites, - mtype=MediaType.MUSIC, - ), - mediainfo, - ) - torrents.extend(matched_torrents) - if matched_torrents and not self.runtime_config.search_multiple_name: - break - return cast( - List[Context], - await run_in_threadpool( - self._build_music_contexts, - torrents=torrents, - mediainfo=mediainfo, - rule_groups=rule_groups, - filter_params=filter_params, - ), + """兼容异步音乐入口,复用所有媒体共用的异步搜索状态机。""" + return await self.async_process( + mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups, + filter_params=filter_params, include_candidates=include_candidates, ) async def _async_process_music_stream( - self, - mediainfo: MusicInfo, - keyword: Optional[str] = None, - sites: Optional[List[int]] = None, - rule_groups: Optional[List[str]] = None, - filter_params: Optional[Dict[str, str]] = None, + self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None, + rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None, + include_candidates: bool = False, ) -> AsyncIterator[Dict[str, Any]]: - """ - 按音乐元数据渐进式搜索资源,逐站点输出进度并在结束时返回过滤后的完整结果。 - - 音乐候选需要同时匹配名称、艺术家和音乐分类,因此站点批次只负责推进搜索进度, - 最终结果仍统一交给音乐上下文构造逻辑过滤、排序和去重。 - """ - keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo) - torrents: List[TorrentInfo] = [] - for index, search_word in enumerate(keywords or [mediainfo.title]): - if index: - await asyncio.sleep(random.randint(1, 10)) - keyword_matched = False - async for event in self._SearchChain__async_search_all_sites_stream( - keyword=search_word, mediainfo=mediainfo, sites=sites, mtype=MediaType.MUSIC - ): - result = event.pop("items", []) or [] - matched_torrents = self._matching_music_torrents(result, mediainfo) - if matched_torrents: - keyword_matched = True - torrents.extend(matched_torrents) - yield { - **event, - "type": "append", - "items": [], - "total_items": len(torrents), - } - if keyword_matched and not self.runtime_config.search_multiple_name: - break - - contexts = await run_in_threadpool( - self._build_music_contexts, - torrents=torrents, - mediainfo=mediainfo, - rule_groups=rule_groups, - filter_params=filter_params, - ) - items = [context.to_dict() for context in contexts] - yield { - "type": "replace", - "stage": "filtered", - "value": 100, - "text": f"过滤匹配完成,共 {len(contexts)} 个资源", - "items": items, - "total_items": len(contexts), - "candidate_items": len(torrents), - } - yield { - "type": "done", - "stage": "done", - "text": f"搜索完成,共 {len(contexts)} 个资源", - "items": items, - "total_items": len(contexts), - "candidate_items": len(torrents), - "contexts": contexts, - } + """兼容音乐 SSE 入口,复用相同的候选预览、过滤、统计及完成事件。""" + async for event in self.async_process_stream( + mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups, + filter_params=filter_params, include_candidates=include_candidates, + ): + yield event diff --git a/app/chain/search/plan.py b/app/chain/search/plan.py index 63bb2fe43..9aa5cfb50 100644 --- a/app/chain/search/plan.py +++ b/app/chain/search/plan.py @@ -1,13 +1,15 @@ """搜索身份、关键词和缺集计划 owner。""" from copy import deepcopy -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union, cast from app.application.configuration import ( get_chain_runtime_config_snapshot, ) from app.chain.search.contract import _SearchOwnerBase -from app.domain.context import MediaInfo +from app.chain.search.music import SearchMusicOwner +from app.domain.context import MediaInfo, MusicInfo +from app.domain.metainfo import MetaInfo from app.schemas.media import build_media_key, resolve_media_identity from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import ( @@ -19,6 +21,12 @@ SeasonEpisodes = Dict[int, List[int]] RecognitionArgs = Dict[str, Optional[Union[MediaSource, str]]] +def _limit_search_names(keywords: List[str], explicit_keyword: Optional[str]) -> List[str]: + """统一遵守用户设置的名称查询上限,显式关键词不受名称展开策略影响。""" + max_names = get_chain_runtime_config_snapshot().max_search_name_limit + return keywords[:max_names] if max_names and not explicit_keyword else keywords + + class SearchPlanOwner(_SearchOwnerBase): """搜索身份、关键词和缺集计划 owner。""" @@ -40,19 +48,36 @@ class SearchPlanOwner(_SearchOwnerBase): } @staticmethod - def _copy_media_input(mediainfo: MediaInfo) -> MediaInfo: + def _copy_media_input(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo: """复制调用方媒体快照,避免搜索归一化污染共享领域对象。""" return deepcopy(mediainfo) + @staticmethod + def _prepare_media_input(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo: + """只规范化影视输入的标题和季号,音乐保留其实体字段及原始名称。""" + if not isinstance(mediainfo, MusicInfo) and not mediainfo.tmdb_id: + meta = MetaInfo(title=mediainfo.title) + mediainfo.title = meta.name + mediainfo.season = cast(int, meta.begin_season) + return mediainfo + + @staticmethod + def _needs_media_details(mediainfo: MediaInfo | MusicInfo) -> bool: + """音乐已有主名称即可匹配,影视别名为空时仍保留既有详情补全策略。""" + return not mediainfo.names and not (isinstance(mediainfo, MusicInfo) and mediainfo.title) + @staticmethod def _prepare_params( - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, no_exists: Optional[MissingMediaMap] = None, + include_candidates: bool = False, ) -> Tuple[Optional[SeasonEpisodes], List[str]]: """ 准备搜索参数 """ + if isinstance(mediainfo, MusicInfo): + return None, _limit_search_names(SearchMusicOwner._music_keywords(mediainfo, keyword, include_candidates), keyword) # 缺失的季集 media_source, media_id = resolve_media_identity(media=mediainfo) mediakey = build_media_key(media_source, media_id) @@ -87,9 +112,4 @@ class SearchPlanOwner(_SearchOwnerBase): ] ) ) - # 限制搜索关键词数量 - max_names = get_chain_runtime_config_snapshot().max_search_name_limit - if max_names: - keywords = keywords[:max_names] - - return season_episodes, keywords + return season_episodes, _limit_search_names(keywords, keyword) diff --git a/app/chain/search/result.py b/app/chain/search/result.py index 6740b7ce3..827c70161 100644 --- a/app/chain/search/result.py +++ b/app/chain/search/result.py @@ -1,15 +1,19 @@ """资源过滤、匹配、投影与去重 owner。""" import copy +from collections import Counter +from dataclasses import dataclass from typing import Callable, Dict, List, Optional, Tuple, cast from app.application.configuration import get_configured_system_config from app.application.torrent.download import TorrentHelper from app.chain.media import MediaChain from app.chain.search.contract import _SearchOwnerBase -from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo +from app.domain.music import MusicMatch, match_music_resource from app.runtime.log import logger from app.runtime.progress import ProgressHelper from app.runtime.stop import runtime_stop_state @@ -17,10 +21,19 @@ from app.schemas.media import resolve_media_identity from app.schemas.types import MediaSource, ProgressKey, SystemConfigKey SiteKey = Tuple[Optional[int], Optional[str]] -MatchedTorrent = Tuple[TorrentInfo, MetaBase, str] DisambiguationCache = Dict[Tuple[str, str, str], Optional[MediaInfo]] +@dataclass(frozen=True, slots=True) +class MatchedTorrent: + """保存资源解析证据和身份匹配结果,不从目标媒体反向生成元数据。""" + + torrent: TorrentInfo + meta: MetaBase + source: str + music_match: Optional[MusicMatch] = None + + def _site_torrents(torrents: List[TorrentInfo]) -> Dict[SiteKey, List[TorrentInfo]]: """按站点归集资源,并保留站点及资源的首次出现顺序。""" grouped: Dict[SiteKey, List[TorrentInfo]] = {} @@ -32,16 +45,19 @@ def _site_torrents(torrents: List[TorrentInfo]) -> Dict[SiteKey, List[TorrentInf def _filter_site_torrents( owner: _SearchOwnerBase, torrents: List[TorrentInfo], - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, rule_groups: List[str], filter_params: Dict[str, str], + diagnostics: Counter[str], ) -> List[TorrentInfo]: """按一个站点执行附加参数和优先级规则过滤。""" filtered = torrents if filter_params: helper = cast(Callable[[], TorrentHelper], TorrentHelper)() filtered = [torrent for torrent in filtered if helper.filter_torrent(torrent, filter_params)] + diagnostics["filter_params"] += len(torrents) - len(filtered) if rule_groups and filtered: + count = len(filtered) filtered = ( owner.filter_torrents( rule_groups=rule_groups, @@ -50,16 +66,18 @@ def _filter_site_torrents( ) or [] ) + diagnostics["filter_rules"] += count - len(filtered) return filtered def _filter_torrents( owner: _SearchOwnerBase, torrents: List[TorrentInfo], - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, rule_groups: List[str], filter_params: Dict[str, str], progress: ProgressHelper, + diagnostics: Counter[str], ) -> List[TorrentInfo]: """逐站点过滤资源,避免在调用方工作线程内再创建线程池。""" if not filter_params and not rule_groups: @@ -77,6 +95,7 @@ def _filter_torrents( mediainfo=mediainfo, rule_groups=rule_groups, filter_params=filter_params, + diagnostics=diagnostics, ) ) progress.update( @@ -86,12 +105,13 @@ def _filter_torrents( return [torrent for torrent in torrents if id(torrent) in retained_ids] -def _torrent_meta(torrent: TorrentInfo, custom_words: List[str]) -> MetaBase: +def _torrent_meta(torrent: TorrentInfo, custom_words: List[str], mediainfo: MediaInfo | MusicInfo) -> MetaBase: """解析一条资源的元数据,并记录识别词改写结果。""" meta = MetaInfo( title=torrent.title, subtitle=torrent.description, custom_words=custom_words, + mtype=mediainfo.type, ) if torrent.title != meta.org_string: logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}") @@ -160,13 +180,15 @@ def _match_source( def _match_torrents( torrents: List[TorrentInfo], - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, season_episodes: Dict[int, List[int]], custom_words: List[str], progress: ProgressHelper, + include_candidates: bool, + diagnostics: Counter[str], ) -> List[MatchedTorrent]: """按输入顺序匹配资源,并复用同名候选的识别结果。""" - logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}") + logger.info(f"开始匹配结果 类型:{mediainfo.type.value},标题:{mediainfo.title},别名:{mediainfo.names}") progress.update(value=51, text=f"开始匹配,总 {len(torrents)} 个资源 ...") matches: List[MatchedTorrent] = [] cache: DisambiguationCache = {} @@ -179,13 +201,26 @@ def _match_torrents( text=f"正在匹配 {torrent.site_name},已完成 {count} / {total} ...", ) if not torrent.title: + diagnostics["title_missing"] += 1 continue - meta = _torrent_meta(torrent=torrent, custom_words=custom_words) + meta = _torrent_meta(torrent=torrent, custom_words=custom_words, mediainfo=mediainfo) if season_episodes and not TorrentHelper.match_season_episodes( torrent=torrent, meta=meta, season_episodes=season_episodes, ): + diagnostics["scope_mismatch"] += 1 + continue + if isinstance(mediainfo, MusicInfo): + match = match_music_resource( + mediainfo, torrent.title, torrent.description, torrent.category, + meta=cast(MetaMusic, meta), + ) + diagnostics[match.reason] += 1 + if match.status == "exact" or (include_candidates and match.status != "rejected"): + matches.append(MatchedTorrent(torrent, meta, "title", match)) + else: + logger.debug(f"音乐资源 {torrent.site_name} - {torrent.title} 未通过匹配:{match.reason}") continue source = _match_source( torrent=torrent, @@ -194,33 +229,38 @@ def _match_torrents( cache=cache, ) if source: - matches.append((torrent, meta, source)) + diagnostics["matched"] += 1 + matches.append(MatchedTorrent(torrent, meta, source)) + else: + diagnostics["identity_mismatch"] += 1 logger.info(f"匹配完成,共匹配到 {len(matches)} 个资源") progress.update(value=97, text=f"匹配完成,共匹配到 {len(matches)} 个资源") return matches -def _context_media(mediainfo: MediaInfo) -> MediaInfo: +def _context_media(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo: """复制并裁剪上下文媒体信息,避免修改调用方持有的目标对象。""" context_media = copy.copy(mediainfo) context_media.clear() return context_media -def _build_contexts(matches: List[MatchedTorrent], mediainfo: MediaInfo) -> List[Context]: +def _build_contexts(matches: List[MatchedTorrent], mediainfo: MediaInfo | MusicInfo) -> List[Context]: """将匹配结果投影为搜索上下文。""" context_media = _context_media(mediainfo) return [ Context( - torrent_info=torrent, - media_info=context_media, - meta_info=meta, + torrent_info=match.torrent, + media_info=context_media if match.music_match is None or match.music_match.status == "exact" else None, + meta_info=match.meta, resource_source="search", - match_source=source, + match_source=match.source, candidate_recognized=False, - media_info_is_target=True, + media_info_is_target=match.music_match is None or match.music_match.status == "exact", + match_status="exact" if match.music_match is None or match.music_match.status == "exact" else "candidate", + match_reason=match.music_match.reason if match.music_match else "matched", ) - for torrent, meta, source in matches + for match in matches ] @@ -230,19 +270,23 @@ class SearchResultOwner(_SearchOwnerBase): def _parse_result( self, torrents: List[TorrentInfo], - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None, rule_groups: Optional[List[str]] = None, season_episodes: Optional[Dict[int, List[int]]] = None, custom_words: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None, + include_candidates: bool = False, + diagnostics: Optional[Counter[str]] = None, + candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None, ) -> List[Context]: """过滤并匹配搜索结果,不修改调用方持有的媒体信息和资源容器。""" if not torrents: logger.warning(f"{keyword or mediainfo.title} 未搜索到资源") return [] - source_torrents = list(torrents) + source_torrents = [copy.copy(torrent) for torrent in torrents] + counts = diagnostics if diagnostics is not None else Counter() effective_rules = rule_groups if effective_rules is None: effective_rules = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) @@ -262,6 +306,7 @@ class SearchResultOwner(_SearchOwnerBase): rule_groups=effective_rules or [], filter_params=filter_params or {}, progress=progress, + diagnostics=counts, ) if effective_rules and not filtered: logger.warning(f"{keyword or mediainfo.title} 没有符合过滤规则的资源") @@ -276,10 +321,17 @@ class SearchResultOwner(_SearchOwnerBase): season_episodes=season_episodes or {}, custom_words=custom_words or [], progress=progress, + include_candidates=include_candidates, + diagnostics=counts, ) contexts = _build_contexts(matches=matches, mediainfo=mediainfo) + if candidate_filter is not None: + before_filter = len(contexts) + contexts = candidate_filter(contexts) + counts["caller_filter"] += before_filter - len(contexts) progress.update(value=99, text=f"正在对 {len(contexts)} 个资源进行排序,请稍候...") contexts = TorrentHelper.sort_torrents(contexts) + contexts.sort(key=lambda context: not context.media_info_is_target) contexts = self._remove_duplicate(contexts) logger.info(f"搜索完成,共 {len(contexts)} 个资源") progress.update(value=100, text=f"搜索完成,共 {len(contexts)} 个资源") diff --git a/app/chain/search/title.py b/app/chain/search/title.py index 7dc90a3a9..ede09a257 100644 --- a/app/chain/search/title.py +++ b/app/chain/search/title.py @@ -19,7 +19,6 @@ from app.application.configuration import ( ) from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase from app.domain.context import Context, TorrentInfo -from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.runtime.execution import run_in_threadpool from app.runtime.log import logger @@ -415,14 +414,7 @@ class SearchTitleOwner(_SearchOwnerBase): mtype: Optional[MediaType], ) -> Any: """根据限定媒体类型构造模糊搜索结果的上下文元数据。""" - if mtype == MediaType.MUSIC: - meta = MetaMusic( - org_string=torrent.title, - title=torrent.title, - ) - meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}") - return meta - return MetaInfo(title=torrent.title, subtitle=torrent.description) + return MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mtype) def _filter_title_search_torrents( self, torrents: List[TorrentInfo], rule_groups: Optional[List[str]] = None diff --git a/app/chain/subscribe/contract.py b/app/chain/subscribe/contract.py index 5c52772fe..5046da18d 100644 --- a/app/chain/subscribe/contract.py +++ b/app/chain/subscribe/contract.py @@ -93,6 +93,8 @@ if TYPE_CHECKING: """异步发送订阅通知。""" raise NotImplementedError check_and_handle_existing_media: Callable[..., Any] + _prepare_music_subscribe: Callable[..., Any] + _download_music_subscribe: Callable[..., Any] check_and_reconcile: Callable[..., Any] filter_torrents: Callable[..., Any] finish_subscribe_or_not: Callable[..., Any] diff --git a/app/chain/subscribe/metadata.py b/app/chain/subscribe/metadata.py index b6440bbe3..25bded591 100644 --- a/app/chain/subscribe/metadata.py +++ b/app/chain/subscribe/metadata.py @@ -1,6 +1,7 @@ """订阅单条元数据刷新与完成对账协作者。""" from collections.abc import Callable +from dataclasses import dataclass from typing import Any, Optional, TypeVar, cast from app.application.classification.reference import ( @@ -16,13 +17,57 @@ from app.chain.media import MediaChain from app.chain.subscribe.contract import _SubscribeOwnerBase from app.chain.subscribe.identity import subscribe_recognize_kwargs from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metabase import MetaBase from app.runtime.log import logger from app.schemas.media import resolve_media_identity +from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType SubscriptionMediaT = TypeVar("SubscriptionMediaT", MediaInfo, MusicInfo) +@dataclass(frozen=True, slots=True) +class SubscriptionSearchTarget: + """所有媒体订阅进入同一搜索编排前的目标和缺失范围快照。""" + + subscribe: SubscriptionSnapshot + meta: MetaBase + media: MediaInfo | MusicInfo + missing: dict[str, dict[int, NotExistMediaInfo]] + media_key: Optional[str | int] + + +def prepare_search_target(owner: _SubscribeOwnerBase, subscribe: SubscriptionSnapshot, + media_chain: MediaChain, ensure_active: Callable[[], None]) -> Optional[SubscriptionSearchTarget]: + """在准备阶段保留实体差异,之后统一复用搜索、预算和交付流程。""" + if subscribe.type == MediaType.MUSIC.value: + target = owner._prepare_music_subscribe(subscribe) + if not target: + return None + current, media, meta = target + ensure_active() + return SubscriptionSearchTarget(current, meta, media, {}, subscribe_media_key(current)) + try: + meta = build_subscribe_meta(subscribe) + except ValueError: + logger.error(f"订阅《{subscribe.name}》的媒体类型不受支持,暂时无法搜索") + return None + media = media_chain.recognize_media( + meta=meta, mtype=meta.type, **subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, cache=False, + ) + if not media: + logger.warning(f"未识别到媒体信息,标题:{subscribe.name},媒体来源:{subscribe.media_source},媒体 ID:{subscribe.media_id}") + return None + ensure_active() + media = apply_subscription_classification(media, subscribe) + key = subscribe_media_key(subscribe) + exists, missing = owner.check_and_handle_existing_media( + subscribe=subscribe, meta=meta, mediainfo=media, mediakey=key, + ) + return None if exists else SubscriptionSearchTarget(subscribe, meta, media, missing, key) + + def apply_subscription_classification( media: SubscriptionMediaT, subscribe: SubscriptionSnapshot, diff --git a/app/chain/subscribe/search.py b/app/chain/subscribe/search.py index b9ebde639..4ec69d975 100644 --- a/app/chain/subscribe/search.py +++ b/app/chain/subscribe/search.py @@ -11,8 +11,6 @@ from app.application.configuration import get_configured_system_config from app.application.subscription.contract import ( SubscriptionRepository, SubscriptionSnapshot, - build_subscribe_meta, - subscribe_media_key, ) from app.application.subscription.execution import ( SearchBatchSnapshot, @@ -38,14 +36,14 @@ from app.application.subscription.sitebudget import ( from app.chain.media import MediaChain from app.chain.search.facade import SearchChain from app.chain.subscribe.contract import _SubscribeOwnerBase -from app.chain.subscribe.identity import subscribe_recognize_kwargs -from app.chain.subscribe.metadata import apply_subscription_classification +from app.chain.subscribe.metadata import apply_subscription_classification, prepare_search_target from app.chain.subscribe.searchtask import ( SubscriptionSearchTaskRunner, retry_at_after, ) from app.domain.context import ( MediaInfo, + MusicInfo, ) from app.domain.meta.metabase import MetaBase from app.runtime.log import logger @@ -679,44 +677,21 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): def _process_search_subscription( self, subscribe: SubscriptionSnapshot, - searchchain: SearchChain, + searchchain: Optional[SearchChain], execution_context: Optional[SubscriptionExecutionContext] = None, ) -> Optional[SubscriptionSnapshot]: """处理单个订阅,并返回下载后重新读取的状态快照。""" _ensure_execution_active(execution_context) logger.debug(f"开始搜索订阅,标题:{subscribe.name} ...") - if subscribe.type == MediaType.MUSIC.value: - self._search_music_subscribe(subscribe, execution_context=execution_context) - return subscribe - try: - meta = build_subscribe_meta(subscribe) - except ValueError: - logger.error(f"订阅《{subscribe.name}》的媒体类型不受支持,暂时无法搜索") - return subscribe - mediainfo: MediaInfo = MediaChain().recognize_media( - meta=meta, - mtype=meta.type, - **subscribe_recognize_kwargs(subscribe), - episode_group=subscribe.episode_group, - cache=False, + target = prepare_search_target( + self, subscribe, MediaChain(), partial(_ensure_execution_active, execution_context), ) - if not mediainfo: - logger.warning( - f"未识别到媒体信息,标题:{subscribe.name}," - f"媒体来源:{subscribe.media_source},媒体 ID:{subscribe.media_id}" - ) - return subscribe - _ensure_execution_active(execution_context) - mediainfo = apply_subscription_classification(mediainfo, subscribe) - mediakey = subscribe_media_key(subscribe) - exists, no_exists = self.check_and_handle_existing_media( - subscribe=subscribe, - meta=meta, - mediainfo=mediainfo, - mediakey=mediakey, - ) - if exists: + if target is None: return subscribe + subscribe, meta, mediainfo = target.subscribe, target.meta, target.media + mediakey, no_exists = target.media_key, target.missing + if searchchain is None: + searchchain = SearchChain() rule_key = ( SystemConfigKey.BestVersionFilterRuleGroups if subscribe.best_version @@ -733,6 +708,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): area="imdbid" if subscribe.search_imdbid and mediainfo.imdb_id else "title", custom_words=subscribe.custom_words.split("\n") if subscribe.custom_words else None, filter_params=self.get_params(subscribe), + candidate_filter=partial(self._filter_search_contexts, subscribe), ) site_budget_failures = searchchain.consume_subscription_site_budget_failures( has_results=bool(contexts), @@ -751,19 +727,16 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): ) raise_subscription_site_budget_failures(site_budget_failures) return subscribe - matched = self._filter_search_contexts(subscribe, contexts) - if not matched: - logger.debug(f"订阅 {subscribe.name} 没有符合过滤条件的资源") - if not site_budget_failures: - raise_subscription_site_budget_deferral(site_budget_deferrals, execution_context) - self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists) - raise_subscription_site_budget_failures(site_budget_failures) - return subscribe if execution_context: execution_context.report_phase("preparing") _ensure_execution_active(execution_context) + if isinstance(mediainfo, MusicInfo): + self._download_music_subscribe(subscribe, mediainfo, contexts, execution_context=execution_context) + raise_subscription_site_budget_failures(site_budget_failures) + raise_subscription_site_budget_deferral(site_budget_deferrals, execution_context) + return cast(Optional[SubscriptionSnapshot], self.subscription_repository.get(subscribe.id)) downloads, lefts = self._SubscribeChain__download_best_version_with_full_pack_first( - contexts=matched, + contexts=contexts, no_exists=no_exists, subscribe=subscribe, mediakey=mediakey, @@ -800,6 +773,10 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): torrent_meta = context.meta_info torrent_info = context.torrent_info media = context.media_info + if getattr(context, "match_status", None) == "candidate" or media is None: + continue + if isinstance(media, MusicInfo) and subscribe.best_version: + torrent_info.pri_order = torrent_info.pri_order or torrent_meta.audio_quality_score if subscribe.best_version and media.type == MediaType.TV: if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe): logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源") diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 3a580863e..f53faf480 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -1,6 +1,6 @@ import re import traceback -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union, cast from app.application.configuration import get_configured_system_config from app.application.rss import RssHelper @@ -495,14 +495,8 @@ class TorrentsChain(ChainBase): meta: MetaBase mediainfo: MediaInfo | MusicInfo if torrent.category == MediaType.MUSIC.value: - meta = MetaMusic.parse_query(torrent.title) - mediainfo = MusicInfo( - title=meta.title, - artists=list(meta.artists), - album=meta.album, - year=meta.year, - names=[meta.title] if meta.title else [], - ) + meta = MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=MediaType.MUSIC) + mediainfo = MusicInfo.from_meta(cast(MetaMusic, meta)) candidate_recognized = False match_source = "unknown" else: diff --git a/app/domain/context.py b/app/domain/context.py index c35bbc38c..1ff234f3b 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -598,6 +598,10 @@ class MusicInfo: tags: list[str] = field(default_factory=list) artist_country: str | None = None names: list[str] = field(default_factory=list) + # 来源提供的同一实体别名及展示转换前的原文,不混入所属专辑或无关联艺术家。 + title_aliases: list[str] = field(default_factory=list) + album_aliases: list[str] = field(default_factory=list) + artist_aliases: list[str] = field(default_factory=list) detail_link: str | None = None listen_count: int | None = None raw_data: dict[str, Any] = field(default_factory=dict) @@ -605,6 +609,12 @@ class MusicInfo: # 显式声明以保留 getattr(..., False) 的默认值语义(__getattr__ 兜底会覆盖它) recognize_cache_hit = False + def __setstate__(self, state: dict[str, Any]) -> None: + """恢复旧版本音乐缓存时补齐新增别名字段,避免列表字段被缺失属性兜底为 None。""" + self.__dict__.update(state) + for name in ("title_aliases", "album_aliases", "artist_aliases"): + self.__dict__.setdefault(name, []) + def __post_init__(self) -> None: """规范化媒体身份,并兼容拆分旧音乐分类字段。""" self.media_source, self.media_id = resolve_media_identity(media=self) @@ -731,7 +741,7 @@ class MusicInfo: def clear(self) -> None: """清理不参与队列展示和持久化的上游原始响应。""" - self.raw_data.clear() + self.raw_data = {} def to_dict(self) -> dict[str, Any]: """转换为统一媒体身份的 Context 外层字典。""" @@ -768,7 +778,7 @@ class MusicInfo: values["media_source"] = normalize_media_source(values.get("media_source")) values["artists"] = _music_string_list(values.get("artists") or data.get("artist")) values["artist_ids"] = _music_aligned_list(values.get("artist_ids")) - for key in ("secondary_types", "genres", "tags"): + for key in ("secondary_types", "genres", "tags", "title_aliases", "album_aliases", "artist_aliases"): values[key] = _music_string_list(values.get(key)) values["names"] = _music_string_list(values.get("names")) values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING) @@ -885,6 +895,8 @@ class MusicAlbumInfo: title: str | None = None artists: list[str] = field(default_factory=list) artist_ids: list[str] = field(default_factory=list) + title_aliases: list[str] = field(default_factory=list) + artist_aliases: list[str] = field(default_factory=list) # 专辑主类型:Album、EP、Single、Broadcast、Other album_type: str | None = None # 专辑副类型:Live、Compilation、Soundtrack、Remix 等 @@ -910,6 +922,12 @@ class MusicAlbumInfo: releases: list[MusicRelease] = field(default_factory=list) raw_data: dict[str, Any] = field(default_factory=dict) + def __setstate__(self, state: dict[str, Any]) -> None: + """恢复旧版专辑缓存时补齐别名列表,保留既有媒体身份及曲目数据。""" + self.__dict__.update(state) + for name in ("title_aliases", "artist_aliases"): + self.__dict__.setdefault(name, []) + def __post_init__(self) -> None: """规范化媒体身份,并补全专辑描述分类和分类结果。""" self.media_source, self.media_id = resolve_media_identity(media=self) @@ -1024,7 +1042,7 @@ class MusicAlbumInfo: values = _music_init_values(cls, data) values["classification"] = _classification_result(values.get("classification")) values["media_source"] = normalize_media_source(values.get("media_source")) - for key in ("artists", "secondary_types", "genres", "tags"): + for key in ("artists", "secondary_types", "genres", "tags", "title_aliases", "artist_aliases"): values[key] = _music_string_list(values.get(key)) values["artist_ids"] = _music_aligned_list(values.get("artist_ids")) values["rating"] = _music_optional_float(values.get("rating")) @@ -1073,6 +1091,9 @@ class MusicAlbumInfo: album=self.title, album_artist=self.artist or None, album_id=self.media_id, + title_aliases=list(self.title_aliases), + album_aliases=list(self.title_aliases), + artist_aliases=list(self.artist_aliases), album_type=self.album_type, secondary_types=list(self.secondary_types), year=self.year, @@ -1221,6 +1242,7 @@ class MusicArtistInfo: tags=list(self.tags), artist_country=self.country, names=[name for name in [self.name, *self.aliases] if name], + title_aliases=list(self.aliases), detail_link=self.detail_link, raw_data=dict(self.raw_data), ) @@ -1918,6 +1940,9 @@ class Context: candidate_recognized: bool = False # 当前 media_info 是否为目标媒体回填,而不是候选自身识别结果。 media_info_is_target: bool = False + # 音乐资源的匹配等级及原因;待确认项不得绑定目标媒体身份。 + match_status: Optional[str] = None + match_reason: Optional[str] = None # 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。 allowed_episodes: Optional[Set[int]] = None # 下载链实际提交的剧集集合;None 表示尚未执行下载选择。 @@ -1938,6 +1963,8 @@ class Context: "match_source": self.match_source, "candidate_recognized": self.candidate_recognized, "media_info_is_target": self.media_info_is_target, + "match_status": getattr(self, "match_status", None), + "match_reason": getattr(self, "match_reason", None), # 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。 "allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None, "selected_episodes": self.selected_episodes, diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index 3e553b2b1..3965e0a34 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -1,15 +1,14 @@ -import re import logging +import re from dataclasses import dataclass from pathlib import Path from threading import RLock from typing import Any, Callable, Optional from app.domain.meta.metabase import MetaBase -from app.schemas.types import MediaSource, MediaType -from app.schemas.media import resolve_media_identity from app.domain.meta.runtime import get_metainfo_accelerator - +from app.schemas.media import resolve_media_identity +from app.schemas.types import MediaSource, MediaType _AUDIO_FORMAT_PATTERN = re.compile( r"(?DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|" @@ -708,6 +707,55 @@ class MetaMusic(MetaBase): """把用户输入或资源标题解析为音乐元数据。""" return cls(org_string=query, title=query, parse_title=True) + @classmethod + def parse_resource(cls, title: str, subtitle: Optional[str] = None) -> "MetaMusic": + """合并资源标题与副标题的独立证据,不使用搜索目标补写作品身份。 + + 标题解析仍复用 Python/Rust 公共入口;副标题只补缺失字段,保留标题中 + 已有的署名。专辑、曲序、曲名的明确多段格式在资源层统一补充。 + """ + meta = cls.parse_query(title) + if not meta.title: + # 整个作品名位于中文展示括号内时,旧解析器可能把它误当发布标签删除。 + bracket = re.match(r"^\s*[【《「]([^】》」]+)[】》」]", title) + if bracket: + meta.apply_title(bracket.group(1)) + meta.apply_audio_quality(title) + if meta.artists and meta.title: + track = re.fullmatch(r"(.+?)\s+-\s+(\d{1,3})\s+-\s+(.+)", meta.title) + if track: + meta.album, number, meta.title = track.groups() + meta.track_number = int(number) + if subtitle: + secondary = cls.parse_query(subtitle) + artist = re.search( + r"(?:^|[;;\n])\s*(?:艺术家|藝術家|藝人|歌手|演唱|专辑艺人|專輯藝人|artist|performer)" + r"\s*[::]\s*([^;;\n]+)", subtitle, re.I, + ) + if not meta.artists: + meta.artists = cls._split_artists(artist.group(1)) if artist else list(secondary.artists) + album = re.search( + r"(?:^|[;;\n])\s*(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|album)\s*[::]\s*([^;;\n]+)", + subtitle, re.I, + ) + if album and not meta.album: + meta.album = album.group(1).strip() + elif not meta.album and secondary.artists and secondary.title != meta.title: + if {cls.compact_text(item) for item in meta.artists} & {cls.compact_text(item) for item in secondary.artists}: + meta.album = secondary.title + if not meta.year: + 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() + return meta + @classmethod def from_music_info(cls, info: Any) -> "MetaMusic": """把标准音乐信息转换为下载、整理和站点搜索使用的元数据。""" diff --git a/app/domain/metainfo.py b/app/domain/metainfo.py index 4ef627e30..0bddc5332 100644 --- a/app/domain/metainfo.py +++ b/app/domain/metainfo.py @@ -1,31 +1,30 @@ import hashlib import logging from dataclasses import dataclass -from pathlib import Path from functools import lru_cache -from typing import Any, Mapping, Tuple, List, Optional +from pathlib import Path +from typing import Any, List, Mapping, Optional, Tuple import regex as re -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.metavideo import MetaVideo +from app.domain.meta.customization import CustomizationMatcher, get_customization from app.domain.meta.infopath import ( clear_parsed_title_for_parent_merge, should_use_parent_title_for_file_stem, ) -from app.domain.meta.words import WordsMatcher, get_custom_words -from app.domain.meta.customization import CustomizationMatcher, get_customization +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.metavideo import MetaVideo from app.domain.meta.releasegroup import ReleaseGroupsMatcher from app.domain.meta.runtime import ( get_audio_extensions, get_media_extensions, get_metainfo_accelerator, ) -from app.schemas.types import MediaSource, MediaType +from app.domain.meta.words import WordsMatcher, get_custom_words from app.schemas.media import normalize_media_source, resolve_media_identity - +from app.schemas.types import MediaSource, MediaType _ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE) _ANIME_DASH_EPISODE_RE = re.compile(r'\s+-\s+[\dv]{1,4}\s+', re.IGNORECASE) @@ -493,18 +492,21 @@ def _requires_python_metainfo( def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None, - force_video: bool = False) -> MetaBase: + force_video: bool = False, mtype: Optional[MediaType] = None) -> MetaBase: """ 根据标题和副标题识别元数据 :param title: 标题、种子名、文件名 :param subtitle: 副标题、描述 :param custom_words: 自定义识别词列表 :param force_video: 音频后缀的影视附加轨(如评论音轨)强制按视频解析,用于影视整理场景 + :param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器 :return: MetaAnime、MetaVideo、MetaMusic """ # 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析 audio_suffix = Path(title).suffix.lower() if title else "" - if not force_video and audio_suffix in get_audio_extensions(): + 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, diff --git a/app/domain/music.py b/app/domain/music.py new file mode 100644 index 000000000..9df0fbf4d --- /dev/null +++ b/app/domain/music.py @@ -0,0 +1,187 @@ +"""音乐名称、版本与站点候选匹配的纯业务规则。""" + +import re +from dataclasses import dataclass +from typing import Iterable, Literal, Optional +from unicodedata import combining, normalize + +from app.domain.context import MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.foundation.text import convert as zhconv_convert +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType + +_EDITION = re.compile( + r"\s*[\[((【](?:[^\]))】]*\b(?:deluxe|expanded|special|limited|anniversary|remaster(?:ed)?)\b" + r"[^\]))】]*|[^\]))】]*(?:豪华版|典藏版|纪念版|重制版|周年版)[^\]))】]*)[\]))】]", + re.IGNORECASE, +) +_VERSIONS = { + "live": r"\blive\b|现场|現場|演唱会|演唱會", + "remix": r"\bremix(?:ed)?\b|混音", + "instrumental": r"\binstrumental\b|\bkaraoke\b|伴奏|纯音乐|純音樂", + "acoustic": r"\bacoustic\b|\bunplugged\b|不插电|不插電", + "demo": r"\bdemo\b", +} +_VERSION_SUFFIX = re.compile( + r"\s*[\[((【][^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|" + r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*[\]))】]", + re.IGNORECASE, +) +_BARE_VERSION_SUFFIX = re.compile( + r"\s+[-/]\s+(?:live\b|remix\b|instrumental\b|acoustic\b|unplugged\b|demo\b|karaoke\b|" + r"现场|現場|混音|伴奏|不插电|不插電).*$", re.IGNORECASE, +) +_TITLE_LABEL = re.compile(r"^(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|曲名|歌曲|album|title)\s*[::]\s*", re.I) +_COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人") + + +@dataclass(frozen=True, slots=True) +class MusicMatch: + """区分可自动采用的精确命中、仅可人工确认的候选和无关资源。""" + + status: Literal["exact", "candidate", "album", "rejected"] + reason: str + + +def unique_music_texts(values: Iterable[Optional[str]]) -> list[str]: + """保留原始文字和顺序,仅合并空白及大小写相同的名称。""" + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if text and text.casefold() not in seen: + seen.add(text.casefold()) + result.append(text) + return result + + +def music_text_key(value: Optional[str]) -> str: + """统一繁简、大小写、全半角和拉丁变音符,忽略名称排版符号。""" + text = normalize("NFKD", str(value or "")).casefold() + return str(zhconv_convert("".join(char for char in text if char.isalnum() and not combining(char)), "zh-hans")) + + +def music_titles(music: MusicInfo, *, album: bool = False) -> list[str]: + """返回同一作品的可信名称,单曲绝不消费兼容 names 中的专辑名。""" + if album or music.music_type == MUSIC_ENTITY_ALBUM: + return unique_music_texts([ + music.album or (music.title if music.music_type == MUSIC_ENTITY_ALBUM else None), + *(music.album_aliases or []), + *((music.title_aliases or []) if music.music_type == MUSIC_ENTITY_ALBUM else ()), + *((music.names or []) if music.music_type == MUSIC_ENTITY_ALBUM else ()), + ]) + return unique_music_texts([music.title, *(music.title_aliases or [])]) + + +def music_artists(music: MusicInfo) -> list[str]: + """合并实体艺术家和来源别名,仅为合辑署名扩展通用缩写。""" + album_artist = music.album_artist if music.music_type == MUSIC_ENTITY_ALBUM or not music.artists else None + artists = unique_music_texts([album_artist, *(music.artists or []), *(music.artist_aliases or [])]) + collective_keys = {music_text_key(item) for item in _COLLECTIVE_ARTISTS} + if music.music_type != MUSIC_ENTITY_ALBUM and any(music_text_key(artist) not in collective_keys for artist in music.artists): + artists = [artist for artist in artists if music_text_key(artist) not in collective_keys] + if any(music_text_key(artist) in collective_keys for artist in artists): + artists = unique_music_texts([*artists, *_COLLECTIVE_ARTISTS]) + return artists + + +def music_base_title(value: Optional[str]) -> str: + """仅剥离已知发行版本后缀,保留未知括号和属于作品本身的文字。""" + text = _VERSION_SUFFIX.sub("", _EDITION.sub("", str(value or ""))) + return _BARE_VERSION_SUFFIX.sub("", text).strip() + + +def _contains_artist(text: str, artist: str) -> bool: + """匹配完整署名,避免短拉丁艺名命中另一个人名的子串。""" + normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans")) + normalized = "".join(char for char in normalized if not combining(char)) + key = music_text_key(artist) + if not key: + return False + pattern = r"[\W_]*".join(re.escape(char) for char in key) + return bool(re.search(r"(? list[str]: + """复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。""" + names: list[str] = [] + artist_keys = [music_text_key(item) for item in artists if item] + 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: + continue + parts = re.split(r"\s+[-|/]\s+|[;;]", value) + # 只有可核验为所属专辑的尾段才允许剥离,未知连字符后缀仍属于作品本身。 + variants = [value] + if len(parts) > 1 and all(music_text_key(part) in suffix_keys for part in parts[1:]): + variants.append(parts[0]) + for part in variants: + key = music_text_key(music_base_title(_TITLE_LABEL.sub("", part.strip()))) + if not key: + continue + names.append(key) + for artist in artist_keys: + if key.startswith(artist) and key != artist: + names.append(key[len(artist):]) + if key.endswith(artist) and key != artist: + names.append(key[:-len(artist)]) + return names + + +def _version_markers(text: str) -> set[str]: + """识别会改变录音身份的版本标记,普通发行后缀单独处理。""" + return {name for name, pattern in _VERSIONS.items() if re.search(pattern, text, re.I)} + + +def match_music_resource( + music: MusicInfo, + title: str, + description: Optional[str] = None, + category: Optional[str] = MediaType.MUSIC.value, + *, + meta: Optional[MetaMusic] = None, +) -> MusicMatch: + """以作品名称为基础验证艺术家、分类和版本,并保留可供人工确认的关联候选。""" + if category not in (None, "", MediaType.UNKNOWN, MediaType.UNKNOWN.value, MediaType.MUSIC, MediaType.MUSIC.value): + return MusicMatch("rejected", "category_mismatch") + description = description or "" + resource = meta or MetaMusic.parse_resource(title, description) + 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) + title_matched = any(music_text_key(music_base_title(item)) in names for item in titles) + content = f"{title} {description}" + resource_artist_keys = {music_text_key(artist) for artist in resource.artists} + if len(resource.artists) > 1 and any(any(separator in artist for separator in ("/", "&", ",")) for artist in artists): + # 带分隔符的完整艺名可能被解析成多个片段,保留整段署名参与比较,不拼接无分隔符艺名。 + resource_artist_keys.add(music_text_key(resource.artist)) + artist_matched = bool(resource_artist_keys & {music_text_key(artist) for artist in artists}) if resource.artists \ + else any(_contains_artist(content, artist) for artist in artists) + if not title_matched: + if music.music_type != MUSIC_ENTITY_ALBUM and artist_matched and any( + music_text_key(music_base_title(item)) in names + for item in music_titles(music, album=True) + ): + return MusicMatch("album", "related_album") + return MusicMatch("rejected", "title_mismatch") + if not artists or (music.music_type != MUSIC_ENTITY_ALBUM and not music.artists): + return MusicMatch("candidate", "target_artist_missing") + if not artist_matched: + return MusicMatch("candidate", "artist_unverified") + 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: + return MusicMatch("candidate", "partial_album") + if music.year and resource.year and str(music.year) != str(resource.year): + return MusicMatch("candidate", "year_mismatch") + target_title = music.album or music.title if music.music_type == MUSIC_ENTITY_ALBUM else music.title + expected_version = _version_markers(f"{target_title or ''} {music.version or ''}") + if expected_version != _version_markers(f"{resource.title or ''} {resource.version or ''}"): + return MusicMatch("candidate", "version_mismatch") + if _EDITION.search(music.title or "") and not any(music_text_key(item) in music_text_key(content) for item in titles): + return MusicMatch("candidate", "edition_unverified") + return MusicMatch("exact", "matched") diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 7186e3a21..609b345c1 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -18,6 +18,7 @@ from app.domain.context import ( from app.domain.media import is_media_source_selected from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic +from app.domain.music import music_text_key, unique_music_texts from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase from app.modules.musicbrainz.cache import MusicBrainzCache @@ -292,9 +293,11 @@ class MusicBrainzModule(_ModuleBase): if not is_media_source_selected(media_source, self._source): return None normalized_limit = max(1, min(limit, 100)) - recordings = self._search_recordings(meta, limit=normalized_limit) - albums = self._search_albums(meta, limit=normalized_limit) - artists = self._search_artists(meta, limit=normalized_limit) + # 中文逐字召回可能包含很多局部命中,扩大单次窗口后按完整名称重新排序。 + fetch_limit = min(100, normalized_limit * 3) if self._QUERY_CJK_RE.search(meta.title or "") else normalized_limit + recordings = self._rank_search_candidates(meta, self._search_recordings(meta, limit=fetch_limit)) + albums = self._rank_search_candidates(meta, self._search_albums(meta, limit=fetch_limit)) + artists = self._rank_search_candidates(meta, self._search_artists(meta, limit=fetch_limit)) return self._interleave_results( recordings, albums, @@ -302,6 +305,26 @@ class MusicBrainzModule(_ModuleBase): limit=normalized_limit, ) + @staticmethod + def _rank_search_candidates(meta: MetaMusic, candidates: list[MusicInfo]) -> list[MusicInfo]: + """按完整作品名及输入署名排序浏览候选,不把逐字 OR 命中视为精确身份。""" + expected = music_text_key(meta.title) + expected_artists = {music_text_key(artist) for artist in meta.artists} + + def score(info: MusicInfo) -> tuple[bool, float, bool]: + """整名优先;普通组合输入同时比较艺术家与作品名,原始顺序用于同分稳定排序。""" + names = [info.title, *(info.title_aliases or [])] + if not meta.artists: + names.extend(f"{artist} {info.title or ''}" for artist in [*info.artists, *(info.artist_aliases or [])]) + keys = [music_text_key(name) for name in names if name] + artist_match = bool(expected_artists & { + music_text_key(artist) for artist in [*info.artists, *(info.artist_aliases or [])] + }) + similarity = max((SequenceMatcher(None, expected, key).ratio() for key in keys), default=0.0) + return expected in keys, similarity, artist_match + + return sorted(candidates, key=score, reverse=True) + def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: """按音频标签条件搜索 Recording,供全局搜索和文件识别复用。""" for query in self._recording_queries(meta): @@ -365,11 +388,13 @@ class MusicBrainzModule(_ModuleBase): for query in [ cls._build_query(meta), f"recording:{cls._query_phrase(title)}" if title else None, - f'recording:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"' + f'recording:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}' if artist and bare_title and bare_title != title else None, # 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索, # 候选挑选阶段要求艺术家命中兜住同名异曲 f"recording:{cls._query_phrase(bare_title)}" if bare_title else None, + f"recording:{cls._query_phrase(bare_title or title, loose=True)}" + if cls._QUERY_CJK_RE.search(title) else None, ]: if query and query not in queries: queries.append(query) @@ -510,6 +535,7 @@ class MusicBrainzModule(_ModuleBase): @classmethod def _album_queries(cls, meta: MetaMusic) -> list[str]: """构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。""" + original_title = cls._search_title(meta.album or meta.title, preserve_script=True) title = cls._search_title(meta.album or meta.title) if not title: return [] @@ -528,16 +554,20 @@ class MusicBrainzModule(_ModuleBase): soundtrack_body = "" queries: list[str] = [] for query in [ - f'releasegroup:{cls._query_phrase(title)} AND artist:"{cls._escape_query(artist)}"' + f'releasegroup:{cls._query_phrase(original_title)} AND artist:{cls._query_phrase(artist)}' + if artist else f"releasegroup:{cls._query_phrase(original_title)}", + f'releasegroup:{cls._query_phrase(title)} AND artist:{cls._query_phrase(artist)}' if artist else None, f"releasegroup:{cls._query_phrase(title)}" if title else None, - f'releasegroup:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"' + f'releasegroup:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}' if artist and bare_title and bare_title != title else None, - f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:"{cls._escape_query(artist)}"' + f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:{cls._query_phrase(artist)}' if artist and soundtrack_body else None, f"releasegroup:{cls._query_phrase(soundtrack_body)}" if soundtrack_body else None, # 署名变体兜底:仅按去注释专辑名检索,挑选阶段要求艺术家同时命中 f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None, + f"releasegroup:{cls._query_phrase(bare_title or title, loose=True)}" + if cls._QUERY_CJK_RE.search(title) else None, ]: if query and query not in queries: queries.append(query) @@ -1376,7 +1406,7 @@ class MusicBrainzModule(_ModuleBase): text = re.sub(r"\s+", " ", str(value or "")).strip() if not text: return text - # MusicBrainz 中文条目以简体为主,资源标题可能是繁体,比对前统一转简体 + # 仅归一化本地比较;实际查询使用原文和完整繁简变体。 try: return zhconv_convert(text, "zh-hans") except Exception: # pylint: disable=broad-except @@ -1394,25 +1424,27 @@ class MusicBrainzModule(_ModuleBase): ) @classmethod - def _search_title(cls, value: Optional[str]) -> str: + def _search_title(cls, value: Optional[str], *, preserve_script: bool = False) -> str: """剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。""" text = cls._quality_token_pattern.sub(" ", str(value or "")) + normalize_text = (lambda value: re.sub(r"\s+", " ", str(value or "")).strip()) \ + if preserve_script else cls._normalize_text # 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语 text = text.replace("_", " ") # 规格剥离后可能残留悬空分隔符,统一修剪 - text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", cls._normalize_text(text)) + text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", normalize_text(text)) # 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB),整体剔除 text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text) # 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取), # 反复剥离尾部年份:场景命名可能重复携带(Live At Montreux 2011 2011) - text = cls._normalize_text(text) + text = normalize_text(text) while True: # 仅剔除空白分隔的尾部年份,纯年份标题(1999)无前导空白不受影响 stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text) if stripped == text: break text = stripped - return cls._normalize_text(text) + return normalize_text(text) def recognize_music( self, @@ -1429,12 +1461,13 @@ class MusicBrainzModule(_ModuleBase): payload = self._request_json( f"/recording/{plan.require_media_id()}", params={ - "inc": "artists+releases+release-groups+isrcs+genres", + "inc": "artists+releases+release-groups+isrcs+genres+aliases", "fmt": "json", }, ) result = self._project_recording_detail(payload) if result: + result.artist_aliases = self._lookup_artist_aliases(result.artist_ids, result.artist_aliases) return result if not self._should_probe_album(plan, result): return None @@ -1457,12 +1490,13 @@ class MusicBrainzModule(_ModuleBase): payload = await self._async_request_json( f"/recording/{plan.require_media_id()}", params={ - "inc": "artists+releases+release-groups+isrcs+genres", + "inc": "artists+releases+release-groups+isrcs+genres+aliases", "fmt": "json", }, ) result = self._project_recording_detail(payload) if result: + result.artist_aliases = await self._async_lookup_artist_aliases(result.artist_ids, result.artist_aliases) return result if not self._should_probe_album(plan, result): return None @@ -1514,7 +1548,7 @@ class MusicBrainzModule(_ModuleBase): payload = await self._async_request_json( f"/release-group/{media_id}", params={ - "inc": "artists+releases+media+genres+tags+ratings", + "inc": "artists+releases+media+genres+tags+ratings+aliases", "fmt": "json", }, ) @@ -1525,6 +1559,7 @@ class MusicBrainzModule(_ModuleBase): payload.get("releases") or [] ) album.tracks = self._project_album_tracks(album, tracks_payload) + album.artist_aliases = await self._async_lookup_artist_aliases(album.artist_ids, album.artist_aliases) return album def music_album( @@ -1538,7 +1573,7 @@ class MusicBrainzModule(_ModuleBase): payload = self._request_json( f"/release-group/{media_id}", params={ - "inc": "artists+releases+media+genres+tags+ratings", + "inc": "artists+releases+media+genres+tags+ratings+aliases", "fmt": "json", }, ) @@ -1547,8 +1582,39 @@ class MusicBrainzModule(_ModuleBase): return None tracks_payload = self._album_tracks_payload(payload.get("releases") or []) album.tracks = self._project_album_tracks(album, tracks_payload) + album.artist_aliases = self._lookup_artist_aliases(album.artist_ids, album.artist_aliases) return album + @staticmethod + def _alias_artist_ids(artist_ids: list[str]) -> list[str]: + """限制补充查询预算,仅使用来源返回的有效 MusicBrainz 艺术家 UUID。""" + return list(dict.fromkeys(artist_id for artist_id in artist_ids if re.fullmatch( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", artist_id, + )))[:3] + + @classmethod + def _artist_alias_values(cls, payload: Optional[dict[str, Any]], artist_id: str) -> list[str]: + """只采信精确艺术家 ID 响应中的名称及别名,避免串入搜索得到的同名艺人。""" + if not payload or payload.get("id") != artist_id: + return [] + return unique_music_texts([payload.get("name"), *cls._names_of(payload.get("aliases"))]) + + def _lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]: + """利用现有请求缓存补全已识别艺术家的可信别名,不按文字猜测其他艺人。""" + values = list(aliases) + for artist_id in self._alias_artist_ids(artist_ids): + payload = self._request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"}) + values.extend(self._artist_alias_values(payload, artist_id)) + return unique_music_texts(values) + + async def _async_lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]: + """原生异步补全同一艺术家别名,保留站点客户端的限流和请求缓存。""" + values = list(aliases) + for artist_id in self._alias_artist_ids(artist_ids): + payload = await self._async_request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"}) + values.extend(self._artist_alias_values(payload, artist_id)) + return unique_music_texts(values) + @classmethod def _project_album_detail( cls, payload: Optional[dict[str, Any]] @@ -1628,13 +1694,13 @@ class MusicBrainzModule(_ModuleBase): """构造 MusicBrainz Recording 搜索表达式。""" clauses = [] # 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中 - title = cls._search_title(meta.title) + title = cls._search_title(meta.title, preserve_script=True) if title: clauses.append(f"recording:{cls._query_phrase(title)}") if meta.artists: - clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"') + clauses.append(f'artist:{cls._query_phrase(meta.artists[0])}') if meta.album: - clauses.append(f'release:"{cls._escape_query(meta.album)}"') + clauses.append(f'release:{cls._query_phrase(meta.album)}') if meta.isrc: clauses.append(f'isrc:"{cls._escape_query(meta.isrc)}"') return " AND ".join(clauses) @@ -1644,7 +1710,7 @@ class MusicBrainzModule(_ModuleBase): """转义 MusicBrainz 查询中的引号和反斜线。""" return value.replace("\\", "\\\\").replace('"', '\\"').strip() - # 中日韩字符:Lucene 标准分词器不会切分连续 CJK,短语检索对中文标题永远零命中 + # 中日韩名称优先完整短语,仅在前置查询无结果时启用逐字兜底。 _QUERY_CJK_RE = re.compile( r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF]") # 检索词元切分:按空白、标点与括号拆分,保留 CJK 串与拉丁词(括号对逐字检索无意义) @@ -1652,18 +1718,18 @@ class MusicBrainzModule(_ModuleBase): r"[\s\-–—−-。,、;:!?·.…()()「」『』【】\[\]《》,;]+") @classmethod - def _query_phrase(cls, value: Optional[str]) -> Optional[str]: - """构造适配 Lucene 分词的检索表达式。 - - 无 CJK 的普通文本返回带引号短语;含 CJK 的文本拆为词元后用 OR 交集检索, - MusicBrainz 索引中连续 CJK 是单一词元,逐字 OR 才能命中(「茹此精彩十三首」); - 过宽的召回由候选挑选阶段的标题与艺术家比对收紧。 - """ + def _query_phrase(cls, value: Optional[str], *, loose: bool = False) -> Optional[str]: + """优先构造完整名称的繁简短语组,逐字 OR 只作为显式请求的末级兜底。""" text = str(value or "").strip() if not text: return None if not cls._QUERY_CJK_RE.search(text): return f'"{cls._escape_query(text)}"' + if not loose: + variants = sorted(unique_music_texts([ + text, zhconv_convert(text, "zh-hans"), zhconv_convert(text, "zh-hant"), + ])) + return cls._or_group([f'"{cls._escape_query(variant)}"' for variant in variants]) tokens = [ token for token in cls._QUERY_TOKEN_SPLIT_RE.split(text) if token.strip() ] @@ -1726,6 +1792,9 @@ class MusicBrainzModule(_ModuleBase): genres=cls._names_of(recording.get("genres")), release_status=cls._stripped((release or {}).get("status")), names=[name for name in (title, album) if name], + title_aliases=cls._names_of(recording.get("aliases")), + album_aliases=cls._names_of(release_group.get("aliases")), + artist_aliases=cls._credit_aliases(recording.get("artist-credit")), detail_link=f"{cls._detail_url}/{media_id}", raw_data=recording, ) @@ -1746,6 +1815,8 @@ class MusicBrainzModule(_ModuleBase): artists=artists, artist_ids=artist_ids, album_type=cls._stripped(release_group.get("primary-type")), + title_aliases=cls._names_of(release_group.get("aliases")), + artist_aliases=cls._credit_aliases(release_group.get("artist-credit")), secondary_types=[cls._stripped(item) for item in release_group.get("secondary-types") or [] if cls._stripped(item)], release_date=release_group.get("first-release-date") or None, cover_url=cls._build_cover_url(media_id), @@ -1798,6 +1869,17 @@ class MusicBrainzModule(_ModuleBase): ids.append(str(artist.get("id") or "")) return names, ids + @classmethod + def _credit_aliases(cls, credits: Optional[list[dict[str, Any]]]) -> list[str]: + """保留 artist-credit 中同一人的实际署名与来源别名,不丢弃外文艺名。""" + names: list[str] = [] + for credit in credits or []: + artist = credit.get("artist") or {} + names.extend(unique_music_texts([ + credit.get("name"), artist.get("name"), *cls._names_of(artist.get("aliases")), + ])) + return unique_music_texts(names) + @staticmethod def _names_of(items: Optional[list[dict[str, Any]]]) -> list[str]: """提取 MusicBrainz 风格、标签或别名列表的名称,热度高的排在前面。""" diff --git a/app/schemas/context.py b/app/schemas/context.py index f1c5cbfcc..cf1cd8d98 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -499,6 +499,8 @@ class Context(BaseModel): candidate_recognized: Optional[bool] = False # 当前 media_info 是否为目标媒体回填 media_info_is_target: Optional[bool] = False + match_status: Optional[str] = None + match_reason: Optional[str] = None # 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源 confirmed_full_coverage: Optional[bool] = False diff --git a/app/schemas/music.py b/app/schemas/music.py index a2358446c..c10cfcf18 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -83,6 +83,9 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel): artist_country: Optional[str] = None release_status: Optional[str] = None names: list[str] = Field(default_factory=list) + title_aliases: list[str] = Field(default_factory=list) + album_aliases: list[str] = Field(default_factory=list) + artist_aliases: list[str] = Field(default_factory=list) detail_link: Optional[str] = None listen_count: Optional[int] = None raw_data: dict[str, JsonData] = Field(default_factory=dict) @@ -154,6 +157,8 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel): artists: list[str] = Field(default_factory=list) artist: Optional[str] = None artist_ids: list[str] = Field(default_factory=list) + title_aliases: list[str] = Field(default_factory=list) + artist_aliases: list[str] = Field(default_factory=list) album: Optional[str] = None album_type: Optional[str] = None secondary_types: list[str] = Field(default_factory=list) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index ffc4505cb..5803fc654 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -755,7 +755,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 973 | -| 内部导入边 | 8,239 | +| 内部导入边 | 8,259 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/media-search-design.md b/docs/architecture/media-search-design.md new file mode 100644 index 000000000..64be80043 --- /dev/null +++ b/docs/architecture/media-search-design.md @@ -0,0 +1,92 @@ +# 音乐与影视搜索流程统一 + +## 目标与边界 + +电影、剧集和音乐共用识别、资源搜索、过滤、匹配与结果发布的编排。 +媒体类型决定元数据解析器、关键词、身份和范围匹配规则,不拥有另一套 +站点请求循环、提前停止条件、缓存、进度和上下文生命周期。 + +资源解析结果是资源自身的证据。目标媒体信息是用于比较和后续交付的目标, +不能先把目标名称、艺术家或 ID 写回资源元数据,再将其当作识别成功。 + +## 改造前调用链审计 + +基线为 `v3@76174b11d6b26793f1471e77e9453f3c7fbb54aa`。 + +| 环节 | 影视 | 音乐 | 判定 | +| --- | --- | --- | --- | +| 媒体身份识别 | `RecognitionChainMixin` 的识别计划与步骤 | 同一计划,原生动作选择音乐来源链 | 已共用主流程;保留来源适配差异 | +| 原生与插件回退 | `MediaRecognitionOwner` + `MediaPluginOwner` | 同一选择状态机,事件和字段不同 | 已共用;不复制状态机 | +| 路径证据 | 文件名、父目录、已有身份 | 指纹、音频标签、文件名及专辑目录 | 必要差异;每级仍复用统一媒体识别入口 | +| 元数据目录搜索 | `MediaSearchOwner.search` | `MediaCatalogOwner.search_music` | 统一上层入口与结果收口,来源模块 ABI 保持兼容 | +| 资源解析 | `MetaInfo(title, subtitle)` | 精确搜索未解析;标题搜索仅构造 `MetaMusic`;RSS 只解析标题 | 应统一至具备媒体类型意图的 `MetaInfo` 工厂 | +| 精确资源搜索 | `SearchMediaOwner` 的准备、请求、解析步骤 | 三个独立音乐搜索循环 | 应移除音乐旁路,使用同一搜索状态机 | +| 停止换词 | 影视收到原始资源即停止,之后才过滤 | 音乐通过字符串匹配即停止,之后才过滤 | 均应基于最终可用的精确结果停止 | +| 资源匹配 | 解析作品名/年份/季集/身份,必要时消歧 | 标题和副标题字符串包含作品名及艺术家 | 应共用匹配阶段;具体规则由媒体类型提供 | +| 上下文生成 | 资源元数据与目标媒体分开 | 用目标信息重新构造资源 `MetaMusic` | 应共用上下文生成,并保留原始证据 | +| 过滤与排序 | 搜索结果 owner 调用规则模块及种子工具 | 音乐重复实现同一规则调用与排序 | 应在同一结果处理 owner 收口 | +| 进度和缓存 | 原始候选及最终替换结果 | 仅统计已经匹配的候选 | 应统一原始召回、过滤结果、最终发布语义 | +| 主动订阅搜索 | 共用 `SearchChain.process` 并处理站点预算事实 | 自行循环 `search_by_title`,未经过同一预算结果处理 | 应复用同一订阅搜索编排 | +| RSS 订阅匹配 | 使用资源元数据、身份和订阅约束 | 单独字符串匹配并回填目标元数据 | 复用相同音乐解析/匹配规则,保留 RSS 不再查询站点的语义 | +| 下载提交 | 共用批量选择、失败冷却和单次提交 | 已与电影共用直接候选下载 | 保留共用提交;禁止人工候选自动下载 | +| 范围完成 | 剧集按缺集/全集覆盖 | 专辑按独立音轨数量和文件清单覆盖 | 必要差异,不将专辑等同于单曲或电影 | + +审计入口:`app/chain/_recognition.py`、`app/chain/media/recognition.py`、 +`app/chain/media/path.py`、`app/domain/metainfo.py`、`app/chain/search/media.py`、 +`app/chain/search/result.py`、`app/chain/_music.py`、`app/chain/subscribe/search.py`、 +`app/chain/torrents.py`、`app/chain/download/selection.py`。 + +## 统一后的资源流程 + +1. 校验来源身份并取得目标媒体快照;不改变显式来源和默认来源约定。 +2. 复制目标,完成适用的附加信息补全,按媒体类型生成有序关键词与范围约束。 +3. 通过现有 provider 层执行站点选择、分页、节流、预算和查询。 +4. 对原始候选计数;通过统一 `MetaInfo` 工厂解析主副标题。 +5. 在同一结果处理阶段应用规则和媒体匹配策略,记录被淘汰原因。 +6. 构造保留资源自身证据的 `Context`,排序、去重。人工候选不绑定目标身份。 +7. 应用调用方提供的候选约束(如订阅当前洗版优先级);有经过完整过滤的精确结果且没有要求多名称搜索时停止,否则继续换词。 +8. 统一发布流式替换/完成事件并缓存最终结果。 + +同步、异步和流式入口只负责不同的 I/O 驱动,不各自决定媒体业务分支。 +音乐 owner 保留关键词和兼容门面,不再拥有完整搜索或结果处理循环。 + +实现由 `app/chain/search/execution.py` 驱动统一状态机,`result.py` 统一处理候选; +`MediaChain.search` / `async_search` 统一元数据目录入口,来源服务只适配既有模块 ABI。 +`app/chain/subscribe/metadata.py` 准备目标,`subscribe/search.py` 统一主动搜索和站点预算处理。 + +## 音乐策略 + +- `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析, + 补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。 +- 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用 + 兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。 +- 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、 + 不确定发行版本、不同录音版本或关联专辑只能成为显式请求的人工候选。 +- 单曲所属专辑可以作为人工搜索的后续查询,但不能自动认定包含目标单曲。 +- 元数据源各自排序和去重后公平合并,避免首个来源独占返回条数上限。 +- MusicBrainz 以完整名称的繁简短语组优先,完整查询无结果才启用逐字兜底, + 并按完整名称重排有界候选窗口。2026-09-05 公网查询 + `recording:"晴天" AND artist:"周杰伦"` 返回 10 个录音候选,前三项均为标题 + “晴天”、艺术家“周杰倫”,证明中文完整短语不是零命中。 + 查询字段与短语语义参考 [MusicBrainz 官方检索语法](https://musicbrainz.org/doc/Indexed_Search_Syntax)。 +- 不根据展示文本臆造 MusicBrainz ID,不更改默认 MusicBrainz 或显式单来源行为。 + +## 订阅与下载 + +订阅共用搜索入口、取消检查和站点预算事实处理。音乐实体合法性、总曲目数、 +音质洗版优先级和整专完成判定作为领域步骤保留;不额外执行另一套关键词循环。 +RSS 复用解析和匹配策略,但不重新发起站点搜索。 + +人工候选必须有清晰状态,默认自动 API/订阅仍只采用精确结果。候选进入手动下载 +确认时仅提交资源本身;自动批量选择也拒绝待确认项,防止缓存或其他调用路径绕过。 + +## 验证要求 + +- 电影、剧集、音乐参数化验证同一请求顺序、过滤后停止、空结果继续与最终缓存。 +- 同步、异步和 SSE 的结果、范围、来源、候选计数和停止条件一致。 +- 影视 IMDb/别名消歧、季集、用户过滤规则、分页与预算行为不回退。 +- 音乐主副标题、繁简原文、可信别名、名称边界、版本、曲序和专辑范围回归。 +- 音乐主动订阅与 RSS 使用一致的匹配依据,资源元数据不能被目标信息污染。 +- 人工候选、缺少 ID 的部分元数据及旧缓存不能流入自动下载。 +- 架构、复杂度、异步阻塞、类型、增量静态检查与后端全量测试通过。 +- 前端完整测试及桌面/窄屏操作检查通过后,完成最终提交 CI 和现版本发布验收。 diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 3108e9f4e..1afe602e5 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 974 / 8,239 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 976 / 8,259 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,8 +102,8 @@ 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,508 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 546 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 9,502 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| Ruff 历史诊断 | 542 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index d33e2b2f9..cc7ec657f 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -293,7 +293,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype`、`area`、`season`、`sites`、`music_type` | +| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype`、`area`、`season`、`sites`、`music_type`、`include_candidates` | | GET | `/api/v1/search/media/{media_id}/stream` | 按统一媒体身份渐进式搜索站点种子资源,返回 SSE,参数同上 | | GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 | | GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` | @@ -326,6 +326,17 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。 +音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自 +标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases`、`album_aliases`、 +`artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。 + +音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`, +额外返回待确认资源及关联专辑:`match_status=candidate`、`match_reason` 描述原因, +且 `media_info` 为空、不绑定目标 ID;精确结果为 `match_status=exact`。自动订阅和批量下载 +不采用待确认项。单曲的关联专辑不代表已经确认包含该单曲,专辑下载仍需检查曲目覆盖。 +SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身份、分类及规则淘汰原因。 +只有完整过滤后还有精确结果时才会按多名称设置提前停止;音乐元数据多来源结果先各自去重再公平合并。 + 音乐识别结果同时提供 `audio_format`、`audio_lossless`、`audio_quality`、`bit_depth`、`sample_rate`、`bitrate`、`audio_specs` 和 `audio_quality_score`。本地文件识别读取实际音频流参数,并使用 Chromaprint 的 `fpcalc` 在本地生成指纹后查询 AcoustID;音频文件本身不会上传。站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。 | 方法 | 路径 | 说明 | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 94a595f88..e92740654 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -629,6 +629,14 @@ The retired `app/chain/search.py` monolith, internal root re-exports and `source copies must not return. Search state normalization and persistence remain owned by `app.application.search.state`; the Chain package only adapts its cache ports. +Movies, TV and music share the state machine in `search/execution.py` and the +filter/match/context pipeline in `search/result.py`. `search/music.py` owns only +music keyword policy and compatibility forwarding methods, not another provider +loop. `MetaInfo(..., mtype=...)` chooses the resource parser; `domain/music.py` +owns pure music identity rules. Unconfirmed candidates retain their own parsed +evidence and never receive the selected target's identity. Subscription search +uses the same SearchChain instance and site-budget result handling for all media. + Media recognition orchestration is owned by the same-named `app.chain.media` package. Its root lazily exposes only the stable `MediaChain`; `facade.py` preserves the direct `MediaChain -> ChainBase` MRO, Singleton class identity and official @@ -1047,7 +1055,7 @@ driven workflow registration. | `app/application/history.py` | History use cases; deeply frozen DownloadHistory/TransferHistory DTOs and typed query/write/staging ports | | `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter | | `app/chain/download/` | Stable DownloadChain facade plus single-owner selection, submission, batch, existence, failure, history, post-processing, subtitle, task and technical-port modules | -| `app/chain/search/` | Stable SearchChain facade plus single-owner plan, provider, pagination, result, cache, title, media, music, subtitle, site and recommendation modules | +| `app/chain/search/` | Stable SearchChain facade plus shared execution, plan, provider, pagination, result, cache, title, media, music policy, subtitle, site and recommendation owners | | `app/chain/media/` | Stable MediaChain facade plus single-owner recognition, plugin, auxiliary, projection, search, catalog, path, album and bounded cache modules | | `app/db/adapters/history/transfer.py` | TransferHistory short-session snapshot/query/mutation adapter and caller-owned transaction stager | | `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 016fc2dab..138c15703 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -889,7 +889,7 @@ Purpose: Search torrent sites directly from a free-form title and optional media Purpose: Search torrent sites for one canonical media identity. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search. -- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope. +- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `include_candidates` (boolean; default `False`): Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope. - `body`: none ### `site.add` @@ -1702,6 +1702,7 @@ Subscription refresh execution status and progress summary. - `can_cancel` (boolean; default `False`): Whether the current subscription execution can be cancelled. - `current_site_id` (integer|null): Configured site ID currently handling the subscription execution. - `error` (string|null): Human-readable workflow, provider, or execution error message. +- `next_run_at` (string|null): Next scheduled subscription search time. Null when no future execution is planned. - `phase*` (string): Current phase of a subscription execution. - `source` (string|null): Exact metadata or recommendation source selected by the operation. - `state*` (string): Current site, subscription, marketplace, or transfer state filter. diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index cde0ca2e0..38800af78 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -336,17 +336,9 @@ }, "target": "app.runtime.execution.run_in_threadpool" }, - "app/chain/search/media.py:app.runtime.execution.run_in_threadpool": { + "app/chain/search/execution.py:app.runtime.execution.run_in_threadpool": { "owners": { - "SearchMediaOwner._run_media_process_async": 1, - "SearchMediaOwner.async_process_stream": 1 - }, - "target": "app.runtime.execution.run_in_threadpool" - }, - "app/chain/search/music.py:app.runtime.execution.run_in_threadpool": { - "owners": { - "SearchMusicOwner._async_process_music": 1, - "SearchMusicOwner._async_process_music_stream": 1 + "SearchExecutionOwner.events": 2 }, "target": "app.runtime.execution.run_in_threadpool" }, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 8d65905fc..bcd45dfac 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8239, - "edge_sha256": "5d2e9b412b62fe2a06ff4dc64489b32ab85d63c6355ba97432f566145b86c0d1", + "edge_count": 8259, + "edge_sha256": "f5f1d4cc3a4a6a659d1b3955995b605c8cc85be099cba76488ac3410935547cf", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2241,7 +2241,6 @@ "app.api.endpoints.download -> app.domain.media", "app.api.endpoints.download -> app.domain.meta", "app.api.endpoints.download -> app.domain.meta.metabase", - "app.api.endpoints.download -> app.domain.meta.metamusic", "app.api.endpoints.download -> app.domain.metainfo", "app.api.endpoints.download -> app.schemas", "app.api.endpoints.download -> app.schemas.common", @@ -4317,6 +4316,7 @@ "app.chain.media.search -> app.domain.context", "app.chain.media.search -> app.domain.meta", "app.chain.media.search -> app.domain.meta.metabase", + "app.chain.media.search -> app.domain.meta.metamusic", "app.chain.media.search -> app.domain.metainfo", "app.chain.media.search -> app.domain.title", "app.chain.media.search -> app.runtime", @@ -4432,6 +4432,19 @@ "app.chain.search.cache -> app.schemas.types", "app.chain.search.contract -> app.chain", "app.chain.search.contract -> app.chain.base", + "app.chain.search.execution -> app.chain", + "app.chain.search.execution -> app.chain.media", + "app.chain.search.execution -> app.chain.search", + "app.chain.search.execution -> app.chain.search.contract", + "app.chain.search.execution -> app.chain.search.plan", + "app.chain.search.execution -> app.domain", + "app.chain.search.execution -> app.domain.context", + "app.chain.search.execution -> app.domain.metainfo", + "app.chain.search.execution -> app.runtime", + "app.chain.search.execution -> app.runtime.execution", + "app.chain.search.execution -> app.runtime.log", + "app.chain.search.execution -> app.schemas", + "app.chain.search.execution -> app.schemas.mediaserver", "app.chain.search.facade -> app.application", "app.chain.search.facade -> app.application.subscription", "app.chain.search.facade -> app.application.subscription.sitebudget", @@ -4460,20 +4473,15 @@ "app.chain.search.media -> app.chain.media", "app.chain.search.media -> app.chain.search", "app.chain.search.media -> app.chain.search.contract", + "app.chain.search.media -> app.chain.search.execution", "app.chain.search.media -> app.domain", "app.chain.search.media -> app.domain.context", - "app.chain.search.media -> app.domain.metainfo", "app.chain.search.media -> app.runtime", - "app.chain.search.media -> app.runtime.execution", "app.chain.search.media -> app.runtime.log", "app.chain.search.media -> app.schemas", "app.chain.search.media -> app.schemas.media", "app.chain.search.media -> app.schemas.mediaserver", "app.chain.search.media -> app.schemas.types", - "app.chain.search.music -> app.application", - "app.chain.search.music -> app.application.configuration", - "app.chain.search.music -> app.application.torrent", - "app.chain.search.music -> app.application.torrent.download", "app.chain.search.music -> app.chain", "app.chain.search.music -> app.chain.search", "app.chain.search.music -> app.chain.search.contract", @@ -4481,10 +4489,9 @@ "app.chain.search.music -> app.domain.context", "app.chain.search.music -> app.domain.meta", "app.chain.search.music -> app.domain.meta.metamusic", + "app.chain.search.music -> app.domain.music", "app.chain.search.music -> app.foundation", "app.chain.search.music -> app.foundation.text", - "app.chain.search.music -> app.runtime", - "app.chain.search.music -> app.runtime.execution", "app.chain.search.music -> app.schemas", "app.chain.search.music -> app.schemas.types", "app.chain.search.pagination -> app.application", @@ -4500,8 +4507,10 @@ "app.chain.search.plan -> app.chain", "app.chain.search.plan -> app.chain.search", "app.chain.search.plan -> app.chain.search.contract", + "app.chain.search.plan -> app.chain.search.music", "app.chain.search.plan -> app.domain", "app.chain.search.plan -> app.domain.context", + "app.chain.search.plan -> app.domain.metainfo", "app.chain.search.plan -> app.schemas", "app.chain.search.plan -> app.schemas.media", "app.chain.search.plan -> app.schemas.mediaserver", @@ -4549,7 +4558,9 @@ "app.chain.search.result -> app.domain.context", "app.chain.search.result -> app.domain.meta", "app.chain.search.result -> app.domain.meta.metabase", + "app.chain.search.result -> app.domain.meta.metamusic", "app.chain.search.result -> app.domain.metainfo", + "app.chain.search.result -> app.domain.music", "app.chain.search.result -> app.runtime", "app.chain.search.result -> app.runtime.log", "app.chain.search.result -> app.runtime.progress", @@ -4593,8 +4604,6 @@ "app.chain.search.title -> app.chain.search.contract", "app.chain.search.title -> app.domain", "app.chain.search.title -> app.domain.context", - "app.chain.search.title -> app.domain.meta", - "app.chain.search.title -> app.domain.meta.metamusic", "app.chain.search.title -> app.domain.metainfo", "app.chain.search.title -> app.runtime", "app.chain.search.title -> app.runtime.execution", @@ -4773,10 +4782,13 @@ "app.chain.subscribe.metadata -> app.chain.subscribe.identity", "app.chain.subscribe.metadata -> app.domain", "app.chain.subscribe.metadata -> app.domain.context", + "app.chain.subscribe.metadata -> app.domain.meta", + "app.chain.subscribe.metadata -> app.domain.meta.metabase", "app.chain.subscribe.metadata -> app.runtime", "app.chain.subscribe.metadata -> app.runtime.log", "app.chain.subscribe.metadata -> app.schemas", "app.chain.subscribe.metadata -> app.schemas.media", + "app.chain.subscribe.metadata -> app.schemas.mediaserver", "app.chain.subscribe.metadata -> app.schemas.types", "app.chain.subscribe.notify -> app.application", "app.chain.subscribe.notify -> app.application.configuration", @@ -4890,7 +4902,6 @@ "app.chain.subscribe.search -> app.chain.search.facade", "app.chain.subscribe.search -> app.chain.subscribe", "app.chain.subscribe.search -> app.chain.subscribe.contract", - "app.chain.subscribe.search -> app.chain.subscribe.identity", "app.chain.subscribe.search -> app.chain.subscribe.metadata", "app.chain.subscribe.search -> app.chain.subscribe.searchtask", "app.chain.subscribe.search -> app.domain", @@ -5945,6 +5956,14 @@ "app.domain.metainfo -> app.schemas", "app.domain.metainfo -> app.schemas.media", "app.domain.metainfo -> app.schemas.types", + "app.domain.music -> app.domain", + "app.domain.music -> app.domain.context", + "app.domain.music -> app.domain.meta", + "app.domain.music -> app.domain.meta.metamusic", + "app.domain.music -> app.foundation", + "app.domain.music -> app.foundation.text", + "app.domain.music -> app.schemas", + "app.domain.music -> app.schemas.types", "app.domain.projection.anilist -> app.domain", "app.domain.projection.anilist -> app.domain.metainfo", "app.domain.projection.anilist -> app.domain.projection", @@ -6896,6 +6915,7 @@ "app.modules.musicbrainz -> app.domain.meta", "app.modules.musicbrainz -> app.domain.meta.metabase", "app.modules.musicbrainz -> app.domain.meta.metamusic", + "app.modules.musicbrainz -> app.domain.music", "app.modules.musicbrainz -> app.foundation", "app.modules.musicbrainz -> app.foundation.text", "app.modules.musicbrainz -> app.modules", @@ -9332,7 +9352,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 974, + "module_count": 976, "modules": [ "app", "app.adapters", @@ -9715,6 +9735,7 @@ "app.chain.search", "app.chain.search.cache", "app.chain.search.contract", + "app.chain.search.execution", "app.chain.search.facade", "app.chain.search.media", "app.chain.search.music", @@ -9892,6 +9913,7 @@ "app.domain.meta.streamingplatform", "app.domain.meta.words", "app.domain.metainfo", + "app.domain.music", "app.domain.plugin", "app.domain.projection", "app.domain.projection.anilist", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 7f2c7ae0b..c3bff68fe 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -428,8 +428,8 @@ "no-untyped-call": 2 }, "app/api/endpoints/download.py": { - "arg-type": 7, - "assignment": 6, + "arg-type": 6, + "assignment": 5, "attr-defined": 4, "misc": 10, "type-arg": 2 @@ -860,7 +860,7 @@ "type-arg": 2 }, "app/chain/base.py": { - "assignment": 13, + "assignment": 12, "attr-defined": 1, "no-any-return": 47, "no-untyped-def": 7, @@ -983,7 +983,7 @@ "var-annotated": 2 }, "app/chain/subscribe/search.py": { - "assignment": 2 + "assignment": 1 }, "app/chain/system.py": { "attr-defined": 1, @@ -998,7 +998,7 @@ "type-arg": 2 }, "app/chain/torrents.py": { - "arg-type": 22, + "arg-type": 21, "assignment": 2, "no-untyped-call": 1, "no-untyped-def": 15, @@ -2924,7 +2924,7 @@ "var-annotated": 1 }, "app/workflow/actions/filter_torrents.py": { - "arg-type": 3, + "arg-type": 2, "list-item": 1, "no-untyped-call": 1, "type-arg": 1, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 2f951aac4..249a0f32d 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -147,9 +147,6 @@ "app/domain/meta/metabase.py": { "I001": 1 }, - "app/domain/meta/metamusic.py": { - "I001": 1 - }, "app/domain/meta/metavideo.py": { "I001": 1 }, @@ -167,9 +164,6 @@ "E731": 1, "I001": 1 }, - "app/domain/metainfo.py": { - "I001": 1 - }, "app/domain/scraper.py": { "I001": 1 }, @@ -785,9 +779,6 @@ "tests/test_media_scrape_endpoint.py": { "I001": 1 }, - "tests/test_media_search_source_selection.py": { - "I001": 1 - }, "tests/test_media_source_signature_compatibility.py": { "I001": 1 }, @@ -851,9 +842,6 @@ "tests/test_music_torrents.py": { "I001": 1 }, - "tests/test_musicbrainz_module.py": { - "I001": 1 - }, "tests/test_navidrome_module.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 0a26bf18d..2a922e542 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 537, + "loaded_app_module_count": 539, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 549, + "loaded_app_module_count": 551, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 551, + "loaded_app_module_count": 553, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_chain_vertical_slices.py b/tests/test_chain_vertical_slices.py index 4a90428ed..8f04a8da4 100644 --- a/tests/test_chain_vertical_slices.py +++ b/tests/test_chain_vertical_slices.py @@ -51,8 +51,10 @@ from app.chain.transfer.facade import TransferChain MediaChain, { "normalize_music_candidates": "MusicCatalogService", - "search_music": "_music_catalog", - "async_search_music": "_music_catalog", + "search": "_music_catalog", + "async_search": "_music_catalog", + "search_music": "self.search", + "async_search_music": "self.async_search", }, ), ( diff --git a/tests/test_media_compat.py b/tests/test_media_compat.py index afde97e25..b4ea23069 100644 --- a/tests/test_media_compat.py +++ b/tests/test_media_compat.py @@ -95,6 +95,8 @@ def test_media_chain_preserves_official_plugin_method_contracts() -> None: ("self", inspect.Parameter.empty), ("title", inspect.Parameter.empty), ("media_source", None), + ("mtype", None), + ("limit", 20), ) assert _parameter_contract(MediaChain.async_search) == _parameter_contract(MediaChain.search) assert inspect.iscoroutinefunction(MediaChain.async_search) diff --git a/tests/test_media_search_pipeline.py b/tests/test_media_search_pipeline.py new file mode 100644 index 000000000..79fb3edbe --- /dev/null +++ b/tests/test_media_search_pipeline.py @@ -0,0 +1,85 @@ +"""三种媒体、三种 I/O 模式必须使用同一搜索执行及资源证据处理流程。""" + +import asyncio +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from app.chain.search import SearchChain, execution, media +from app.domain.context import MediaInfo, MusicInfo, TorrentInfo +from app.domain.meta.metamusic import MetaMusic +from app.schemas.types import MediaSource, MediaType + + +@pytest.mark.parametrize("mtype", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC]) +@pytest.mark.parametrize("mode", ["sync", "async", "stream"]) +def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtype, mode): + """已召回但被过滤的资源不能终止任何媒体类型的后续查询。""" + chain = SearchChain() + chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False) + title = "Example Album" if mtype == MediaType.MUSIC else "Example Movie" + if mtype == MediaType.MUSIC: + target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="album", music_type="album", + title=title, artists=["Artist"], year=2024) + resource_title = "Artist - Example Album (2024) FLAC" + else: + target = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1, + title=title, names=[title], type=mtype, year="2024") + if mtype == MediaType.TV: + target.season_years = {1: "2024"} + resource_title = f"Example.Movie.2024{' S01E01' if mtype == MediaType.TV else ''}.1080p" + calls = [] + + def search(**kwargs): + """首轮未达到过滤要求,第二轮返回相同作品的可用资源。""" + calls.append(kwargs["keyword"]) + return [TorrentInfo(title=resource_title, category=mtype.value, + labels=[] if len(calls) == 1 else ["SITE_ACCEPT"])] + + 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): + """跳过测试中的退避等待。""" + + provider = SimpleNamespace(supplement_media_info=lambda mediainfo: mediainfo, + async_supplement_media_info=supplement) + monkeypatch.setattr(media, "MediaChain", lambda: provider) + 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 = {"mediainfo": target, "rule_groups": [], "filter_params": {"include": "SITE_ACCEPT"}} + 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)] + + events = asyncio.run(collect()) + for event in events: + if event["type"] == "append": + assert all(item["media_info"] is None for item in event["items"]) + assert events[-1]["candidate_items"] == 2 + contexts = events[-1]["contexts"] + assert calls == ["first", "second"] + assert len(contexts) == 1 + assert contexts[0].match_status == "exact" + 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) diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py index e1a62e585..8018153d0 100644 --- a/tests/test_media_search_source_selection.py +++ b/tests/test_media_search_source_selection.py @@ -5,10 +5,10 @@ import httpx import pytest from fastapi import FastAPI +from app.adapters.web.security.access import verify_token from app.api.endpoints import media as media_endpoints from app.api.endpoints.media import search from app.chain.base import ChainBase -from app.adapters.web.security.access import verify_token from app.modules.douban import DoubanModule from app.modules.themoviedb import TheMovieDbModule from app.schemas.types import MediaSource, MediaType @@ -71,7 +71,7 @@ def test_media_search_endpoint_forwards_multi_source() -> None: async def test_media_search_route_accepts_comma_separated_music_sources() -> None: """真实路由在旧逗号格式兼容边界后应把每项转换为 MediaSource。""" chain = Mock() - chain.async_search_music = AsyncMock(return_value=[]) + chain.async_search = AsyncMock(return_value=(None, [])) app = FastAPI() app.include_router(media_endpoints.router, prefix="/api/v1/media") app.dependency_overrides[verify_token] = lambda: Mock() @@ -93,8 +93,9 @@ async def test_media_search_route_accepts_comma_separated_music_sources() -> Non assert response.status_code == 200 assert response.json() == {"success": True, "message": "", "data": []} - chain.async_search_music.assert_awaited_once_with( - query="周杰伦", + chain.async_search.assert_awaited_once_with( + title="周杰伦", + mtype=MediaType.MUSIC, limit=30, media_source=( MediaSource.MusicBrainz, diff --git a/tests/test_music_catalog_service.py b/tests/test_music_catalog_service.py index 98ecc3e99..f61b9fddb 100644 --- a/tests/test_music_catalog_service.py +++ b/tests/test_music_catalog_service.py @@ -50,3 +50,12 @@ def test_music_catalog_service_isolates_failed_source(): assert service.search("artist title") == [] assert errors and "broken" in errors[0] + + +def test_music_catalog_merge_keeps_later_source_with_full_first_page(): + """第一来源达到条数上限也不能挤掉后来来源的准确目标。""" + first = [MusicInfo(media_source=MediaSource.MusicBrainz, media_id=str(index), title=f"Other {index}") for index in range(30)] + second = MusicInfo(media_source=MediaSource.DoubanMusic, media_id="exact", title="Target") + result = MusicCatalogService.merge_sources([first, [second]], limit=30) + assert len(result) == 30 + assert result[1] is second diff --git a/tests/test_music_download.py b/tests/test_music_download.py index 1ebcb0f05..a0e9c3415 100644 --- a/tests/test_music_download.py +++ b/tests/test_music_download.py @@ -170,6 +170,28 @@ def test_download_endpoint_builds_music_context(): assert context.media_info.media_id == "recording-1" assert context.meta_info.type == MediaType.MUSIC assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC" + assert context.meta_info.title == "叶惠美" + assert context.meta_info.media_id is None + + +def test_manual_album_without_id_uses_subtitle_evidence(): + """没有 ID 的人工专辑下载保留实体意图,并使用副标题中的真实艺人识别。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = _album_info() + download_chain = Mock() + download_chain.download_single.return_value = "album-task" + with patch("app.api.endpoints.download.MediaChain", return_value=media_chain), patch( + "app.api.endpoints.download.DownloadChain", return_value=download_chain + ): + response = add( + torrent_in=TorrentInfo(title="叶惠美 FLAC", description="艺术家:周杰伦", category="未知"), + music_type="album", current_user=Mock(name="admin"), + ) + assert response.success is True + meta = media_chain.recognize_by_meta.call_args.args[0] + assert meta.artists == ["周杰伦"] + assert meta.title == "叶惠美" + assert media_chain.recognize_by_meta.call_args.kwargs["music_type"] == "album" def test_download_add_forwards_album_namespace_to_media_chain(): diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index 0f52fee50..17115203d 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -61,12 +61,12 @@ def test_music_routes_are_registered(): ) -def test_media_search_routes_music_queries_with_query_kwarg(): - """统一媒体搜索的音乐分支应以关键字参数调用 MediaChain。""" +def test_media_search_routes_music_through_common_catalog_entry(): + """音乐与影视应调用同一媒体搜索入口,仅传入不同的媒体类型。""" chain = Mock() - chain.async_search_music = AsyncMock( - return_value=[ + chain.async_search = AsyncMock( + return_value=(None, [ MusicInfo( media_source="musicbrainz", media_id="recording-1", @@ -76,7 +76,7 @@ def test_media_search_routes_music_queries_with_query_kwarg(): release_date="2003-07-31", category="Album / Studio", ) - ] + ]) ) with ( @@ -95,14 +95,14 @@ def test_media_search_routes_music_queries_with_query_kwarg(): assert result[0]["media_id"] == "recording-1" assert result[0]["music_type"] == "recording" assert result[0]["title"] == "晴天" - chain.async_search_music.assert_awaited_once_with(query="晴天", limit=30) + chain.async_search.assert_awaited_once_with(title="晴天", limit=30, mtype=MediaType.MUSIC, media_source=None) media_chain.assert_called_once() def test_media_search_forwards_explicit_music_source(): """统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。""" chain = Mock() - chain.async_search_music = AsyncMock(return_value=[]) + chain.async_search = AsyncMock(return_value=(None, [])) with patch.object(media_endpoints, "MediaChain", return_value=chain): result = asyncio.run( @@ -116,8 +116,9 @@ def test_media_search_forwards_explicit_music_source(): ) assert result == [] - chain.async_search_music.assert_awaited_once_with( - query="Coldplay", + chain.async_search.assert_awaited_once_with( + title="Coldplay", + mtype=MediaType.MUSIC, limit=20, media_source=(MediaSource.TheAudioDB,), ) diff --git a/tests/test_music_matching.py b/tests/test_music_matching.py new file mode 100644 index 000000000..c607dddfe --- /dev/null +++ b/tests/test_music_matching.py @@ -0,0 +1,192 @@ +"""音乐搜索的名称边界、别名和人工候选回归测试。""" + +import copy +import pickle + +import pytest + +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +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.music import match_music_resource + + +@pytest.mark.parametrize("title", ["U2 - One Tree Hill FLAC", "U2 - Someone FLAC", "U2 - One - Tree Hill FLAC"]) +def test_music_match_rejects_other_titles(title): + """短曲名不能将较长的另一首作品判为同一单曲。""" + assert match_music_resource(MusicInfo(title="One", artists=["U2"]), title).status == "rejected" + + +@pytest.mark.parametrize("artist", ["Jay Chou", "周杰倫"]) +def test_music_match_accepts_source_artist_aliases(artist): + """同一艺术家来源别名和繁简署名均可命中。""" + music = MusicInfo(title="晴天", artists=["周杰伦"], artist_aliases=["Jay Chou"]) + assert match_music_resource(music, f"{artist} - 晴天 FLAC").status == "exact" + + +@pytest.mark.parametrize("artist", ["VA", "V.A.", "群星"]) +def test_music_match_accepts_compilation_credit(artist): + """合辑通用艺术家署名不应造成漏搜。""" + music = MusicInfo(music_type="album", title="Test Compilation", artists=["Various Artists"]) + assert match_music_resource(music, f"{artist} - Test Compilation FLAC").status == "exact" + + +@pytest.mark.parametrize("title,reason", [ + ("Jay Chou - 晴天 FLAC", "artist_unverified"), + ("周杰伦 - 晴天 (Live) FLAC", "version_mismatch"), +]) +def test_music_match_uncertain_identity_requires_confirmation(title, reason): + """未核验艺名和不同录音版本只能交给用户确认。""" + result = match_music_resource(MusicInfo(title="晴天", artists=["周杰伦"]), title) + assert (result.status, result.reason) == ("candidate", reason) + + +def test_music_match_distinguishes_missing_evidence_and_related_album(): + """缺艺术家、缺分类和所属专辑均不得直接绑定成目标单曲。""" + music = MusicInfo(title="Get Lucky", artists=["Daft Punk"], album="Random Access Memories") + assert match_music_resource(music, "Daft Punk - Random Access Memories FLAC").status == "album" + assert match_music_resource(music, "Daft Punk - Get Lucky FLAC", category=None).reason == "category_unknown" + assert match_music_resource(MusicInfo(title="晴天"), "周杰伦 - 晴天 FLAC").reason == "target_artist_missing" + + +def test_music_match_handles_accents_and_edition_suffix(): + """变音符不改变身份,缺少明确的目标发行版本则必须人工确认。""" + assert match_music_resource(MusicInfo(title="Halo", artists=["Beyoncé"]), "Beyonce - Halo FLAC").status == "exact" + album = MusicInfo(music_type="album", title="Test Album (Deluxe Edition)", artists=["Artist"]) + assert match_music_resource(album, "Artist - Test Album FLAC").reason == "edition_unverified" + + +def test_resource_parser_merges_subtitle_without_target_information(): + """副标题补全艺术家、专辑和音质,资源元数据不携带目标 ID。""" + meta = MetaMusic.parse_resource("晴天 FLAC", "演唱:周杰伦;专辑:叶惠美;24bit 96kHz") + assert meta.title == "晴天" + assert meta.artists == ["周杰伦"] + assert meta.album == "叶惠美" + assert meta.bit_depth == 24 + assert meta.media_id is None + + +def test_resource_parser_preserves_title_evidence_and_parses_track_segments(): + """冲突副标题不能覆盖标题艺术家,明确的曲序段则可用于区分专辑和单曲。""" + meta = MetaMusic.parse_resource("Artist - Album - 01 - Song [FLAC]", "演唱:Other Artist") + assert (meta.artists, meta.album, meta.track_number, meta.title) == (["Artist"], "Album", 1, "Song") + live = MetaMusic.parse_resource("U2 - One (Live) FLAC") + assert live.version == "Live" + + +def test_resource_parser_keeps_bracketed_title(): + """纯展示括号内的作品名不能与规格标签一同丢弃。""" + meta = MetaMusic.parse_resource("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健;無損音樂") + assert meta.title and "永遠" in meta.title + assert meta.artists == ["周華健"] + + +def test_music_album_field_cannot_match_target_recording(): + """结构化资源中的所属专辑不能冒充同名目标单曲。""" + music = MusicInfo(title="Album", artists=["Artist"]) + assert match_music_resource(music, "Artist - Album - 01 - Other Song FLAC").status == "rejected" + wanted = MusicInfo(title="Wanted Song", album="Album", artists=["Artist"]) + assert match_music_resource(wanted, "Artist - Album - 01 - Other Song FLAC").status == "rejected" + + +def test_music_artist_in_subtitle_cannot_override_conflicting_title_credit(): + """另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。""" + music = MusicInfo(title="One", artists=["U2"]) + assert match_music_resource(music, "Metallica - One FLAC", "Related artist: U2").reason == "artist_unverified" + + +def test_artist_name_is_not_a_recording_version(): + """艺人名称含 Live 时,不能把署名误当作现场录音标记。""" + music = MusicInfo(title="Song", artists=["Live"]) + assert match_music_resource(music, "Live - Song FLAC").status == "exact" + + +def test_compilation_album_credit_does_not_prove_track_artist(): + """单曲已经有明确艺人时,整专的合辑署名不构成同一录音的身份依据。""" + music = MusicInfo(title="Song", artists=["Artist"], album_artist="Various Artists") + assert match_music_resource(music, "VA - Song FLAC").status == "candidate" + + +def test_distinct_album_artist_is_not_a_recording_artist_alias(): + """演唱者与专辑艺术家分属不同实体时,不把专辑署名视为演唱者别名。""" + music = MusicInfo(title="Song", artists=["Performer"], album_artist="Album Artist") + assert match_music_resource(music, "Album Artist - Song FLAC").status == "candidate" + + +def test_simplification_does_not_merge_recording_and_album_artist_aliases(): + """文本展示转换不能把另一位专辑艺术家混入录音艺术家别名。""" + original = MusicInfo(title="晴天", artists=["周杰倫"], album_artist="周華健") + simplified = MediaChain._simplify_recognized_music_info(original) + assert simplified.artist_aliases == ["周杰倫"] + + +@pytest.mark.parametrize("artist", ["AC/DC", "Earth, Wind & Fire"]) +def test_compound_artist_credit_preserves_name_punctuation(artist): + """已知完整艺名含分隔符时,不能因资源解析拆段而误判为另一位艺人。""" + music = MusicInfo(title="Song", artists=[artist]) + assert match_music_resource(music, f"{artist} - Song FLAC").status == "exact" + + +@pytest.mark.parametrize("python_only", [False, True]) +def test_resource_evidence_matches_python_and_native_parser_paths(monkeypatch, python_only): + """资源级补充规则必须同时适用于 Python 回退和真实 Rust 标题解析。""" + if python_only: + monkeypatch.setattr("app.domain.meta.metamusic.get_metainfo_accelerator", lambda: None) + else: + accelerator = get_metainfo_accelerator() + if not accelerator or not accelerator.parse_metamusic("Artist - Song FLAC"): + pytest.skip("当前环境没有可用的 Rust 音乐解析器") + meta = MetaMusic.parse_resource("Artist - Album - 01 - Song FLAC", "24bit 96kHz") + assert (meta.title, meta.album, meta.track_number) == ("Song", "Album", 1) + assert meta.artists == ["Artist"] + assert (meta.bit_depth, meta.sample_rate) == (24, 96000) + bracketed = MetaMusic.parse_resource("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健") + assert bracketed.title and "永遠" in bracketed.title + assert bracketed.artists == ["周華健"] + + +@pytest.mark.parametrize("factory", [MusicInfo, MusicAlbumInfo]) +def test_old_music_cache_restores_alias_defaults(factory): + """旧 pickle 没有新增字段时应恢复空列表,继续支持标准序列化和专辑投影。""" + info = factory(title="Album", artists=["Artist"]) + for field in ("title_aliases", "artist_aliases", "album_aliases"): + info.__dict__.pop(field, None) + restored = pickle.loads(pickle.dumps(info)) + assert restored.to_dict()["title_aliases"] == [] + if isinstance(restored, MusicAlbumInfo): + assert restored.to_music_info().artist_aliases == [] + + +def test_clearing_music_context_does_not_mutate_source_snapshot(): + """共用结果裁剪只清理副本,不能清空模块缓存或调用方仍持有的原始响应。""" + original = MusicInfo(title="Album", raw_data={"id": "source"}) + duplicate = copy.copy(original) + duplicate.clear() + assert duplicate.raw_data == {} + assert original.raw_data == {"id": "source"} + + +def test_music_simplification_preserves_original_search_names(): + """展示繁转简后必须仍能按原始标题与艺术家检索。""" + original = MusicInfo(music_type="album", title="永遠是朋友", album="永遠是朋友", artists=["周華健"]) + simplified = MediaChain._simplify_recognized_music_info(original) + keywords = SearchChain.music_site_keywords(simplified) + assert keywords[:2] == ["永远是朋友", "永遠是朋友"] + assert "周華健" in simplified.artist_aliases + assert MusicInfo.from_dict(simplified.to_dict()).title_aliases == ["永遠是朋友"] + + +def test_music_album_alias_is_used_for_search(): + """匹配接受的作品别名也必须进入实际站点查询阶梯。""" + music = MusicInfo(music_type="album", title="Ye Hui Mei", title_aliases=["叶惠美"], artists=["Jay Chou"]) + assert "叶惠美" in SearchChain.music_site_keywords(music) + + +def test_automatic_batch_download_excludes_manual_music_candidates(): + """人工候选即使被传入批量入口,也不得自动交给下载器。""" + chain = object.__new__(DownloadChain) + assert chain._execute_batch_download([Context(match_status="candidate")]) == ([], None) + assert chain._execute_batch_download([Context(match_status="candidate", match_reason="related_album")]) == ([], None) diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index a0fbad8f5..92ae43bdf 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -342,6 +342,22 @@ def test_media_chain_rejects_cross_entity_detail_result(monkeypatch): assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM +def test_default_music_source_preserves_explicit_album_intent(monkeypatch): + """没有原生 ID 或显式来源时,用户选择的专辑实体不能被默认单曲模式覆盖。""" + chain = MediaChain() + expected = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="album-1", + music_type=MUSIC_ENTITY_ALBUM, title="Album", artists=["Artist"]) + recognize = Mock(return_value=expected) + monkeypatch.setattr(chain, "recognize_music_from_source", recognize) + with patch("app.adapters.external.server.MoviePilotServerHelper.report_recognize_share"), \ + patch("app.adapters.external.server.MoviePilotServerHelper.query_recognize_share", return_value=None): + result = chain.recognize_media(meta=MetaMusic(title="Album", artists=["Artist"]), + mtype=MediaType.MUSIC, music_type=MUSIC_ENTITY_ALBUM) + assert result.music_type == MUSIC_ENTITY_ALBUM + assert recognize.call_args.kwargs["media_source"] == MediaSource.MusicBrainz + assert recognize.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM + + def test_music_metadata_simplified_conversion_defaults_to_enabled(): """音乐识别结果转简体开关应默认开启。""" assert ConfigModel.model_fields["MUSIC_METADATA_TO_SIMPLIFIED"].default is True diff --git a/tests/test_music_search.py b/tests/test_music_search.py index 3a0844cf5..70bdbe2f6 100644 --- a/tests/test_music_search.py +++ b/tests/test_music_search.py @@ -1,6 +1,8 @@ import asyncio from dataclasses import replace -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch + +import pytest from app.chain.search import SearchChain from app.domain.context import MusicInfo, TorrentInfo @@ -8,6 +10,86 @@ from app.domain.meta.metamusic import MetaMusic from app.schemas.types import MediaSource, MediaType +@pytest.mark.parametrize("mode", ["sync", "async", "stream"]) +def test_music_search_keeps_searching_after_final_audio_filter(mode): + """首轮名称命中但音质不符时,三条入口均须继续查询艺术家组合词。""" + chain = SearchChain() + chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False) + target = MusicInfo(music_type="album", title="Test Album", artists=["Test Artist"]) + calls = [] + + def batch(**kwargs): + """首轮模拟有损音质,后续组合词返回无损资源。""" + calls.append(kwargs["keyword"]) + codec = "MP3" if len(calls) == 1 else "FLAC" + return [TorrentInfo(title=f"Test Artist - Test Album {codec}", category=MediaType.MUSIC.value)] + + async def stream(**kwargs): + """从同一站点样本生成进度事件。""" + yield {"items": batch(**kwargs), "stage": "searching", "value": 100} + + async def async_batch(**kwargs): + """普通异步端口与流式端口读取相同站点样本。""" + return batch(**kwargs) + + async def collect(): + """调用对应异步入口并返回最终结果。""" + params = {"mediainfo": target, "rule_groups": [], "filter_params": {"audio_format": "FLAC"}} + if mode == "async": + return await chain._async_process_music(**params) + events = [event async for event in chain._async_process_music_stream(**params)] + assert events[-1]["candidate_items"] == 2 + assert events[-1]["match_counts"]["filter_params"] == 1 + return events[-1]["contexts"] + + with ( + patch.object(chain, "_SearchChain__search_all_sites", side_effect=batch), + patch.object(chain, "_SearchChain__async_search_all_sites", side_effect=async_batch), + patch.object(chain, "_SearchChain__async_search_all_sites_stream", side_effect=stream), + patch("app.chain.search.execution.time.sleep"), + patch("app.chain.search.execution.asyncio.sleep", new=AsyncMock()), + ): + results = chain._process_music(target, rule_groups=[], filter_params={"audio_format": "FLAC"}) \ + if mode == "sync" else asyncio.run(collect()) + assert calls == ["Test Album", "Test Artist Test Album"] + assert len(results) == 1 + assert results[0].meta_info.audio_format == "FLAC" + + +def test_music_manual_candidates_keep_resource_identity_separate(): + """来源署名未经验证时只展示资源自身信息,不能回填成所选单曲。""" + chain = SearchChain() + target = MusicInfo(media_source="musicbrainz", media_id="target", title="晴天", artists=["周杰伦"]) + torrent = TorrentInfo(title="Jay Chou - 晴天 FLAC", category=MediaType.MUSIC.value) + assert chain._build_music_contexts([torrent], target, rule_groups=[]) == [] + candidate = chain._build_music_contexts([torrent], target, rule_groups=[], include_candidates=True)[0] + assert candidate.match_reason == "artist_unverified" + assert candidate.media_info is None + assert candidate.meta_info.artists == ["Jay Chou"] + assert candidate.meta_info.media_id is None + assert candidate.media_info_is_target is False + + +def test_music_stream_counts_rejected_site_results(): + """分类和名称不符仍属于站点原始候选,空态应能说明为何没有最终结果。""" + chain = SearchChain() + target = MusicInfo(title="One", artists=["U2"]) + + async def batches(**_kwargs): + """返回一条属于其他作品的候选。""" + yield {"items": [TorrentInfo(title="U2 - One Tree Hill", category=MediaType.MUSIC.value)]} + + async def collect(): + """使用显式关键词把样例限定为一次站点查询。""" + return [event async for event in chain._async_process_music_stream(target, keyword="One", rule_groups=[])] + + with patch.object(chain, "_SearchChain__async_search_all_sites_stream", side_effect=batches): + events = asyncio.run(collect()) + assert events[-1]["candidate_items"] == 1 + assert events[-1]["total_items"] == 0 + assert events[-1]["match_counts"] == {"title_mismatch": 1} + + def test_music_context_builder_keeps_only_music_category(): """精确音乐搜索只应保留明确标记为音乐分类的站点资源。""" chain = SearchChain() @@ -36,7 +118,7 @@ def test_music_context_builder_keeps_only_music_category(): ), ] - with patch.object(chain, "filter_torrents", return_value=torrents[:1]): + with patch.object(chain, "filter_torrents", side_effect=lambda **kwargs: kwargs["torrent_list"][:1]): contexts = chain._build_music_contexts( torrents=torrents, mediainfo=music, @@ -44,9 +126,11 @@ def test_music_context_builder_keeps_only_music_category(): ) assert len(contexts) == 1 - assert contexts[0].media_info is music + assert contexts[0].media_info is not music assert isinstance(contexts[0].meta_info, MetaMusic) - assert contexts[0].meta_info.media_id == "recording-1" + assert contexts[0].meta_info.media_id is None + assert contexts[0].meta_info.title == "Get Lucky - Random Access Memories" + assert contexts[0].media_info.media_id == "recording-1" assert contexts[0].torrent_info.category == MediaType.MUSIC.value @@ -75,7 +159,7 @@ def test_music_search_continues_after_unrelated_first_keyword_results(): chain, "_SearchChain__search_all_sites", side_effect=[[unrelated], [matched]], - ) as search_sites, patch("app.chain.search.music.time.sleep"): + ) as search_sites, patch("app.chain.search.execution.time.sleep"): contexts = chain._process_music(music, rule_groups=[]) assert search_sites.call_count == 2 @@ -104,7 +188,7 @@ def test_music_search_uses_simplified_keywords_before_original_traditional_keywo chain, "_SearchChain__search_all_sites", side_effect=[[], [matched]], - ) as search_sites, patch("app.chain.search.music.time.sleep"): + ) as search_sites, patch("app.chain.search.execution.time.sleep"): contexts = chain._process_music(music, rule_groups=[]) assert [call.kwargs["keyword"] for call in search_sites.call_args_list] == [ diff --git a/tests/test_music_search_api.py b/tests/test_music_search_api.py new file mode 100644 index 000000000..48a5986ca --- /dev/null +++ b/tests/test_music_search_api.py @@ -0,0 +1,59 @@ +"""人工音乐候选从 HTTP 参数到共用搜索、结果序列化的集成回归。""" + +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from fastapi import FastAPI + +from app.adapters.web.security.access import verify_token +from app.api.endpoints import search as endpoint +from app.chain.search import media +from app.chain.search.facade import SearchChain +from app.domain.context import MusicInfo, TorrentInfo +from app.schemas.types import MediaSource, MediaType + + +@pytest.mark.anyio +@pytest.mark.parametrize("include_candidates", [False, True]) +async def test_music_manual_candidate_opt_in_survives_http_and_serialization(include_candidates): + """默认精确搜索不会放行未核验署名,显式人工候选请求保留资源但不绑定目标。""" + media_id = "695f5ac8-cfd5-4e7b-96a0-22830c931bb0" + target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id=media_id, title="晴天", artists=["周杰伦"]) + chain = SearchChain() + chain.async_save_last_search_params = AsyncMock() + chain._async_save_results = AsyncMock() + chain.cancel_ai_recommend = Mock() + chain._SearchChain__async_search_all_sites = AsyncMock(return_value=[ + TorrentInfo(title="Jay Chou - 晴天 FLAC", category=MediaType.MUSIC.value), + ]) + metadata = Mock() + metadata.async_recognize_media = AsyncMock(return_value=target) + metadata.async_supplement_media_info = AsyncMock(side_effect=lambda mediainfo: mediainfo) + app = FastAPI() + app.include_router(endpoint.router, prefix="/api/v1/search") + app.dependency_overrides[verify_token] = lambda: Mock() + with ( + patch.object(endpoint, "SearchChain", return_value=chain), + patch.object(media, "MediaChain", return_value=metadata), + patch("app.chain.search.execution.asyncio.sleep", new=AsyncMock()), + ): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + response = await client.get(f"/api/v1/search/media/{media_id}", params={ + "media_source": "musicbrainz", "include_candidates": include_candidates, + }) + assert response.status_code == 200 + payload = response.json() + if include_candidates: + assert payload["success"] is True + assert len(payload["data"]) == 1 + candidate = payload["data"][0] + assert candidate["match_status"] == "candidate" + assert candidate["match_reason"] == "artist_unverified" + assert candidate["media_info"] is None + assert candidate["meta_info"]["artists"] == ["Jay Chou"] + assert candidate["meta_info"]["media_id"] is None + assert chain.async_save_last_search_params.call_args.kwargs["include_candidates"] is True + else: + assert payload["success"] is False + assert chain._async_save_results.call_args.args[0] == [] diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 4e406e05b..61a920a20 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -18,6 +18,7 @@ from app.application.subscription.execution import ( ) from app.application.subscription.mutation import SubscriptionMutation from app.application.subscription.sitebudget import SubscriptionSearchCancelled +from app.chain.search import SearchChain from app.chain.subscribe.facade import SubscribeChain from app.domain.context import ( MUSIC_ENTITY_ALBUM, @@ -44,6 +45,14 @@ def _music_info() -> MusicInfo: ) +def _search_chain_for_torrents(contexts: list[Context]) -> SearchChain: + """只替换站点 I/O,保留真实共用搜索、解析、过滤和匹配流程。""" + chain = SearchChain() + chain._SearchChain__search_all_sites = Mock(return_value=[context.torrent_info for context in contexts]) + chain.process = Mock(wraps=chain.process) + return chain + + def _subscribe(**overrides) -> SimpleNamespace: """构造不依赖数据库的音乐订阅对象。""" values = dict( @@ -52,6 +61,7 @@ def _subscribe(**overrides) -> SimpleNamespace: year="2003", type=MediaType.MUSIC.value, keyword=None, + search_imdbid=False, media_source="musicbrainz", media_id="recording-1", mediaid=None, @@ -196,10 +206,9 @@ def test_music_subscribe_reuses_search_download_and_finish_flow(): category=MediaType.MUSIC.value, ) ) - search_chain = Mock() - search_chain.search_by_title.side_effect = [[context], []] + search_chain = _search_chain_for_torrents([context]) download_chain = Mock() - download_chain.batch_download.return_value = ([context], None) + download_chain.batch_download.side_effect = lambda **kwargs: (kwargs["contexts"], None) chain = SubscribeChain() chain.finish_subscribe_or_not = Mock() chain.check_and_handle_existing_media = Mock(return_value=(False, {})) @@ -209,17 +218,17 @@ def test_music_subscribe_reuses_search_download_and_finish_flow(): chain.subscription_repository = subscribe_oper with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \ - patch("app.chain._music.SearchChain", return_value=search_chain), \ + patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \ patch("app.chain._music.DownloadChain", return_value=download_chain): chain._search_music_subscribe(subscribe) - search_chain.search_by_title.assert_any_call( - title="周杰伦 晴天", sites=[], mtype=MediaType.MUSIC, rule_groups=[] - ) + search_chain.process.assert_called_once() + assert search_chain.process.call_args.kwargs["keyword"] == "周杰伦 晴天" + assert search_chain.process.call_args.kwargs["sites"] == [] download_chain.batch_download.assert_called_once() matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0] assert matched_context is not context - assert matched_context.media_info is target + assert matched_context.media_info.media_id == target.media_id assert isinstance(matched_context.meta_info, MetaMusic) assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC" assert matched_context.meta_info.audio_format == "FLAC" @@ -233,7 +242,7 @@ def test_music_search_honours_cancel_before_external_work(): chain = SubscribeChain() execution_context = _execution_context(cancelled=lambda: True) - with patch("app.chain._music.SearchChain") as search_chain, \ + with patch("app.chain.subscribe.search.SearchChain") as search_chain, \ pytest.raises(SubscriptionSearchCancelled): chain._search_music_subscribe( subscribe, @@ -243,6 +252,39 @@ def test_music_search_honours_cancel_before_external_work(): search_chain.assert_not_called() +def test_music_best_version_filters_before_stopping_keyword_search(): + """首关键词只有当前音质时,应继续换词直到找到满足洗版条件的资源。""" + subscribe = _subscribe(best_version=1, current_priority=90) + target = _music_info() + chain = SubscribeChain() + chain.subscription_repository = Mock() + chain.subscription_repository.get.return_value = subscribe + search_chain = SearchChain() + calls = [] + + def search(**kwargs): + """首轮为 CD 音质,组合词返回更高音质。""" + calls.append(kwargs["keyword"]) + quality = "16bit 44.1kHz" if len(calls) == 1 else "24bit 96kHz" + return [TorrentInfo(title=f"周杰伦 - 晴天 FLAC {quality}", category=MediaType.MUSIC.value)] + + search_chain._SearchChain__search_all_sites = Mock(side_effect=search) + with ( + patch.object(chain, "_prepare_music_subscribe", return_value=(subscribe, target, MetaMusic.from_music_info(target))), + patch.object(chain, "_download_music_subscribe") as download, + patch("app.chain.search.execution.time.sleep"), + patch.object(search_chain, "consume_subscription_site_budget_failures", wraps=search_chain.consume_subscription_site_budget_failures) as budget, + ): + chain._process_search_subscription(subscribe, search_chain) + assert calls == ["晴天", "周杰伦 晴天"] + budget.assert_called_once_with(has_results=True) + download.assert_called_once() + contexts = download.call_args.args[2] + assert len(contexts) == 1 + assert contexts[0].meta_info.bit_depth == 24 + assert contexts[0].torrent_info.pri_order > 90 + + def test_music_download_marks_shared_execution_context_before_side_effect(): """音乐下载必须把取消和副作用边界传入统一下载治理。""" cancelled = [False] @@ -520,13 +562,13 @@ def test_music_subscribe_ignores_non_music_category(): category=MediaType.MOVIE.value, ) ) - search_chain = Mock() - search_chain.search_by_title.return_value = [context] + search_chain = _search_chain_for_torrents([context]) chain = SubscribeChain() chain.check_and_handle_existing_media = Mock(return_value=(False, {})) with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ - patch("app.chain._music.SearchChain", return_value=search_chain), \ + patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \ + patch("app.chain.search.execution.time.sleep"), \ patch("app.chain._music.DownloadChain") as download_chain: chain._search_music_subscribe(subscribe) @@ -542,14 +584,14 @@ def test_music_subscribe_ignores_unrelated_music_title(): category=MediaType.MUSIC.value, ) ) - search_chain = Mock() - search_chain.search_by_title.return_value = [context] + search_chain = _search_chain_for_torrents([context]) chain = SubscribeChain() chain.check_and_handle_existing_media = Mock(return_value=(False, {})) with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ - patch("app.chain._music.SearchChain", return_value=search_chain), \ + patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \ + patch("app.chain.search.execution.time.sleep"), \ patch("app.chain._music.DownloadChain") as download_chain: chain._search_music_subscribe(subscribe) @@ -563,7 +605,7 @@ def test_music_subscribe_skips_search_when_target_is_already_in_library(): chain.check_and_handle_existing_media = Mock(return_value=(True, {})) with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ - patch("app.chain._music.SearchChain") as search_chain, \ + patch("app.chain.subscribe.search.SearchChain") as search_chain, \ patch("app.chain._music.DownloadChain") as download_chain: chain._search_music_subscribe(subscribe) diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index 370c7dba6..dd0e69574 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -1,7 +1,62 @@ -from app.runtime.config import settings +import asyncio +from unittest.mock import AsyncMock + from app.domain.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo from app.domain.meta.metamusic import MetaMusic from app.modules.musicbrainz import MusicBrainzModule +from app.runtime.config import settings + + +def test_recording_search_uses_phrase_before_character_fallback(monkeypatch): + """完整中文名称命中时不应再执行逐字 OR 查询。""" + module = MusicBrainzModule() + queries = [] + + def request(_path, params): + """模拟完整名称检索返回同名录音。""" + queries.append(params["query"]) + return {"recordings": [{"id": "recording", "title": "晴天"}]} + + monkeypatch.setattr(module, "_request_json", request) + assert module._search_recordings(MetaMusic(title="晴天"), 30)[0].title == "晴天" + assert queries == ['recording:"晴天"'] + + +def test_recording_search_keeps_character_query_as_last_resort(monkeypatch): + """完整短语无结果后保留旧的宽召回能力,不能先用单字占满结果窗口。""" + module = MusicBrainzModule() + queries = [] + + def request(_path, params): + """首轮无结果,仅在末级检索式返回候选。""" + queries.append(params["query"]) + return {"recordings": [] if len(queries) == 1 else [{"id": "recording", "title": "晴天"}]} + + monkeypatch.setattr(module, "_request_json", request) + assert module._search_recordings(MetaMusic(title="晴天"), 30) + assert queries == ['recording:"晴天"', 'recording:("晴" OR "天")'] + + +def test_artist_alias_lookup_verifies_identity_in_both_io_modes(monkeypatch): + """同步和异步别名补充必须校验精确艺术家 ID,拒绝其它艺人的响应。""" + module = MusicBrainzModule() + artist_id = "a223958d-5c56-4b2c-a30a-87e357bc121b" + payload = {"id": artist_id, "name": "周杰倫", "aliases": [{"name": "Jay Chou"}]} + monkeypatch.setattr(module, "_request_json", lambda *_args, **_kwargs: payload) + monkeypatch.setattr(module, "_async_request_json", AsyncMock(return_value=payload)) + expected = ["周杰倫", "Jay Chou"] + assert module._lookup_artist_aliases([artist_id], []) == expected + assert asyncio.run(module._async_lookup_artist_aliases([artist_id], [])) == expected + assert module._artist_alias_values(payload, "other-artist") == [] + + +def test_metadata_ranking_prefers_complete_name_over_partial_character_hit(): + """宽召回之后也应按完整标题与署名排序,避免单字相关候选压过准确目标。""" + exact = MusicInfo(title="晴天", artists=["周杰倫"]) + unrelated = MusicInfo(title="天", artists=["Other Artist"]) + assert MusicBrainzModule._rank_search_candidates( + MetaMusic(title="周杰伦 晴天"), [unrelated, exact], + )[0] is exact def test_musicbrainz_cover_domains_are_allowed_by_image_proxy(): @@ -36,7 +91,7 @@ def test_build_query_strips_audio_quality_tokens(): ) # CJK 短语在 Lucene 索引中是单一词元,检索式拆为逐字 OR,OR 组带括号避免 AND 优先级歧义 - assert query == 'recording:("永" OR "远" OR "是" OR "朋" OR "友") AND artist:"毛阿敏"' + assert query == 'recording:("永远是朋友" OR "永遠是朋友") AND artist:"毛阿敏"' def test_select_candidate_matches_traditional_chinese_title(): @@ -166,8 +221,8 @@ def test_search_music_interleaves_recordings_albums_and_artists(monkeypatch): assert results[1].album == "叶惠美" assert results[2].title == "周杰伦" assert results[2].artists == [] - assert requested[1][1]["query"] == 'releasegroup:("晴" OR "天") AND artist:"周杰伦"' - assert requested[2][1]["query"] == 'artist:("周" OR "杰" OR "伦")' + assert requested[1][1]["query"] == 'releasegroup:"晴天" AND artist:("周杰伦" OR "周杰倫")' + assert requested[2][1]["query"] == 'artist:("周杰伦" OR "周杰倫")' def test_file_recognition_searches_recordings_only(monkeypatch): @@ -660,19 +715,20 @@ def test_recording_queries_ladder_relaxes_to_bare_title_last(): MetaMusic(title="晴天 (电影版)", artists=["周杰伦"]) ) - full = '("晴" OR "天") OR ("电" OR "影" OR "版")' - assert queries[0] == f'recording:({full}) AND artist:"周杰伦"' - assert queries[1] == f'recording:({full})' - assert queries[2] == 'recording:("晴" OR "天") AND artist:"周杰伦"' - # 署名变体兜底放在最后,由候选挑选的艺术家要求收紧 + full = '("晴天 (电影版)" OR "晴天 (電影版)")' + assert queries[0] == f'recording:{full} AND artist:("周杰伦" OR "周杰倫")' + assert queries[1] == f'recording:{full}' + assert queries[2] == 'recording:"晴天" AND artist:("周杰伦" OR "周杰倫")' + # 全名查询之后才逐字兜底,避免单字噪声抢占召回窗口。 assert queries[-1] == 'recording:("晴" OR "天")' -def test_query_phrase_latin_unchanged_and_cjk_char_or(): - """拉丁文本保持短语检索,CJK 文本拆为逐字 OR,混合文本按词元拆分。""" +def test_query_phrase_prefers_complete_names_with_explicit_loose_fallback(): + """所有文字优先完整短语,CJK 只有显式末级兜底才拆为逐字 OR。""" assert MusicBrainzModule._query_phrase("Fearless") == '"Fearless"' - assert MusicBrainzModule._query_phrase("晴天") == '("晴" OR "天")' - assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == ( + assert MusicBrainzModule._query_phrase("晴天") == '"晴天"' + assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == '"好歌茹芸 Vol. 3"' + assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3", loose=True) == ( '(("好" OR "歌" OR "茹" OR "芸") OR "Vol." OR "3")' ) assert MusicBrainzModule._query_phrase("") is None diff --git a/tests/test_search_architecture.py b/tests/test_search_architecture.py index 50112a230..da4831005 100644 --- a/tests/test_search_architecture.py +++ b/tests/test_search_architecture.py @@ -19,6 +19,7 @@ EXPECTED_SEARCH_MODULES = { "__init__.py", "cache.py", "contract.py", + "execution.py", "facade.py", "media.py", "music.py", diff --git a/tests/test_search_compat.py b/tests/test_search_compat.py index 94d52f846..352d19b00 100644 --- a/tests/test_search_compat.py +++ b/tests/test_search_compat.py @@ -38,6 +38,7 @@ LUNA_PRIVATE_PATCH_POINTS = ( ) INTERNAL_EXPORTS = ( "SearchCacheOwner", + "SearchExecutionOwner", "SearchMediaOwner", "SearchMusicOwner", "SearchPaginationOwner", @@ -62,6 +63,8 @@ EXPECTED_SIGNATURES = { ("area", "title"), ("custom_words", None), ("filter_params", None), + ("include_candidates", False), + ("candidate_filter", None), ), "search_by_id": ( ("self", REQUIRED), @@ -73,6 +76,7 @@ EXPECTED_SIGNATURES = { ("sites", None), ("cache_local", False), ("music_type", None), + ("include_candidates", False), ), "search_by_title": ( ("self", REQUIRED), @@ -93,6 +97,8 @@ EXPECTED_SIGNATURES = { ("area", "title"), ("custom_words", None), ("filter_params", None), + ("include_candidates", False), + ("candidate_filter", None), ), "async_search_by_id": ( ("self", REQUIRED), @@ -104,6 +110,7 @@ EXPECTED_SIGNATURES = { ("sites", None), ("cache_local", False), ("music_type", None), + ("include_candidates", False), ), "async_search_by_title": ( ("self", REQUIRED), @@ -124,6 +131,8 @@ EXPECTED_SIGNATURES = { ("area", "title"), ("custom_words", None), ("filter_params", None), + ("include_candidates", False), + ("candidate_filter", None), ), "async_search_by_title_stream": ( ("self", REQUIRED), diff --git a/tests/test_search_sync_async_parity.py b/tests/test_search_sync_async_parity.py index ca3546262..957947cba 100644 --- a/tests/test_search_sync_async_parity.py +++ b/tests/test_search_sync_async_parity.py @@ -7,6 +7,7 @@ from typing import Any import pytest +from app.chain.search import execution as execution_module from app.chain.search import media as media_module from app.chain.search import title as title_module from app.chain.search.facade import SearchChain @@ -169,9 +170,9 @@ def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch): async_sleeps.append(delay) monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain) - monkeypatch.setattr(media_module.random, "randint", lambda _start, _end: 1) - monkeypatch.setattr(media_module.time, "sleep", sync_sleeps.append) - monkeypatch.setattr(media_module.asyncio, "sleep", fake_async_sleep) + monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1) + monkeypatch.setattr(execution_module.time, "sleep", sync_sleeps.append) + monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep) chain.runtime_config = SimpleNamespace(search_multiple_name=False) chain._copy_media_input = deepcopy chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"]) @@ -188,46 +189,47 @@ def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch): assert sync_result == async_result == [found] -def test_keyword_resolution_searches_all_names_when_enabled(): - """开启多名称搜索时共享状态机应完整执行并稳定聚合各关键字结果。""" +def test_keyword_resolution_searches_all_names_when_enabled(monkeypatch): + """开启多名称搜索时同步、异步共用状态机须完整执行并稳定聚合结果。""" + chain = _chain() + target = _media() first = _torrent("First Result") third = _torrent("Third Result") - expected = { - "first": [first], - "second": [], - "third": [third], - } - sync_keywords: list[str] = [] - async_keywords: list[str] = [] + results = {"first": [first], "second": [], "third": [third]} + sync_keywords = [] + async_keywords = [] - def execute_sync(request): - """记录同步驱动顺序并返回关键字结果。""" - sync_keywords.append(request.keyword) - return expected[request.keyword] + def search(**kwargs): + """记录同步站点调用顺序。""" + sync_keywords.append(kwargs["keyword"]) + return results[kwargs["keyword"]] - async def execute_async(request): - """记录异步驱动顺序并返回关键字结果。""" - async_keywords.append(request.keyword) - return expected[request.keyword] + async def async_search(**kwargs): + """记录异步站点调用顺序。""" + async_keywords.append(kwargs["keyword"]) + return results[kwargs["keyword"]] - keywords = ["first", "second", "third"] - sync_result = media_module._run_keyword_search_sync( - media_module._keyword_search_resolution(keywords, search_multiple_name=True), - execute_sync, - ) - async_result = asyncio.run( - media_module._run_keyword_search_async( - media_module._keyword_search_resolution( - keywords, search_multiple_name=True - ), - execute_async, - ) - ) + async def supplement(mediainfo): + """异步补充步骤在样例中不改变媒体对象。""" + return mediainfo - assert sync_keywords == async_keywords == keywords - assert sync_result == async_result - assert sync_result.torrents == [first, third] - assert sync_result.stopped_early is False + async def sleep(_delay): + """隔离关键词间隔,不执行实际等待。""" + + provider = SimpleNamespace(supplement_media_info=lambda mediainfo: mediainfo, + async_supplement_media_info=supplement) + monkeypatch.setattr(media_module, "MediaChain", lambda: provider) + monkeypatch.setattr(execution_module.time, "sleep", lambda _delay: None) + monkeypatch.setattr(execution_module.asyncio, "sleep", sleep) + chain.runtime_config = SimpleNamespace(search_multiple_name=True) + chain._prepare_params = lambda **_kwargs: (None, list(results)) + chain._SearchChain__search_all_sites = search + chain._SearchChain__async_search_all_sites = async_search + chain._parse_result = lambda **kwargs: list(kwargs["torrents"]) + + assert chain.process(target) == [first, third] + assert asyncio.run(chain.async_process(target)) == [first, third] + assert sync_keywords == async_keywords == list(results) def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch): @@ -272,8 +274,8 @@ def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch ] monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain) - monkeypatch.setattr(media_module.random, "randint", lambda _start, _end: 1) - monkeypatch.setattr(media_module.asyncio, "sleep", fake_async_sleep) + monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1) + monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep) chain.runtime_config = SimpleNamespace(search_multiple_name=False) chain._copy_media_input = deepcopy chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"]) @@ -282,13 +284,14 @@ def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch events = asyncio.run(collect_events()) - assert stream_keywords == ["first", "second"] - assert sleeps == [1] + assert stream_keywords == ["first", "second", "third"] + assert sleeps == [1, 1] assert parsed == [[found]] assert [event["type"] for event in events] == [ "append", "append", "progress", + "append", "replace", "done", ]