diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index f397314ef..0afdd2a36 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -79,6 +79,9 @@ _METHOD_CONTRACTS = { result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("meta", "mtype", "media_source", "media_id", "episode_group", "cache"), ), + "match_doubaninfo": ModuleMethodContract(family="media-recognition", input_contract="DoubanMatchRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("name", "imdbid", "mtype", "year", "season", "raise_exception")), + "match_tmdbinfo": ModuleMethodContract(family="media-recognition", input_contract="TmdbMatchRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("name", "mtype", "year", "season")), + "update_recognize_cache": ModuleMethodContract(family="media-recognition", input_contract="RecognitionCacheUpdateRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("meta", "mediainfo")), "search_medias": ModuleMethodContract( family="media-recognition", input_contract="MediaSearchRequest", result_contract="list[MediaInfo]", result_shape=ModuleResultShape.LIST, @@ -217,6 +220,7 @@ _METHOD_CONTRACTS = { "download_wechat_media_bytes": ModuleMethodContract(family="messaging", input_contract="MessageMediaDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("media_ref", "source")), "downloader_info": ModuleMethodContract(family="downloader", input_contract="DownloaderInfoRequest", result_contract="list[DownloaderInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("downloader",)), "list_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentListRequest", result_contract="list[DownloaderTorrent]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("status", "hashs", "downloader", "include_all_tags")), + "filter_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentFilterRequest", result_contract="list[TorrentInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("rule_groups", "torrent_list", "mediainfo")), "refresh_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentRefreshRequest", result_contract="list[TorrentInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("site", "keyword", "cat", "page", "mtype")), "torrent_files": ModuleMethodContract(family="downloader", input_contract="TorrentFilesRequest", result_contract="DownloaderFileCollection | None", required_parameters=("tid", "downloader")), "get_torrent_trackers": ModuleMethodContract(family="downloader", input_contract="TorrentTrackersRequest", result_contract="dict[str, list[str]] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.ORDERED_MAPPING_MERGE, required_parameters=("hash_string", "downloader")), @@ -233,6 +237,9 @@ _METHOD_CONTRACTS = { # 同一能力的同步/异步入口共享不可变契约对象,避免参数和聚合语义各自漂移。 _METHOD_CONTRACTS.update({ "async_recognize_media": _METHOD_CONTRACTS["recognize_media"], + "async_match_doubaninfo": _METHOD_CONTRACTS["match_doubaninfo"], + "async_match_tmdbinfo": _METHOD_CONTRACTS["match_tmdbinfo"], + "async_update_recognize_cache": _METHOD_CONTRACTS["update_recognize_cache"], "async_search_medias": _METHOD_CONTRACTS["search_medias"], "async_obtain_images": _METHOD_CONTRACTS["obtain_images"], "async_movie_hot": _METHOD_CONTRACTS["movie_hot"], diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 806f62d75..b91177f2a 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -2958,16 +2958,23 @@ "version": 1 }, "async_match_doubaninfo": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "DoubanMatchRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "imdbid", + "mtype", + "name", + "raise_exception", + "season", + "year" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -2994,16 +3001,21 @@ "version": 1 }, "async_match_tmdbinfo": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "TmdbMatchRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "mtype", + "name", + "season", + "year" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -3590,16 +3602,19 @@ "version": 1 }, "async_update_recognize_cache": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "RecognitionCacheUpdateRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "mediainfo", + "meta" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4321,16 +4336,20 @@ "version": 1 }, "filter_torrents": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "downloader", - "input_contract": "DownloaderKeywordArguments", + "input_contract": "TorrentFilterRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "DownloaderProviderResult", - "result_shape": "any", + "required_parameters": [ + "mediainfo", + "rule_groups", + "torrent_list" + ], + "result_contract": "list[TorrentInfo]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4569,16 +4588,23 @@ "version": 1 }, "match_doubaninfo": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "DoubanMatchRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "imdbid", + "mtype", + "name", + "raise_exception", + "season", + "year" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4605,16 +4631,21 @@ "version": 1 }, "match_tmdbinfo": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "TmdbMatchRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "mtype", + "name", + "season", + "year" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -6281,16 +6312,19 @@ "version": 1 }, "update_recognize_cache": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", - "input_contract": "MediaRecognitionKeywordArguments", + "input_contract": "RecognitionCacheUpdateRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MediaRecognitionProviderResult", - "result_shape": "any", + "required_parameters": [ + "mediainfo", + "meta" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index c36de6759..4dc7522ce 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -268,6 +268,36 @@ def test_lookup_contracts_separate_value_routes_from_list_aggregation() -> None: assert contract.result_contract +def test_recognition_match_and_cache_contracts_share_sync_async_semantics() -> None: + """媒体匹配与缓存回填的同步、异步入口必须复用同一目标路由契约。""" + pairs = { + "match_doubaninfo": "async_match_doubaninfo", + "match_tmdbinfo": "async_match_tmdbinfo", + "update_recognize_cache": "async_update_recognize_cache", + } + + for sync_method, async_method in pairs.items(): + contract = get_module_method_contract(sync_method) + assert contract is get_module_method_contract(async_method) + assert contract.family == "media-recognition" + assert contract.aggregation is ModuleResultAggregation.FIRST_NON_EMPTY + assert contract.required_parameters + + +def test_torrent_filter_contract_preserves_original_argument_list_merge() -> None: + """种子过滤 provider 应接收原始参数并有序合并结果,不得误用单参数接力。""" + contract = get_module_method_contract("filter_torrents") + + assert contract.family == "downloader" + assert contract.aggregation is ModuleResultAggregation.ORDERED_LIST_MERGE + assert contract.result_shape is ModuleResultShape.LIST + assert contract.required_parameters == ( + "rule_groups", + "torrent_list", + "mediainfo", + ) + + def test_heterogeneous_torrent_files_result_remains_legacy_compatible() -> None: """下载器文件集合尚未归一前不得声明虚假的列表聚合语义。""" contract = get_module_method_contract("torrent_files")