refactor: 统一音乐与影视的识别搜索匹配流程

This commit is contained in:
jxxghp
2026-09-06 00:48:00 +08:00
parent 41d46d1b5c
commit fba0b6ae67
63 changed files with 1951 additions and 1301 deletions
+2
View File
@@ -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.",
@@ -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."
+3 -9
View File
@@ -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())
+6 -10
View File
@@ -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)
+6
View File
@@ -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,
+15 -10
View File
@@ -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,
+4
View File
@@ -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,
}
)
+1 -1
View File
@@ -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')
# 站点上传量
+3 -50
View File
@@ -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,
+1 -1
View File
@@ -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]:
"""
过滤种子资源
+12 -1
View File
@@ -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(
+22 -6
View File
@@ -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
+15
View File
@@ -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,
+2 -2
View File
@@ -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(
+22 -17
View File
@@ -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)
+4 -1
View File
@@ -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"),
+4
View File
@@ -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]]:
+5 -4
View File
@@ -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]
+221
View File
@@ -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)} 个资源",
}
+22 -27
View File
@@ -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)
+60 -615
View File
@@ -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
+89 -266
View File
@@ -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
+30 -10
View File
@@ -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)
+71 -19
View File
@@ -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)} 个资源")
+1 -9
View File
@@ -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
+2
View File
@@ -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]
+45
View File
@@ -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,
+21 -44
View File
@@ -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} 不是全集资源")
+3 -9
View File
@@ -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:
+30 -3
View File
@@ -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,
+52 -4
View File
@@ -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"(?<![A-Z])(?P<format>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":
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
+14 -12
View File
@@ -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: MetaAnimeMetaVideoMetaMusic
"""
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析
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,
+187
View File
@@ -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"(?<![a-z0-9])" + pattern + r"(?![a-z0-9])", normalized))
def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = False,
album_suffixes: Optional[list[str]] = None) -> 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")
+109 -27
View File
@@ -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 风格、标签或别名列表的名称,热度高的排在前面。"""
+2
View File
@@ -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
+5
View File
@@ -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)
+1 -1
View File
@@ -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 |
+92
View File
@@ -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 和现版本发布验收。
+3 -3
View File
@@ -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 热点文件
+12 -1
View File
@@ -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:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `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。
| 方法 | 路径 | 说明 |
+9 -1
View File
@@ -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 |
+2 -1
View File
@@ -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.
+2 -10
View File
@@ -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"
},
+37 -15
View File
@@ -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",
+6 -6
View File
@@ -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,
-12
View File
@@ -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
},
@@ -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,
+4 -2
View File
@@ -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",
},
),
(
+2
View File
@@ -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)
+85
View File
@@ -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)
+5 -4
View File
@@ -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,
+9
View File
@@ -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
+22
View File
@@ -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():
+10 -9
View File
@@ -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,),
)
+192
View File
@@ -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("【永遠・是朋友】24bit96kHz", "專輯藝人:周華健;無損音樂")
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("【永遠・是朋友】24bit96kHz", "專輯藝人:周華健")
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)
+16
View File
@@ -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
+90 -6
View File
@@ -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] == [
+59
View File
@@ -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] == []
+58 -16
View File
@@ -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)
+69 -13
View File
@@ -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
+1
View File
@@ -19,6 +19,7 @@ EXPECTED_SEARCH_MODULES = {
"__init__.py",
"cache.py",
"contract.py",
"execution.py",
"facade.py",
"media.py",
"music.py",
+9
View File
@@ -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),
+44 -41
View File
@@ -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",
]