mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
fix: merge downloader tracker mappings
This commit is contained in:
@@ -15,6 +15,7 @@ class ModuleResultAggregation(StrEnum):
|
||||
LEGACY = "legacy"
|
||||
FIRST_NON_EMPTY = "first_non_empty"
|
||||
ORDERED_LIST_MERGE = "ordered_list_merge"
|
||||
ORDERED_MAPPING_MERGE = "ordered_mapping_merge"
|
||||
|
||||
|
||||
class ModuleResultShape(StrEnum):
|
||||
@@ -118,6 +119,7 @@ _METHOD_CONTRACTS = {
|
||||
"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")),
|
||||
"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")),
|
||||
}
|
||||
|
||||
_PREFIX_CONTRACTS = (
|
||||
|
||||
@@ -383,6 +383,12 @@ class ModuleInvocationDispatcher:
|
||||
if isinstance(result, list)
|
||||
else _ProviderCallMode.STOP
|
||||
)
|
||||
if aggregation is ModuleResultAggregation.ORDERED_MAPPING_MERGE:
|
||||
return (
|
||||
_ProviderCallMode.ORIGINAL
|
||||
if isinstance(result, dict)
|
||||
else _ProviderCallMode.STOP
|
||||
)
|
||||
if allow_relay and ObjectUtils.check_signature(func, result):
|
||||
return _ProviderCallMode.RELAY
|
||||
if isinstance(result, list):
|
||||
@@ -396,10 +402,14 @@ class ModuleInvocationDispatcher:
|
||||
call_mode: _ProviderCallMode,
|
||||
) -> Any:
|
||||
"""合并单个 provider 结果,接力调用则用新结果替换旧结果。"""
|
||||
if call_mode is _ProviderCallMode.RELAY or not isinstance(result, list):
|
||||
if call_mode is _ProviderCallMode.RELAY:
|
||||
return provider_result
|
||||
if isinstance(provider_result, list):
|
||||
if isinstance(result, list) and isinstance(provider_result, list):
|
||||
result.extend(provider_result)
|
||||
elif isinstance(result, dict) and isinstance(provider_result, dict):
|
||||
result.update(provider_result)
|
||||
elif not isinstance(result, (list, dict)):
|
||||
return provider_result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
`shield` 不再让网络请求逃逸生命周期预算,仓库级并发合并、缓存键和 V1/V2/V3 返回兼容保持不变。
|
||||
请求作用域的结构化并发不进入全局登记器:传统 WebAgent SSE 的 collection 子任务改由生成器
|
||||
`finally` 取消并等待清理,断线和 ASGI 取消均不会留下请求级 task。
|
||||
2. **动态模块契约仍以 legacy 聚合语义为主。** 当前登记 `212` 个模块方法,其中 `179` 个仍使用 `legacy` aggregation,`27` 个使用 `first_non_empty`、`6` 个使用 `ordered_list_merge`。`app/runtime/extensions/module/contracts.py` 已能登记 family、输入/结果标签和基础签名诊断,调度器也已按这 33 个显式聚合声明执行首个非空或有序列表合并;但 `177` 个方法没有 required parameters,其余方法仍主要依赖运行时反射、返回值形状和旧短路规则。未知第三方方法保留 legacy fallback 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。
|
||||
2. **动态模块契约仍以 legacy 聚合语义为主。** 当前登记 `212` 个模块方法,其中 `178` 个仍使用 `legacy` aggregation,`27` 个使用 `first_non_empty`、`6` 个使用 `ordered_list_merge`、`1` 个使用 `ordered_mapping_merge`。`app/runtime/extensions/module/contracts.py` 已能登记 family、输入/结果标签和基础签名诊断,调度器也已按这 34 个显式聚合声明执行首个非空、有序列表或有序映射合并;但 `176` 个方法没有 required parameters,其余方法仍主要依赖运行时反射、返回值形状和旧短路规则。未知第三方方法保留 legacy fallback 是兼容要求,不应删除;宿主高频能力则应逐族补齐可执行的输入校验、结果校验、超时和错误语义。
|
||||
3. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。
|
||||
|
||||
Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入
|
||||
@@ -798,6 +798,9 @@ ModuleMethodSpec(
|
||||
参数、bytes/string 结果和首个非空语义登记;`list_torrents`、`downloader_info` 按三个下载器宿主实现
|
||||
冻结为有序列表合并。`torrent_files` 因 qBittorrent 的 `TorrentFilesList` 与其他下载器普通列表并存,
|
||||
只补真实参数与异构结果合同,继续显式保留 legacy 聚合,不能用错误的列表标签掩盖待归一化边界。
|
||||
- `get_torrent_trackers` 新增有序映射聚合:未指定下载器时按宿主优先级合并 qBittorrent、Transmission、
|
||||
rTorrent 的名称到 Tracker 列表映射,不再由首个非空 dict 隐式截断;插件 provider 返回映射后仍按旧 ABI
|
||||
优先短路宿主,未知方法和其他 legacy 映射不受该策略影响。
|
||||
|
||||
#### ARCH-241:Event Contract Registry
|
||||
|
||||
|
||||
@@ -4178,16 +4178,19 @@
|
||||
"version": 1
|
||||
},
|
||||
"get_torrent_trackers": {
|
||||
"aggregation": "legacy",
|
||||
"aggregation": "ordered_mapping_merge",
|
||||
"error_policy": "isolate_provider",
|
||||
"execution": "sync_or_async",
|
||||
"family": "downloader",
|
||||
"input_contract": "DownloaderKeywordArguments",
|
||||
"input_contract": "TorrentTrackersRequest",
|
||||
"plugin_short_circuit": true,
|
||||
"public_to_plugins": true,
|
||||
"required_parameters": [],
|
||||
"result_contract": "DownloaderProviderResult",
|
||||
"result_shape": "any",
|
||||
"required_parameters": [
|
||||
"downloader",
|
||||
"hash_string"
|
||||
],
|
||||
"result_contract": "dict[str, list[str]] | None",
|
||||
"result_shape": "mapping",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
|
||||
@@ -242,6 +242,75 @@ def test_ordered_list_contract_bypasses_legacy_signature_relay() -> None:
|
||||
assert dispatcher.dispatch("search_medias") == ["plugin", "system"]
|
||||
|
||||
|
||||
def test_ordered_mapping_contract_merges_system_downloader_results() -> None:
|
||||
"""未指定下载器时应按宿主优先级合并各 provider 的 Tracker 映射。"""
|
||||
class TrackerModule:
|
||||
"""返回单个下载器 Tracker 映射的测试模块。"""
|
||||
|
||||
def __init__(self, name: str, priority: int) -> None:
|
||||
"""保存下载器名称和 provider 优先级。"""
|
||||
self._name = name
|
||||
self._priority = priority
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""返回测试模块名。"""
|
||||
return self._name
|
||||
|
||||
def get_priority(self) -> int:
|
||||
"""返回测试优先级。"""
|
||||
return self._priority
|
||||
|
||||
def get_torrent_trackers(
|
||||
self,
|
||||
hash_string: str,
|
||||
downloader: str | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""返回当前测试下载器的 Tracker 映射。"""
|
||||
assert hash_string == "hash"
|
||||
assert downloader is None
|
||||
return {self._name: [f"https://{self._name}.test/announce"]}
|
||||
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
modules=[
|
||||
TrackerModule("transmission", 20),
|
||||
TrackerModule("qbittorrent", 10),
|
||||
]
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch(
|
||||
"get_torrent_trackers",
|
||||
hash_string="hash",
|
||||
downloader=None,
|
||||
) == {
|
||||
"qbittorrent": ["https://qbittorrent.test/announce"],
|
||||
"transmission": ["https://transmission.test/announce"],
|
||||
}
|
||||
|
||||
|
||||
def test_plugin_mapping_keeps_existing_host_short_circuit() -> None:
|
||||
"""插件返回 Tracker 映射后仍应保持插件优先,不再调用宿主 provider。"""
|
||||
system_call = Mock(return_value={"system": ["https://system.test"]})
|
||||
module = _Module("系统", 10, system_call)
|
||||
setattr(module, "get_torrent_trackers", module.execute)
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
plugins={
|
||||
("P1", "插件一"): {
|
||||
"get_torrent_trackers": lambda **_kwargs: {
|
||||
"plugin": ["https://plugin.test"]
|
||||
}
|
||||
},
|
||||
},
|
||||
modules=[module],
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch(
|
||||
"get_torrent_trackers",
|
||||
hash_string="hash",
|
||||
downloader=None,
|
||||
) == {"plugin": ["https://plugin.test"]}
|
||||
system_call.assert_not_called()
|
||||
|
||||
|
||||
def test_module_exception_uses_error_policy_and_continues() -> None:
|
||||
"""普通异常应交给错误策略,后续空结果模块仍可继续运行。"""
|
||||
def broken():
|
||||
|
||||
@@ -169,6 +169,15 @@ def test_heterogeneous_torrent_files_result_remains_legacy_compatible() -> None:
|
||||
assert contract.result_shape is ModuleResultShape.ANY
|
||||
|
||||
|
||||
def test_torrent_tracker_contract_merges_downloader_mappings() -> None:
|
||||
"""Tracker 查询应登记跨下载器有序映射合并,而不是首个字典短路。"""
|
||||
contract = get_module_method_contract("get_torrent_trackers")
|
||||
|
||||
assert contract.required_parameters == ("hash_string", "downloader")
|
||||
assert contract.aggregation is ModuleResultAggregation.ORDERED_MAPPING_MERGE
|
||||
assert contract.result_shape is ModuleResultShape.MAPPING
|
||||
|
||||
|
||||
def test_attachment_result_diagnostics_distinguish_bytes_and_strings() -> None:
|
||||
"""附件契约应区分二进制内容和可展示字符串,偏差仍仅供诊断。"""
|
||||
assert diagnose_module_result("download_qq_file_bytes", b"content") == ()
|
||||
|
||||
Reference in New Issue
Block a user