From 4575b11d8456c28e0d1f44fced1242020c0c554b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 30 Aug 2026 02:48:42 +0800 Subject: [PATCH] fix(architecture): close canonical search type gate --- app/api/endpoints/search.py | 38 +- app/api/endpoints/system.py | 14 +- app/application/chain/events.py | 2 +- app/chain/search/facade.py | 338 ++++++++++++++++-- app/domain/context.py | 6 +- app/workflow/actions/fetch_torrents.py | 38 +- docs/architecture-optimization-checklist.md | 138 +++---- docs/architecture-overview.md | 8 +- docs/architecture-refactor-roadmap.md | 39 +- .../architecture/dependency-baseline.json | 9 +- .../fixtures/architecture/mypy-baseline.json | 16 +- tests/test_chain_vertical_slices.py | 4 +- tests/test_workflow_actions.py | 16 +- 13 files changed, 504 insertions(+), 162 deletions(-) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 34bf8274f..b959f4f43 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -11,6 +11,7 @@ from app.adapters.web.security.access import verify_resource_token, verify_token from app.api.response import ResponseAPIRouter from app.application.security.url import SecurityUtils from app.chain.search.facade import SearchChain +from app.domain.context import Context from app.domain.media import normalize_music_type from app.runtime.localization import LocaleHelper from app.runtime.log import logger @@ -128,6 +129,11 @@ def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]: return [_serialize_signed_subtitle_result(subtitle) for subtitle in subtitles] +def _serialize_context_results(contexts: list[Context]) -> list[dict[str, Any]]: + """序列化 canonical 搜索上下文,集中保持传输层投影合同。""" + return [context.to_dict() for context in contexts] + + def _sign_subtitle_search_event(event: dict) -> dict: """ 签名字幕搜索流事件中的下载链接。 @@ -339,7 +345,7 @@ async def search_latest(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: 查询搜索结果 """ torrents = await SearchChain().async_last_search_results() or [] - return [torrent.to_dict() for torrent in torrents] + return _serialize_context_results(torrents) @router.get( @@ -354,16 +360,18 @@ async def search_latest_context(_: _SchemaTokenPayload = Depends(verify_token)) search_chain = SearchChain() params = await search_chain.async_last_search_params() or {} if params.get("result_type") == "subtitle": - results = await search_chain.async_last_subtitle_search_results() or [] + subtitle_results = ( + await search_chain.async_last_subtitle_search_results() or [] + ) + serialized_results = _serialize_signed_subtitle_results(subtitle_results) else: - results = await search_chain.async_last_search_results() or [] + context_results = await search_chain.async_last_search_results() or [] + serialized_results = _serialize_context_results(context_results) return _SchemaResponse( success=True, data={ "params": params, - "results": _serialize_signed_subtitle_results(results) - if params.get("result_type") == "subtitle" - else [result.to_dict() for result in results], + "results": serialized_results, }, ) @@ -467,7 +475,7 @@ async def search_by_id( if not torrents: return _SchemaResponse(success=False, message="未搜索到任何资源") return _SchemaResponse( - success=True, data=[torrent.to_dict() for torrent in torrents] + success=True, data=_serialize_context_results(torrents) ) @@ -496,7 +504,7 @@ async def search_by_title_stream( """ event_source = SearchChain().async_search_by_title_stream( - title=keyword, + title=keyword or "", page=page, sites=_parse_site_list(sites), cache_local=True, @@ -525,7 +533,7 @@ async def search_by_title( 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 """ torrents = await SearchChain().async_search_by_title( - title=keyword, + title=keyword or "", page=page, sites=_parse_site_list(sites), cache_local=True, @@ -534,7 +542,7 @@ async def search_by_title( if not torrents: return _SchemaResponse(success=False, message="未搜索到任何资源") return _SchemaResponse( - success=True, data=[torrent.to_dict() for torrent in torrents] + success=True, data=_serialize_context_results(torrents) ) @@ -562,7 +570,10 @@ async def search_subtitle_by_title_stream( """ event_source = SearchChain().async_search_subtitles_by_title_stream( - title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True + title=keyword or "", + page=page, + sites=_parse_site_list(sites), + cache_local=True, ) return StreamingResponse( _stream_search_events( @@ -589,7 +600,10 @@ async def search_subtitle_by_title( 根据名称模糊搜索站点字幕资源,支持分页。 """ subtitles = await SearchChain().async_search_subtitles_by_title( - title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True + title=keyword or "", + page=page, + sites=_parse_site_list(sites), + cache_local=True, ) if not subtitles: return _SchemaResponse(success=False, message="未搜索到任何字幕") diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index e664163a2..da1d5c7f8 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -53,6 +53,7 @@ from app.application.site.sites import SitesHelper # pylint: disable=import-err from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain from app.chain.search.facade import SearchChain +from app.domain.context import TorrentInfo as _DomainTorrentInfo from app.domain.metainfo import MetaInfo from app.foundation.crypto import HashUtils from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled @@ -82,7 +83,6 @@ from app.schemas.system import RuleTestData as _SchemaRuleTestData from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus -from app.schemas.system import TorrentInfo as _SchemaTorrentInfo from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import EventType, SystemConfigKey from app.startup.composition.context import HostRuntime @@ -1171,9 +1171,9 @@ def ruletest( 过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索 """ metainfo = MetaInfo(title=title, subtitle=subtitle) - torrent = _SchemaTorrentInfo( + torrent = _DomainTorrentInfo( title=title, - description=subtitle, + description=subtitle or "", ) # 查询规则组详情 rulegroup = RuleHelper().get_rule_group(rulegroup_name) @@ -1184,7 +1184,7 @@ def ruletest( "rulegroup": rulegroup.model_dump() if rulegroup else None, "meta_info": metainfo.to_dict(), "media_info": None, - "torrent_info": torrent.model_dump(), + "torrent_info": torrent.to_dict(), "priority": None, "matched": False, } @@ -1210,7 +1210,9 @@ def ruletest( # 过滤 result = SearchChain().filter_torrents( - rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info + rule_groups=[rulegroup.name or rulegroup_name], + torrent_list=[torrent], + mediainfo=media_info, ) if not result: return _SchemaResponse( @@ -1222,7 +1224,7 @@ def ruletest( { "matched": True, "priority": 100 - result[0].pri_order + 1, - "torrent_info": result[0].model_dump(), + "torrent_info": result[0].to_dict(), } ) return _SchemaResponse( diff --git a/app/application/chain/events.py b/app/application/chain/events.py index ab81ed32c..614972eab 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -106,7 +106,7 @@ def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]: return cast(dict[str, Any], _json_snapshot({ "hash": payload.get("hash"), "context": ( - cast(Callable[[], dict[str, Any]], context.to_dict)() + context.to_dict() if isinstance(context, Context) else context ), diff --git a/app/chain/search/facade.py b/app/chain/search/facade.py index d9ecac87e..76587b466 100644 --- a/app/chain/search/facade.py +++ b/app/chain/search/facade.py @@ -1,6 +1,6 @@ """搜索处理链稳定 Facade。""" -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from typing import Any, Optional, TypeVar, cast from app.chain.base import ChainBase @@ -15,10 +15,10 @@ 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 +from app.domain.context import Context, MediaInfo, SubtitleInfo from app.runtime.events import Event, eventmanager from app.schemas.mediaserver import NotExistMediaInfo -from app.schemas.types import EventType, MediaType +from app.schemas.types import EventType, MediaSource, MediaType _PUBLIC_MODULE = "app.chain.search" _Handler = TypeVar("_Handler", bound=Callable[..., Any]) @@ -56,12 +56,41 @@ class SearchChain(ChainBase): ) _should_continue_search_pages = SearchPaginationOwner._should_continue_search_pages _should_continue_subtitle_search_pages = staticmethod(SearchPaginationOwner._should_continue_subtitle_search_pages) - is_ai_recommend_enabled = SearchRecommendOwner.is_ai_recommend_enabled + @property + def is_ai_recommend_enabled(self) -> bool: + """经推荐 owner 的原始描述符读取启用状态,避免 Facade 属性递归。""" + descriptor = cast( + property, + SearchRecommendOwner.__dict__["is_ai_recommend_enabled"], + ) + return cast(bool, descriptor.__get__(self, type(self))) + _calculate_recommend_request_hash = staticmethod(SearchRecommendOwner._calculate_recommend_request_hash) _build_ai_recommend_status = SearchRecommendOwner._build_ai_recommend_status - get_current_recommend_status_only = SearchRecommendOwner.get_current_recommend_status_only - get_recommend_status = SearchRecommendOwner.get_recommend_status - cancel_ai_recommend = SearchRecommendOwner.cancel_ai_recommend + def get_current_recommend_status_only(self) -> dict[str, Any]: + """返回当前推荐状态,不改变推荐请求代际。""" + return SearchRecommendOwner.get_current_recommend_status_only( + cast(SearchRecommendOwner, self) + ) + + def get_recommend_status( + self, + filtered_indices: Optional[list[int]], + search_results_count: int, + ) -> dict[str, Any]: + """按当前筛选条件返回推荐状态。""" + return SearchRecommendOwner.get_recommend_status( + cast(SearchRecommendOwner, self), + filtered_indices=filtered_indices, + search_results_count=search_results_count, + ) + + def cancel_ai_recommend(self) -> None: + """委托推荐 owner 取消当前任务并清理推荐缓存。""" + SearchRecommendOwner.cancel_ai_recommend( + cast(SearchRecommendOwner, self) + ) + _build_search_keyword = staticmethod(SearchPlanOwner._build_search_keyword) _media_recognize_kwargs = staticmethod(SearchPlanOwner._media_recognize_kwargs) _stringify_sites = staticmethod(SearchCacheOwner._stringify_sites) @@ -72,14 +101,56 @@ class SearchChain(ChainBase): _async_save_subtitles = SearchCacheOwner._async_save_subtitles save_last_search_params = SearchCacheOwner.save_last_search_params async_save_last_search_params = SearchCacheOwner.async_save_last_search_params - last_search_params = SearchCacheOwner.last_search_params - async_last_search_params = SearchCacheOwner.async_last_search_params + def last_search_params(self) -> Optional[dict[str, str]]: + """返回最近一次搜索参数。""" + return SearchCacheOwner.last_search_params(cast(SearchCacheOwner, self)) + + async def async_last_search_params(self) -> Optional[dict[str, str]]: + """异步返回最近一次搜索参数。""" + return await SearchCacheOwner.async_last_search_params( + cast(SearchCacheOwner, self) + ) _normalize_ai_indices = staticmethod(SearchRecommendOwner._normalize_ai_indices) _extract_recommend_items = staticmethod(SearchRecommendOwner._extract_recommend_items) _restore_original_indices = staticmethod(SearchRecommendOwner._restore_original_indices) _invoke_recommend_llm = staticmethod(SearchRecommendOwner._invoke_recommend_llm) - start_recommend_task = SearchRecommendOwner.start_recommend_task - search_by_id = SearchMediaOwner.search_by_id + def start_recommend_task( + self, + filtered_indices: Optional[list[int]], + search_results_count: int, + results: list[Any], + ) -> None: + """委托推荐 owner 启动受代际保护的推荐任务。""" + SearchRecommendOwner.start_recommend_task( + cast(SearchRecommendOwner, self), + filtered_indices=filtered_indices, + search_results_count=search_results_count, + 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, + ) def search_by_title( self, @@ -101,19 +172,192 @@ class SearchChain(ChainBase): rule_groups=rule_groups, ) - last_search_results = SearchCacheOwner.last_search_results - async_last_search_results = SearchCacheOwner.async_last_search_results - async_last_subtitle_search_results = SearchCacheOwner.async_last_subtitle_search_results - async_search_subtitles_by_title = SearchSubtitleOwner.async_search_subtitles_by_title - async_search_subtitles_by_title_stream = SearchSubtitleOwner.async_search_subtitles_by_title_stream - async_search_subtitles_by_id = SearchSubtitleOwner.async_search_subtitles_by_id - async_search_subtitles_by_id_stream = SearchSubtitleOwner.async_search_subtitles_by_id_stream - async_search_by_id = SearchMediaOwner.async_search_by_id - async_search_by_title = SearchTitleOwner.async_search_by_title - async_search_by_title_stream = SearchTitleOwner.async_search_by_title_stream + def last_search_results(self) -> Optional[list[Context]]: + """返回最近一次资源搜索结果。""" + return SearchCacheOwner.last_search_results(cast(SearchCacheOwner, self)) + + async def async_last_search_results(self) -> Optional[list[Context]]: + """异步返回最近一次资源搜索结果。""" + return await SearchCacheOwner.async_last_search_results( + cast(SearchCacheOwner, self) + ) + + async def async_last_subtitle_search_results( + self, + ) -> Optional[list[SubtitleInfo]]: + """异步返回最近一次字幕搜索结果。""" + return await SearchCacheOwner.async_last_subtitle_search_results( + cast(SearchCacheOwner, self) + ) + + async def async_search_subtitles_by_title( + self, + title: str, + page: Optional[int] = 0, + sites: Optional[list[int]] = None, + cache_local: Optional[bool] = False, + ) -> list[SubtitleInfo]: + """通过稳定 Facade 执行异步标题字幕搜索。""" + return await SearchSubtitleOwner.async_search_subtitles_by_title( + cast(SearchSubtitleOwner, self), + title=title, + page=page, + sites=sites, + cache_local=cache_local, + ) + + async def async_search_subtitles_by_title_stream( + self, + title: str, + page: Optional[int] = 0, + sites: Optional[list[int]] = None, + cache_local: Optional[bool] = False, + ) -> AsyncIterator[dict[str, Any]]: + """通过稳定 Facade 流式执行标题字幕搜索。""" + async for event in SearchSubtitleOwner.async_search_subtitles_by_title_stream( + cast(SearchSubtitleOwner, self), + title=title, + page=page, + sites=sites, + cache_local=cache_local, + ): + yield event + + async def async_search_subtitles_by_id( + self, + media_source: MediaSource, + media_id: str, + mtype: Optional[MediaType] = None, + season: Optional[int] = None, + episode: Optional[int] = None, + sites: Optional[list[int]] = None, + cache_local: bool = False, + ) -> list[SubtitleInfo]: + """通过稳定 Facade 执行异步精确字幕搜索。""" + return await SearchSubtitleOwner.async_search_subtitles_by_id( + cast(SearchSubtitleOwner, self), + media_source=media_source, + media_id=media_id, + mtype=mtype, + season=season, + episode=episode, + sites=sites, + cache_local=cache_local, + ) + + async def async_search_subtitles_by_id_stream( + self, + media_source: MediaSource, + media_id: str, + mtype: Optional[MediaType] = None, + season: Optional[int] = None, + episode: Optional[int] = None, + sites: Optional[list[int]] = None, + cache_local: bool = False, + ) -> AsyncIterator[dict[str, Any]]: + """通过稳定 Facade 流式执行精确字幕搜索。""" + async for event in SearchSubtitleOwner.async_search_subtitles_by_id_stream( + cast(SearchSubtitleOwner, self), + media_source=media_source, + media_id=media_id, + mtype=mtype, + season=season, + episode=episode, + sites=sites, + cache_local=cache_local, + ): + yield event + + async def async_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 await SearchMediaOwner.async_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, + ) + + async def async_search_by_title( + self, + title: str, + page: Optional[int] = 0, + sites: Optional[list[int]] = None, + cache_local: Optional[bool] = False, + mtype: Optional[MediaType] = None, + rule_groups: Optional[list[str]] = None, + ) -> list[Context]: + """通过稳定 Facade 执行异步标题资源搜索。""" + return await SearchTitleOwner.async_search_by_title( + cast(SearchTitleOwner, self), + title=title, + page=page, + sites=sites, + cache_local=cache_local, + mtype=mtype, + rule_groups=rule_groups, + ) + + async def async_search_by_title_stream( + self, + title: str, + page: Optional[int] = 0, + sites: Optional[list[int]] = None, + cache_local: Optional[bool] = False, + mtype: Optional[MediaType] = None, + rule_groups: Optional[list[str]] = None, + ) -> AsyncIterator[dict[str, Any]]: + """通过稳定 Facade 流式执行标题资源搜索。""" + async for event in SearchTitleOwner.async_search_by_title_stream( + cast(SearchTitleOwner, self), + title=title, + page=page, + sites=sites, + cache_local=cache_local, + mtype=mtype, + rule_groups=rule_groups, + ): + yield event _build_title_search_meta = staticmethod(SearchTitleOwner._build_title_search_meta) _filter_title_search_torrents = SearchTitleOwner._filter_title_search_torrents - async_search_by_id_stream = SearchMediaOwner.async_search_by_id_stream + async def async_search_by_id_stream( + 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, + ) -> AsyncIterator[dict[str, Any]]: + """通过稳定 Facade 流式执行精确媒体搜索。""" + async for event in SearchMediaOwner.async_search_by_id_stream( + 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, + ): + yield event _prepare_params = staticmethod(SearchPlanOwner._prepare_params) _copy_media_input = staticmethod(SearchPlanOwner._copy_media_input) _parse_result = SearchResultOwner._parse_result @@ -148,8 +392,54 @@ class SearchChain(ChainBase): filter_params=filter_params, ) - async_process = SearchMediaOwner.async_process - async_process_stream = SearchMediaOwner.async_process_stream + async def async_process( + self, + mediainfo: MediaInfo, + 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, + ) -> list[Context]: + """通过稳定 Facade 执行异步媒体搜索编排。""" + return await SearchMediaOwner.async_process( + cast(SearchMediaOwner, self), + mediainfo=mediainfo, + keyword=keyword, + no_exists=no_exists, + sites=sites, + rule_groups=rule_groups, + area=area, + custom_words=custom_words, + filter_params=filter_params, + ) + + async def async_process_stream( + self, + mediainfo: MediaInfo, + 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, + ) -> AsyncIterator[dict[str, Any]]: + """通过稳定 Facade 流式执行媒体搜索编排。""" + async for event in SearchMediaOwner.async_process_stream( + cast(SearchMediaOwner, self), + mediainfo=mediainfo, + keyword=keyword, + no_exists=no_exists, + sites=sites, + rule_groups=rule_groups, + area=area, + custom_words=custom_words, + filter_params=filter_params, + ): + yield event _build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes) _build_subtitle_torrent = staticmethod(SearchSubtitleOwner._build_subtitle_torrent) _build_subtitle_names = staticmethod(SearchSubtitleOwner._build_subtitle_names) diff --git a/app/domain/context.py b/app/domain/context.py index e8ccb724b..852c8d1e8 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -1088,7 +1088,7 @@ class TorrentInfo: print(f"种子发布时间获取失败: {e}") return 0 - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """ 返回字典 """ @@ -1179,7 +1179,7 @@ class SubtitleInfo: for key, value in data.items(): setattr(self, key, value) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """ 返回字典。 """ @@ -1575,7 +1575,7 @@ class Context: # 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源。 confirmed_full_coverage: bool = False - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """ 转换为字典 """ diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index 6789b8f4b..9ff183f99 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -1,13 +1,15 @@ import random import time -from typing import List, Optional +from typing import List, Optional, cast from pydantic import Field from app.chain.media import MediaChain from app.chain.search.facade import SearchChain +from app.domain.context import Context as SearchContext from app.runtime.log import logger from app.runtime.stop import runtime_stop_state +from app.schemas.context import Context as WorkflowContext from app.schemas.types import MediaType from app.schemas.workflow import ActionContext, ActionParams from app.workflow.actions import BaseAction @@ -38,7 +40,7 @@ class FetchTorrentsAction(BaseAction): def __init__(self, action_id: str): super().__init__(action_id) - self._torrents = [] + self._torrents: list[WorkflowContext] = [] name = "搜索站点资源" description = "搜索站点种子资源列表" @@ -56,31 +58,42 @@ class FetchTorrentsAction(BaseAction): searchchain = SearchChain() if params.search_type == "keyword": # 按关键字搜索 - torrents = searchchain.search_by_title(title=params.name, sites=params.sites) + torrents = searchchain.search_by_title( + title=params.name or "", + sites=params.sites, + ) for torrent in torrents: if runtime_stop_state.is_workflow_stopped(workflow_id): break - if params.year and torrent.meta_info.year != params.year: + meta_info = torrent.meta_info + if params.year and (not meta_info or meta_info.year != params.year): continue if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type): continue - if params.season is not None and torrent.meta_info.begin_season != params.season: + if params.season is not None and ( + not meta_info or meta_info.begin_season != params.season + ): continue # 识别媒体信息 if params.match_media: + if not meta_info: + continue torrent.media_info = MediaChain().recognize_by_meta( - torrent.meta_info, + meta_info, obtain_images=False, ) if not torrent.media_info: logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息") continue - self._torrents.append(torrent) + self._torrents.append(self._to_workflow_context(torrent)) else: # 搜索媒体列表 - for media in context.medias: + for media in context.medias or []: if runtime_stop_state.is_workflow_stopped(workflow_id): break + if not media.media_source or not media.media_id or not media.type: + logger.warning("媒体身份或类型不完整,跳过资源搜索") + continue torrents = searchchain.search_by_id( media_source=media.media_source, media_id=media.media_id, @@ -88,7 +101,7 @@ class FetchTorrentsAction(BaseAction): sites=params.sites, ) for torrent in torrents: - self._torrents.append(torrent) + self._torrents.append(self._to_workflow_context(torrent)) # 随机休眠 5-30秒 sleep_time = random.randint(5, 30) @@ -96,8 +109,15 @@ class FetchTorrentsAction(BaseAction): time.sleep(sleep_time) if self._torrents: + if context.torrents is None: + context.torrents = [] context.torrents.extend(self._torrents) logger.info(f"共搜索到 {len(self._torrents)} 条资源") self.job_done(f"搜索到 {len(self._torrents)} 个资源") return context + + @staticmethod + def _to_workflow_context(context: SearchContext) -> WorkflowContext: + """把领域搜索结果投影为工作流传输上下文。""" + return cast(WorkflowContext, WorkflowContext.model_validate(context.to_dict())) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index fbc4a0a5b..f2ebab0ea 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -2,9 +2,10 @@ > 审计日期:2026-08-26 > -> 首次审计历史快照:`v3@9053db926d20`;当前交付状态以路线图和生成 fixture 为准 +> 首次审计历史快照:`v3@9053db926d20`;历史问题描述用于追溯,当前事实与交付状态以 +> 本文“当前架构画像”、路线图和生成 fixture 为准 > -> 文档性质:当前源码的差距清单与分阶段执行说明,不是历史重构结项账本 +> 文档性质:原始差距、治理结果和剩余交付边界的持续校准账本 ## 1. 结论摘要 @@ -13,20 +14,20 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` 基本成立。旧物理兼容目录、Model/Oper 自建 Session/提交和直接跨层 DB 依赖等历史问题已有 硬门禁;门禁还能证明可识别的 TaskRegistry 调用已经声明稳定 owner。 -当前主要问题不再是“目录没有分层”,而是以下四类更深层的债务: +截至当前校准,原审计识别的四类结构性债务已经按既定路线收口: -1. **边界有形、合同偏弱**:部分 Application Port 仍是 `Callable[[], Any]`,返回 ORM 或 - 动态代理;生产路径因此继续依赖无 Session Oper、全局 provider 和隐式构造。 -2. **用例编排过度集中**:部分 Chain、Plugin、Agent、LLM 和 API 文件仍同时承担决策、I/O、 - 状态、生命周期与兼容职责;Scheduler 已完成生产代码拆包和综合验收,其他私有长方法仍可能 - 处于复杂度门禁盲区。 -3. **可靠性声明强于实现**:整理 pending 目前只能做最小重放,尚不满足 ADR 中声明的 E3 - 状态机语义;部分 commit 后副作用仍存在“业务已提交但调用方收到失败”或进程退出后丢失的窗口。 -4. **治理事实源不一致**:架构规则、总览、AST 基线和 CI 语义存在漂移;快照门禁能证明“没有变化”, - 但不能自动证明依赖合理、没有新环或所有运行资源都有生命周期 owner。 +1. **类型化边界**:Chain/Agent 正式数据入口已使用冻结 DTO 与 typed Port;raw Oper/ORM/`Any` + 只留统一 Legacy/Compat,canonical 宿主不再依赖无 Session 写入口或全局数据 locator。 +2. **职责拆分**:Transfer、Subscribe、Download、Search、Media、Scheduler、Plugin、Agent/LLM、 + Domain 投影和 Startup composition 均已有单一 owner;Facade 只保留稳定 ABI 与显式委托。 +3. **可靠性语义**:Transfer E3 状态机、业务 UoW、post-commit 结构化结果、Outbox claim/settlement + fencing 和人工确认边界均已落地,外部 sink 的 at-least-once 责任被明确记录。 +4. **治理事实源**:依赖、SCC、Adapter、egress、Event、复杂度、类型、Ruff、生命周期和兼容基线 + 已进入机器门禁;`app/plugins/**` 始终排除,插件兼容只由 SDK/Compat 承接。 -审计时唯一 P0 的主线 `mypy` ratchet 失败已由 `5df388719` 修复并推送。后续架构优化按可靠性、 -边界类型化、生命周期、复杂度的顺序推进;不建议再做一次大规模目录搬迁。 +当前唯一活动项是 S5-L6 的最终交付闭环:`c960a0184` 已推送,但 exact-head Unit Tests 暴露 +canonical `SearchChain` 类型债务。本地修复和低水位更新已经通过,尚需提交推送并取得新精确 head +远端 CI 全绿,才能把 S5-L3 至 S5-L6 及对应总表项晋升为已交付并结束本轮任务。 ## 2. 范围与证据边界 @@ -55,30 +56,30 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 层/区域 | 已形成的正确边界 | 当前主要缺口 | |---|---|---| | `foundation` | 无状态、无配置、无 I/O 的底层机制 | 当前未发现需要重做的结构性问题 | -| `domain` | 已与 DB、Application、Adapter 保持单向隔离 | `domain/context.py` 等核心对象过大,来源投影与领域模型仍集中 | -| `runtime` | 已有任务、事件、缓存、托管资源和停止合同 | 导入/构造时启动线程;`global_vars` 仍混合停止、主循环和 WebPush 状态 | -| `adapters` | 技术与命名外部生态已有明确目录 | Application/Chain 仍直接识别部分具体 Adapter;例外规则没有形式化 | -| `application` | 已有命令、Port、Outbox 和安全能力,具体 Adapter 直连已清零 | 部分 Port 是 `Any` 服务定位器,仍需继续收窄合同 | -| `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,包根 SCC 已清零 | God object、私有长方法、无参构造和资源物化过重 | -| `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 | -| `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 | +| `domain` | 已与 DB、Application、Adapter 保持单向隔离;四类媒体来源投影已拆入独立 owner | `domain/context.py` 仍承载核心领域对象和兼容 setter,继续由复杂度门禁约束 | +| `runtime` | 已有任务、事件、缓存、托管资源和停止合同;资源由 lifecycle 显式装配 | 原生并发原语的全宿主清单属于已取消 ARCH-111,不在本轮扩张 | +| `adapters` | 技术与命名外部生态已有明确目录;Application/Chain 具体 Adapter 直连已清零 | 现存 direct egress 均为精确书面化协议例外,需由 zero-growth 门禁持续守护 | +| `application` | 命令、typed Port、Outbox 和安全能力边界已落地,具体 Adapter 与 raw data locator 已清零 | 新增能力必须继续使用明确 Protocol/DTO,不能恢复 `Any` 服务定位器 | +| `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,热点 Chain 已拆为同名职责包 | Facade 仍需保持稳定 ABI,并由类型、复杂度和兼容门禁防止职责回流 | +| `db/oper` | Model/Oper 已不自建 Session、不自行提交,只在调用方 Session 内查询、stage 或 flush | 当前未发现需重做的结构性事务所有权问题 | +| `db/adapters` | Application Port、短 Session/UoW 与 DTO 投影已落地;Outbox stager/store 已分离 | 新用例需继续防止 ORM 越界和隐式事务回流 | | `startup` | `composition/runtime.py` 已成为 HostRuntime、领域 Runtime 与旧 ApiData 投影唯一 owner;Chain/Agent 复用同一 RuntimeDependencies | `initializers/modules.py` 仍保留启动顺序与跨领域发布调用,高扇出需由生命周期验收继续约束 | -| API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 | +| API/Command/Scheduler | Agent/System/Plugin 用例已下沉 Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | endpoint 与 Facade 需继续由架构门禁防止业务编排回流 | | `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 | ### 3.2 量化快照 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 973 / 8,350 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 973 / 8,355 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Module Contract | 217 specs / 215 methods / 266 calls | 动态方法名为 0;内部 planning 合同不进入插件调度,旧 transfer 只保留 provider ABI | | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | -| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | -| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,008 / 592 文件 | 识别链 typed plan/result、TMDB API、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 | +| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | +| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | +| 全量 mypy 历史债务 | 9,995 / 592 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 | | Ruff 历史诊断 | 633 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.61%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | @@ -86,52 +87,56 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 文件 | 行数 | 主要职责混合 | |---|---:|---| -| `app/chain/subscribe/`(已治理) | Facade 91 行 | 搜索、匹配、刷新、完成、规则引用与通知已拆至单一 owner;原 3,895 行单文件已删除 | +| `app/chain/subscribe/`(已治理) | Facade 98 行 | 搜索、匹配、刷新、完成、规则引用与通知已拆至单一 owner;原 3,895 行单文件已删除 | | `app/agent/orchestrator.py` | 2,558 | 单 Agent 运行、流式输出、工具与中间件编排 | | `app/agent/session.py` | 837 | 会话队列、worker、状态、取消与延迟清理 | | `app/agent/tasks.py` | 208 | 后台 prompt、持久化定时任务与心跳 | | `app/agent/lifecycle.py` | 166 | 接收门禁、启动、空闲回收与有界关闭 | | `app/agent/manager.py` | 27 | 稳定 `AgentManager` 构造门面 | -| `app/agent/llm/provider.py` | 420 | 稳定 `LLMProviderManager` Facade 与兼容方法 | +| `app/agent/llm/provider.py` | 413 | 稳定 `LLMProviderManager` Facade 与兼容方法 | | `app/agent/llm/catalog.py` | 1,571 | provider spec、预设和模型元数据 | | `app/agent/llm/discovery.py` | 985 | 远端目录发现与 SDK 客户端 I/O | | `app/agent/llm/auth.py` | 582 | 持久鉴权和外部授权协议 | | `app/adapters/external/market.py`(已治理) | Facade 226 行 | `PluginHelper` 只保留精确旧 ABI、安装 Gateway 及 owner 委托 | -| `app/adapters/external/plugin/client.py` | 1,600 | 插件市场传输、索引、Release 与本地仓候选查询 | -| `app/adapters/system/plugin/package.py` | 2,324 | 插件安装、checkpoint、备份、恢复和物理删除事务 | +| `app/adapters/external/plugin/client.py` | 1,927 | 插件市场传输、索引、Release 与本地仓候选查询 | +| `app/adapters/system/plugin/package.py` | 2,391 | 插件安装、checkpoint、备份、恢复和物理删除事务 | | `app/adapters/system/plugin/health.py` | 1,060 | 插件运行环境保护、安装前后检查与故障恢复 | | `app/chain/transfer/`(已治理) | Facade 82 行 | 队列/恢复、规划、执行、结算、历史/通知已拆至单一 owner;旧 `transfer.py` 与 `_transfer.py` 已删除 | -| `app/chain/search.py` | 2,970 | 搜索计划、并发 fan-out、状态、分页和结果处理 | -| `app/api/endpoints/agent.py` | 2,346 | HTTP/SSE、文件/音频、Agent 会话和事件编排 | +| `app/chain/search/`(已治理) | Facade 473 行 | 搜索计划、并发 fan-out、状态、分页和结果处理已拆至单一 owner;旧 2,970 行单文件已删除 | +| `app/api/endpoints/agent.py`(已治理) | 577 | 只保留 HTTP/SSE、文件/音频传输映射;Agent 会话和事件编排已下沉 Application | +| `app/application/messaging/agent.py` | 2,195 | WebAgent 会话、文件/音频、事件与消息桥接用例;是后续复杂度治理热点,不回流 endpoint | | `app/chain/download/`(已治理) | Facade 47 行 | 选择、提交、批量、历史、提交后处理、字幕和任务控制已拆至单一 owner;原 2,413 行单文件已删除 | | `app/chain/media/`(已治理) | Facade 213 行 | 识别、来源投影、插件事件、音乐目录、路径证据与缓存已拆至单一 owner;旧 2,191 行单文件已删除 | -| `app/scheduler/` | Facade 128 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner | +| `app/scheduler/`(已治理) | Facade 135 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner | 热点不是按行数机械拆文件的依据。只有在提取出稳定合同、保留旧入口委托并有行为测试时,拆分才算 降低复杂度;把长方法原样移动到新目录不算完成。 ## 4. 优化清单总表 -状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面;`已验证` 表示实现和本地合同已落地但未在本文中声明未知的提交或 CI;`渐进` 表示应按触碰路径逐步收敛。 +状态含义:`阻塞` 表示当前门禁已失败;`待执行` 表示尚未开始;`执行中` 表示只完成部分纵切面; +`已验证` 表示实现和本地验收已通过,但本叶提交、推送或精确 head 远端门禁尚未全部闭环; +`已交付` 表示实现、提交、推送、远端一致性和该叶要求的 CI 证据均已闭环;`已取消` 表示维护者明确 +取消且不再作为本轮完成依赖。 | ID | 优先级 | 状态 | 事项 | 目标结果 | |---|---|---|---|---| | ARCH-001 | P0 | 已交付 | 恢复 mypy ratchet | `5df388719` 已推送,主线既有 CI gate 通过 | | ARCH-101 | P1 | 已交付 | 统一规则、总览、基线和语义门禁 | `113355784` 已推送,Unit Tests `33031697902`、Pylint `33031697785` 全绿,远端 `0/0` | | ARCH-102 | P1 | 已交付 | 将 Transfer pending 升级为真实 E3 状态机 | `e9de149db`、`a2e249f20` 已推送;Unit Tests `33092427327`、Pylint `33092427348` 全绿,崩溃结果未知时进入人工确认 | -| ARCH-103 | P1 | 已交付 | 类型化 Chain/Agent 数据 Port 与 DTO | Chain/Agent 正式数据入口已切换到冻结 DTO 与 typed Port,raw Oper/ORM/`Any` 仅留统一 Legacy/Compat | +| ARCH-103 | P1 | 已验证 | 类型化 Chain/Agent 数据 Port 与 DTO | Chain/Agent 正式数据入口已切换到冻结 DTO 与 typed Port,raw Oper/ORM/`Any` 仅留统一 Legacy/Compat | | ARCH-104 | P1 | 已交付 | 收口跨多次写入的业务事务 | 订阅、站点和规则引用变更由单一 UoW/CAS 拥有,失败整体回滚或幂等恢复 | -| ARCH-105 | P1 | 已交付 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 | -| ARCH-106 | P1 | 已交付 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志、消息、任务、模块与插件资源由 lifecycle 显式创建、逆序关闭和失败回滚 | -| ARCH-107 | P1 | 已交付 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 | -| ARCH-108 | P1 | 已交付 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application/Chain 具体 Adapter 与普通 direct HTTP 债务清零,剩余出口均为精确政策例外 | -| ARCH-109 | P1 | 已交付 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler、Download、Search、Media 与 Agent/System/Plugin API 均已按 owner 收敛 | +| ARCH-105 | P1 | 已验证 | 明确 post-commit 与 Outbox 完成语义 | 业务提交、effect 完成/pending 可区分;stager/store 分离且 claim/settlement 受 fencing,外部 sink 仍承担 at-least-once 幂等边界 | +| ARCH-106 | P1 | 已验证 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志、消息、任务、模块与插件资源由 lifecycle 显式创建、逆序关闭和失败回滚 | +| ARCH-107 | P1 | 已验证 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 | +| ARCH-108 | P1 | 已验证 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application/Chain 具体 Adapter 与普通 direct HTTP 债务清零,剩余出口均为精确政策例外 | +| ARCH-109 | P1 | 已验证 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler、Download、Search、Media 与 Agent/System/Plugin API 均已按 owner 收敛;部分早期叶只保留阶段性验证记录 | | ARCH-110 | P1 | 已取消 | Module/Event Contract 分可信级执行 | 随 S4 取消,不属于本轮完成依赖;现有宿主严格合同和插件兼容诊断继续保留 | | ARCH-111 | P1 | 已取消 | 升级复杂度、类型、覆盖率和并发原语门禁 | 随 S4 取消,不扩张为全宿主质量债务清零;保留现有 ratchet | | ARCH-201 | P2 | 已交付 | 收窄 PluginHelper/PluginManager 与 SDK 暴露面 | `fa40f29df` 已推送;ABI Facade 只委托,构造和具体服务归组合根 | -| ARCH-202 | P2 | 已交付 | 拆分 Agent/LLM provider 职责 | provider catalog、发现、认证、会话和运行时分离,Facade 只保留稳定 API | -| ARCH-203 | P2 | 已交付 | 拆分 Domain 投影与 Startup 高扇出目录 | Domain 四来源投影与 Startup 单词型 composition owner 均已完成 | -| ARCH-204 | P2 | 已交付 | 合并重复 sync/async 核心逻辑并转换存量测试风格 | 双 ABI 只保留 I/O 外壳,共享解析、校验、映射和状态决策 | +| ARCH-202 | P2 | 已验证 | 拆分 Agent/LLM provider 职责 | provider catalog、发现、认证、会话和运行时分离,Facade 只保留稳定 API;实现提交已在远端,待最终精确 head CI 统一闭环 | +| ARCH-203 | P2 | 已验证 | 拆分 Domain 投影与 Startup 高扇出目录 | Domain 四来源投影与 Startup 单词型 composition owner 均已完成,等待最终远端门禁闭环 | +| ARCH-204 | P2 | 已验证 | 合并重复 sync/async 核心逻辑并转换存量测试风格 | 双 ABI 只保留 I/O 外壳,共享解析、校验、映射和状态决策,等待最终远端门禁闭环 | ## 5. P0:先恢复主线 @@ -296,7 +301,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - [x] Subscription 按领域定义 Query/Write/Staging/History Protocol,不再使用通用 `OperFactory = Callable[[], Any]` 或 raw Subscribe Oper factory。 -- [ ] 写 Port 由 `db/adapters` 创建单操作 Session/UoW;Oper 的 canonical 写方法只 stage/flush, +- [x] 写 Port 由 `db/adapters` 创建单操作 Session/UoW;Oper 的 canonical 写方法只 stage/flush, 读取方法仍可在调用方 Session 中查询。 - [x] Workflow 查询 Port 在 adapter Session 内映射为冻结 DTO/Projection,API、Agent、Chain、Scheduler、 Workflow runtime 和中心服务分享均不接收 ORM。 @@ -401,7 +406,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - [x] commit 后先取得 delivery lease;未取得时跳过请求线程直投,确保与 dispatcher 排他。 - [x] 每个 post-commit effect 使用独立 intent 隔离和结算,避免效果 A 失败导致效果 B 被一起重放。 单个外部效果仍是 at-least-once,必须有稳定幂等键和幂等 handler/消费者。 -- [ ] 按完成承诺、可重建性、外部不可逆性和业务重要性划分 E0-E3;用户可见性只是因素之一。 +- [x] 按完成承诺、可重建性、外部不可逆性和业务重要性划分 E0-E3;用户可见性只是因素之一。 + 完整分类、失败语义和恢复责任记录在 `docs/adr/0007-background-action-reliability.md`。 - [x] 拆分 `OutboxStager` 与 `OutboxDispatchStore`,避免业务 Session 调用自提交方法。 - [x] 在 commit、claim、publish、complete、通知和任务提交各断点注入异常/崩溃,验证声明等级与实际恢复一致。 - [x] 增加请求线程即时投递与 dispatcher 并发竞争测试,以及“外部调用成功、complete 前崩溃”的重放测试。 @@ -421,8 +427,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - 日志 writer 已退出 `app.runtime.config` 导入路径,由 lifespan 显式创建、关闭并在真实收敛后释放身份。 - `ChainBase` 只绑定共享消息队列的轻量客户端;队列线程由 lifespan 唯一启动,不再保存首个 Chain 回调。 - 停止状态、WebPush registry 和主循环 owner 已拆为独立 runtime 合同;`global_vars` 只保留旧插件 ABI 门面。 -- 当前 TaskRegistry 门禁只检查 Registry 调用是否带 owner,不会扫描全部原生 `create_task`、 - `Thread`、`Timer`、`Executor`。 +- 当前 TaskRegistry 门禁检查 Registry 调用必须带 owner;全宿主原生 `create_task`、`Thread`、 + `Timer`、`Executor` 清单原属于已取消 ARCH-111,不作为 ARCH-106 的完成依赖。 - service locator 门禁继续覆盖五类 concrete runtime;独立 AST 门禁现已拒绝 canonical 宿主导入 `global_vars`,只允许定义模块与 SDK 兼容出口。 - Workflow、Scheduler、Command、Agent provider 均由生命周期显式 configure/reset,冷导入不再改变注册状态。 @@ -437,8 +443,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - [x] 保留 `global_vars` 作为兼容薄门面,但禁止 canonical 宿主新增依赖。 - [x] 将 initializer 的 provider 注册迁入显式装配阶段并提供测试 reset;冷导入回归测试覆盖 Workflow、Scheduler、Command、Agent、技能目录和 LLM provider。 -- [ ] 建立原生并发原语清单:结构化局部等待、TaskRegistry、显式生命周期 owner 或受限上下文; - 未分类的新原语由 ratchet 拒绝。 +- [x] 对本叶迁移的日志、消息、Scheduler、Workflow、Agent、Plugin 资源建立 owner 与生命周期门禁; + 全宿主原生并发原语清单不在本叶偷扩范围,保留为已取消 ARCH-111 的历史定义。 **验收** @@ -462,9 +468,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` **问题与证据** -- 当前新增 SCC 为 `app.chain -> app.chain._messaging/_recognition -> app.chain`。 +- 当前宿主图只剩精确 containment 的 TMDB 移植包环;Chain 包内 SCC 已清零。 - `ChainBase` 已迁到 `app/chain/base.py`;物理包根仅保留包说明,旧符号由 Compat/SDK 惰性解析。 -- `baseline.py --check-host` 会确认该 SCC 与 fixture 一致,但现有语义测试没有拒绝 Chain 包内环。 +- `baseline.py --check-host` 与独立 SCC 语义测试共同拒绝 Chain 包内环、allowlist 成员增长和 + 包外模块通过反向边加入 TMDB containment。 **目标与步骤** @@ -498,14 +505,14 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - `app/application` 原有 8 个文件、13 条直接 Adapter 导入已全部迁移为 Application-owned Port, startup 统一注入具体实现;Passkey、Backup 与 DNS 纵切面也已退出临时 policy。 -- `app/chain` 有 8 个文件、13 条直接 Adapter 导入,使用 `RequestUtils`、Browser、Cloudflare、 - CookieCloud、ServerHelper 等具体能力。 +- `app/chain` 原有 8 个文件、13 条直接 Adapter 导入和 11 条普通 HTTP/Session bridge 已全部 + 迁移至注入 Port 或统一技术边界,当前具体 Adapter 与普通 direct HTTP 债务均为零。 - Passkey Application 已改为消费启动注入的 `PasskeyChallengeCache`,不再判断 Redis 或导入 具体 cache adapter;Memory/Redis 均实现严格 `AtomicCacheBackend.store/consume`。 -- 审计时 LLM streaming、第三方 SDK、移植库和本地控制面没有精确例外表;S0-L2.4b 已建立 - 66 条完整 egress identity 与 zero-growth policy,其中 11 条普通 HTTP/Session bridge 和 1 条 - Application DNS I/O 是清零债务;每条初始边另有独立指纹上界,不能靠同时刷新 baseline/policy - 掩盖同一边的调用面增长,债务删除后也不得恢复。 +- S0-L2.4b 已为 LLM streaming、第三方 SDK、移植库和本地控制面建立完整 egress identity 与 + zero-growth policy;11 条普通 HTTP/Session bridge 和 1 条 Application DNS I/O 债务已清零。 + 当前 53 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新 + baseline/policy 掩盖调用面增长,已删除债务也不得恢复。 **目标与步骤** @@ -513,11 +520,12 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - [x] 建立全宿主 direct egress 事实;SDK/stream/vendor/local-control 例外精确到 bindings/uses 指纹。 - [x] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。 - [x] 为 Backup 定义 Application-owned artifact store Port,由 startup 注入文件系统实现。 -- [ ] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。 +- [x] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。 - [x] 为 Application SSRF 校验注入 DNS 解析 Port,清除 `socket.getaddrinfo` 直接 I/O。 - [x] Application 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。 -- [ ] Chain 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。 -- [ ] 最终把基线收缩到零或少量书面化例外,而不是一次性禁止后再大量豁免。 +- [x] Chain 中命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不保留直连例外。 +- [x] Adapter/direct HTTP 债务基线已收缩为零;其余 canonical transport、SDK、stream、vendor、 + diagnostic 和 control-plane 出口均按用途精确书面化,不使用宽泛豁免。 ```bash .venv/bin/python -m pytest \ @@ -702,8 +710,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 1. ARCH-102 设计并迁移 Transfer E3 状态机。 2. 以站点/规则引用清理为 ARCH-103/104 的第一个 typed Port + UoW 纵切面。 -3. ARCH-105 已完成 post-commit 结构化结果、Outbox 角色拆分和 claim fencing; - 后续只继续清理独立 intent 与更完整的崩溃矩阵。 +3. ARCH-105 已完成 post-commit 结构化结果、独立 intent、Outbox 角色拆分、claim fencing 和 + commit/claim/publish/complete/通知/任务提交崩溃矩阵。 **退出条件**:故障注入覆盖清单列出的崩溃窗口;schema 有 migration;主路径不再 fail-open;调用方能区分 业务提交与后置效果 pending。 @@ -712,7 +720,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 1. ARCH-106 将日志、消息队列和主循环 gateway 纳入 lifecycle。 2. ARCH-107 拆出 `chain/base.py` 并消除新增 SCC。 -3. ARCH-108 的 Passkey 缓存与 Backup artifact 边界已验证;后续按风险迁移 DNS 和其余外部调用。 +3. ARCH-108 的 Passkey、Backup、DNS、Application/Chain Adapter 与普通 HTTP 纵切面均已完成; + 现存协议出口只保留精确书面化例外并由 zero-growth 门禁守护。 **退出条件**:冷导入不启动线程;SCC 只剩 TMDB 精确豁免;Application/Chain 到 Adapter 的债务只降不增。 @@ -726,7 +735,8 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 ### 阶段 4:取消记录与 S5 收口 1. ARCH-110/111 随 S4 于 2026-08-28 取消,不再作为本轮完成依赖。 -2. ARCH-201 至 ARCH-204 已由 S5 完成 Facade、Provider、Domain/Startup 与 sync/async 重复治理。 +2. ARCH-201/202 已完成 Facade 与 Provider 实现,ARCH-203/204 的 Domain/Startup 与 sync/async + 重复治理已通过本地验证;最终交付状态由 S5-L6 的精确 head 远端门禁统一闭环。 3. 保留现有 strict frontier、复杂度、mypy、Ruff、覆盖率和并发 ratchet,不借取消阶段接受回退。 **退出条件**:S5 全部叶子通过现有门禁与官方插件兼容验证,canonical 无旧实现和重复导出。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index a35ab7b77..1f521ea56 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -7,7 +7,7 @@ > [`docs/rules/04-design-patterns.md`](rules/04-design-patterns.md) 为准,本文与其保持一致; > 如出现差异,以规则文档为准。 > -> *Last Updated: 2026-08-29* +> *Last Updated: 2026-08-30* --- @@ -747,15 +747,15 @@ flowchart LR SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。 - 延迟导入不被接受为隐藏循环依赖的手段。 -### 10.1 2026-08-29 当前收口状态与后续边界 +### 10.1 2026-08-30 当前收口状态与后续边界 当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自 `tests/fixtures/architecture/`,更新基线前必须先审查语义变化: | 指标 | 当前值 | |---|---:| -| Python 模块 | 972 | -| 内部导入边 | 8,350 | +| Python 模块 | 973 | +| 内部导入边 | 8,355 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index 7f9e188bd..b2af592ee 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -27,7 +27,7 @@ | `PLANNED` | 已定义边界,依赖满足后可激活 | | `BLOCKED` | 有明确阻塞证据,且没有可推进的替代叶子 | | `VERIFIED` | 本地验收完成,尚未推送或尚未确认远端 | -| `DELIVERED` | 已提交、推送,远端 SHA 与本地交付一致 | +| `DELIVERED` | 已提交、推送,远端 SHA 与本地交付一致,且本叶要求的远端 CI 已闭环 | | `COMPLETE` | 叶子验收和后续依赖均已结清 | | `CANCELLED` | 经维护者明确取消,不进入执行、验收或完成计数 | @@ -54,15 +54,15 @@ G-ARCH 只有在 S3、S5 及以下条件全部满足后才可完成;S4 不属于本轮完成范围: -- [x] `ARCH-001`、`ARCH-101` 至 `ARCH-109`、`ARCH-201` 至 `ARCH-204` 全部完成;`ARCH-110/111` 随 S4 取消且不作为完成依赖。 +- [ ] `ARCH-001`、`ARCH-101` 至 `ARCH-109`、`ARCH-201` 至 `ARCH-204` 全部完成;`ARCH-110/111` 随 S4 取消且不作为完成依赖。 - [x] 宿主依赖图没有未解释 SCC;只允许精确 containment 的 TMDB 移植包例外,成员不能增长。 -- [x] Application/Chain 到具体 Adapter、direct egress、raw concurrency 等所有例外均精确、可解释、不可增长。 +- [x] Application/Chain 到具体 Adapter、direct egress,以及本轮迁移资源的并发 owner 例外均精确、可解释、不可增长。 - [x] Chain/Agent 正式数据入口不再注入无 Session Oper,不再以 `Any`/ORM 作为跨层合同。 - [x] 正式业务写入均有单一事务 owner;E2/E3 动作完成语义、幂等、恢复与人工决策路径一致。 - [x] import、普通对象构造不启动进程资源;资源由 bootstrap/lifecycle 显式创建、发布、关闭和重试收口。 - [x] S3、S5 各叶子定义的类型、复杂度、覆盖率和并发债务清零,现有全局 ratchet 不发生回退。 - [x] canonical 主程序无旧实现、重复导出和 legacy import;插件兼容检查基于同步后的官方插件仓 SHA 通过。 -- [x] 锁定全量测试、Pylint、依赖一致性和最终远端一致性全部通过;本轮未修改依赖。 +- [ ] 锁定全量测试、Pylint、依赖一致性和最终远端一致性全部通过;本轮未修改依赖。 ## 3. 阶段与叶子 @@ -86,9 +86,9 @@ G-ARCH 只有在 S3、S5 及以下条件全部满足后才可完成;S4 不属 退出条件:Transfer 达到 E3;正式数据 Port 类型化;跨表业务操作有单一 UoW;post-commit/Outbox 竞争、失败呈现、at-least-once 和幂等语义全部闭环。 -`S1-L1` 是 ARCH-102 的 Transfer E3 父项,当前状态为 **DELIVERED**。`S1-L1.1` 至 -`S1-L1.5` 已全部交付,真实调用链已完成迁移,旧 fail-open、重复状态和兼容层外旧入口已退出 -canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 +`S1-L1` 是 ARCH-102 的 Transfer E3 父项,当前状态为 **DELIVERED**。`S1-L1.5` 聚合交付 +`S1-L1.1` 至 `S1-L1.4` 的阶段性 `VERIFIED` 结果;真实调用链已完成迁移,旧 fail-open、重复状态和 +兼容层外旧入口已退出 canonical 主程序,兼容只经统一 Compat/SDK 门面提供。 | Leaf | 状态 | 依赖 | 完成定义 | |---|---|---|---| @@ -125,12 +125,12 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | S2-L4 Passkey 缓存边界 | `VERIFIED` | S0-L2.4 | `PasskeyChallengeCache` 由 startup 注入,Application 不识别 Redis;严格 `AtomicCacheBackend.store/consume` 由 Memory/Redis backend 分别实现,challenge 仅能被原子领取一次 | | S2-L5 Backup artifact Port | `VERIFIED` | S0-L2.4 | Application-owned `BackupArtifactStore`/factory 覆盖创建、发布、清理、列举、解析、删除和元数据读取;startup 注入唯一 `BackupFiles` 实现,Application 具体 Adapter 直连由 14 降至 13,完整备份调用链 62 项通过 | | S2-L6 Application Adapter/DNS 债务清零 | `VERIFIED` | S2-L4,S2-L5 | Application 自有 DNS、图片、消息回环、RSS、站点登录、种子、系统加速 Port,startup 统一注入;13 条具体 Adapter 边及 Application DNS I/O 均归零,未装配和失败回滚语义已验证 | -| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `VERIFIED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 均归零;当前 55 条出口事实全部属于精确 SDK/stream/vendor/canonical containment,policy 无 unreviewed、stale 或指纹漂移 | +| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `VERIFIED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 均归零;当前 53 条出口事实全部属于精确 SDK/stream/vendor/canonical containment,policy 无 unreviewed、stale 或指纹漂移 | ### S3:大型编排器职责清零 退出条件:每个热点 Facade 只保留稳定公开入口;决策、I/O、状态和生命周期各有单一 owner; -旧实现从 canonical 文件删除,complexity-v2 对该所有权面无债务。 +旧实现从 canonical 文件删除,现有复杂度 ratchet 与叶子结构门禁对该所有权面无新增债务。 | Leaf | 状态 | 依赖 | 完成定义 | |---|---|---|---| @@ -166,11 +166,11 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | Leaf | 状态 | 依赖 | 完成定义 | |---|---|---|---| | S5-L1 PluginHelper/PluginManager | `DELIVERED` | S2,S3 | `fa40f29df`:市场/包/依赖/备份/健康服务各归 owner;Facade 只转发稳定 ABI,构造归 typed PluginRuntime | -| S5-L2 Agent/LLM provider | `DELIVERED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058`:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,旧包根符号仅由精确 Compat 承接 | -| S5-L3 Domain projection | `DELIVERED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 | -| S5-L4 Startup composition | `DELIVERED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 | -| S5-L5 Sync/async 重复清零 | `DELIVERED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 | -| S5-L6 最终兼容与交付 | `DELIVERED` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 | +| S5-L2 Agent/LLM provider | `VERIFIED` | S3-L7 | `c000c4fff`、`3a33da0b3`、`dbdf7d058` 已在 `origin/v3` 祖先链:catalog、发现、认证、session、runtime 分离;Manager/Facade 只保留稳定 API,待最终精确 head CI 统一闭环 | +| S5-L3 Domain projection | `VERIFIED` | S3-L6 | `MediaInfo` canonical 路径和 setter ABI 保留;四来源纯投影归入单词 owner,输入不可变、重复业务语义清零 | +| S5-L4 Startup composition | `VERIFIED` | S2,S3,S5-L1,S5-L2 | 领域构造全部归单词型 composition owner;HostRuntime/ApiData 同源;显式逆序 reset manifest 在 worker 收敛后撤销 Provider,失败保留完整诊断边界 | +| S5-L5 Sync/async 重复清零 | `VERIFIED` | S3,S5 | 双 ABI 外壳共享纯逻辑,重复业务实现清零,Session/客户端不跨并发边界复用 | +| S5-L6 最终兼容与交付 | `ACTIVE` | S3,S5-L1,S5-L2,S5-L3,S5-L4,S5-L5 | canonical 旧实现/重复导出清零;同步官方插件仓验证;锁定全量、Pylint、架构、现有类型/覆盖率门禁全部通过并推送;完成后结束本轮战略任务 | ## 4. 叶子合同与当前活动项 @@ -361,7 +361,7 @@ TMDB、豆瓣、Bangumi、AniList 详情到统一字段的规则一次迁入 `ap ### S5-L6 最终兼容与交付 -**Status:** `DELIVERED` +**Status:** `ACTIVE` - 22 个不符合规范的 canonical 文件名已归并为单词文件或同名职责包;旧实现文件和主程序包根重复导出 已删除,宿主调用方直接导入真实 owner,兼容只由精确 SDK/Compat manifest 提供。 @@ -371,8 +371,13 @@ TMDB、豆瓣、Bangumi、AniList 详情到统一字段的规则一次迁入 `ap - 锁定串行覆盖率全量 `7659 passed, 9 skipped`;Application/Domain 低水位提升并固化为 `81.61%` / `81.03%`。Host 架构、Event/Egress policy、复杂度、async 阻塞、TaskRegistry、 service locator、mypy/Ruff ratchet、启动性能、Pylint `10.00/10` 与依赖一致性均通过。 -- 本叶随最终收口提交推送到 `v3`;以 exact head 的 GitHub Unit Tests/Pylint 终态和远端 `0/0` - 作为 G-ARCH 结束证据。 +- exact-head 失败后已将 canonical `SearchChain` Facade 改为显式类型转发,并修正 API/Workflow 的 + Optional、Domain/Schema DTO 边界;2026-08-30 最终本地锁定全量 `7659 passed, 9 skipped`, + CI 同构架构测试 `197 passed`,mypy 低水位降至 `9,995`,Ruff、Pylint `10.00/10`、复杂度、 + async、TaskRegistry、service locator、启动性能与依赖快照门禁均通过。 +- 收口提交 `c960a0184` 已推送,但 exact-head Unit Tests `33260357239` 的 mypy ratchet 暴露 + canonical `SearchChain` 直达 owner 后的类型债务,因此本叶仍为 `ACTIVE`;不得以本地全量或 Pylint + 成功替代远端失败终态。 ### S1-L1.1 Durable admission diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 5ca8c1c85..78c67209f 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8350, - "edge_sha256": "cc67750171cce278b34e966da0c00faa5fef41f843a37fba472c657a13f8fb24", + "edge_count": 8355, + "edge_sha256": "83d2fd65af87bfe90562c115b235afd2446e1e3576393398687a6e76e0c34105", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3338,6 +3338,7 @@ "app.api.endpoints.search -> app.chain.search", "app.api.endpoints.search -> app.chain.search.facade", "app.api.endpoints.search -> app.domain", + "app.api.endpoints.search -> app.domain.context", "app.api.endpoints.search -> app.domain.media", "app.api.endpoints.search -> app.runtime", "app.api.endpoints.search -> app.runtime.localization", @@ -3491,6 +3492,7 @@ "app.api.endpoints.system -> app.chain.search", "app.api.endpoints.system -> app.chain.search.facade", "app.api.endpoints.system -> app.domain", + "app.api.endpoints.system -> app.domain.context", "app.api.endpoints.system -> app.domain.metainfo", "app.api.endpoints.system -> app.foundation", "app.api.endpoints.system -> app.foundation.crypto", @@ -9357,10 +9359,13 @@ "app.workflow.actions.fetch_torrents -> app.chain.media", "app.workflow.actions.fetch_torrents -> app.chain.search", "app.workflow.actions.fetch_torrents -> app.chain.search.facade", + "app.workflow.actions.fetch_torrents -> app.domain", + "app.workflow.actions.fetch_torrents -> app.domain.context", "app.workflow.actions.fetch_torrents -> app.runtime", "app.workflow.actions.fetch_torrents -> app.runtime.log", "app.workflow.actions.fetch_torrents -> app.runtime.stop", "app.workflow.actions.fetch_torrents -> app.schemas", + "app.workflow.actions.fetch_torrents -> app.schemas.context", "app.workflow.actions.fetch_torrents -> app.schemas.types", "app.workflow.actions.fetch_torrents -> app.schemas.workflow", "app.workflow.actions.fetch_torrents -> app.workflow", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index bdd9e9be0..b5a232f55 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -578,7 +578,6 @@ "arg-type": 1, "assignment": 1, "misc": 1, - "no-untyped-call": 1, "no-untyped-def": 2 }, "app/agent/tools/impl/reload_plugin.py": { @@ -867,7 +866,7 @@ "assignment": 8, "attr-defined": 3, "misc": 14, - "no-untyped-call": 7, + "no-untyped-call": 5, "no-untyped-def": 3, "type-arg": 1 }, @@ -942,7 +941,6 @@ "assignment": 2, "attr-defined": 3, "misc": 26, - "no-untyped-call": 1, "no-untyped-def": 2, "return-value": 3, "type-arg": 6, @@ -966,7 +964,7 @@ }, "app/api/endpoints/system.py": { "arg-type": 2, - "attr-defined": 5, + "attr-defined": 4, "import-untyped": 1, "misc": 34, "no-untyped-call": 7, @@ -1251,7 +1249,7 @@ "arg-type": 1, "attr-defined": 26, "no-any-return": 6, - "no-untyped-call": 2, + "no-untyped-call": 1, "no-untyped-def": 2, "type-arg": 4 }, @@ -1679,8 +1677,8 @@ "app/domain/context.py": { "assignment": 86, "no-any-return": 1, - "no-untyped-call": 5, - "no-untyped-def": 27, + "no-untyped-call": 4, + "no-untyped-def": 24, "operator": 1, "type-arg": 36 }, @@ -3345,9 +3343,7 @@ "var-annotated": 1 }, "app/workflow/actions/fetch_torrents.py": { - "type-arg": 1, - "union-attr": 2, - "var-annotated": 1 + "type-arg": 1 }, "app/workflow/actions/filter_medias.py": { "operator": 1, diff --git a/tests/test_chain_vertical_slices.py b/tests/test_chain_vertical_slices.py index bb34eeae6..1fc241558 100644 --- a/tests/test_chain_vertical_slices.py +++ b/tests/test_chain_vertical_slices.py @@ -27,8 +27,8 @@ from app.chain.transfer import TransferChain SearchChain, { "save_last_search_params": "_search_state", - "last_search_params": "_search_state", - "last_search_results": "_search_state", + "last_search_params": "SearchCacheOwner", + "last_search_results": "SearchCacheOwner", }, ), ( diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index fa648db98..ce15b61bf 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +from app.domain.context import Context, TorrentInfo +from app.domain.metainfo import MetaInfo from app.schemas.download import DownloadTask from app.schemas.file import FileItem from app.schemas.workflow import ActionContext, ActionResult @@ -53,15 +55,13 @@ def test_fetch_torrents_filters_special_season_zero(monkeypatch): def search_by_title(self, **_kwargs): return [ - SimpleNamespace( - meta_info=SimpleNamespace(year=None, begin_season=0), - media_info=None, - torrent_info=SimpleNamespace(title="Test S00"), + Context( + meta_info=MetaInfo("Test S00"), + torrent_info=TorrentInfo(title="Test S00"), ), - SimpleNamespace( - meta_info=SimpleNamespace(year=None, begin_season=1), - media_info=None, - torrent_info=SimpleNamespace(title="Test S01"), + Context( + meta_info=MetaInfo("Test S01"), + torrent_info=TorrentInfo(title="Test S01"), ), ]