From d57de9b28a8830f6c7589fac9d346591a68bdfef Mon Sep 17 00:00:00 2001 From: Jan' s Date: Sun, 10 May 2026 10:24:07 +0800 Subject: [PATCH] feat: add agent resource officer plugins --- AIRecognizerEnhancer/ARCHITECTURE.md | 83 + AIRecognizerEnhancer/README.md | 101 + AIRecognizerEnhancer/__init__.py | 2043 ++ AgentResourceOfficer/ARCHITECTURE.md | 223 + AgentResourceOfficer/README.md | 212 + AgentResourceOfficer/__init__.py | 26967 ++++++++++++++++ AgentResourceOfficer/agenttool.py | 870 + AgentResourceOfficer/feishu_channel.py | 1885 ++ AgentResourceOfficer/requirements.txt | 3 + AgentResourceOfficer/schemas.py | 259 + AgentResourceOfficer/services/__init__.py | 1 + .../services/hdhive_openapi.py | 1113 + .../services/p115_transfer.py | 823 + .../services/quark_transfer.py | 664 + FeishuCommandBridgeLong/README.md | 109 + FeishuCommandBridgeLong/__init__.py | 4111 +++ FeishuCommandBridgeLong/requirements.txt | 1 + HdhiveOpenApi/README.md | 217 + QuarkShareSaver/README.md | 45 + QuarkShareSaver/__init__.py | 1113 + .../AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md | 192 + docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md | 181 + docs/ALL_COMMANDS.md | 288 + docs/GITHUB_PUBLISH.md | 63 + docs/INDEX.md | 55 + docs/MAINTENANCE_COMMANDS.md | 193 + docs/PACKAGING.md | 227 + docs/PLUGIN_INSTALL.md | 188 + docs/REBUILD_AGENT_SUITE.md | 127 + docs/RELEASE_CHECKLIST.md | 209 + docs/RELEASE_v2.0.0-alpha.1.md | 92 + icons/agentresourceofficer.png | Bin 0 -> 240942 bytes icons/agentresourceofficer.svg | 31 + icons/airecoginzerforwarder.png | Bin 0 -> 4165 bytes icons/airecoginzerforwarder.svg | 27 + icons/airecognizerenhancer.svg | 31 + icons/feishucommandbridgelong.png | Bin 0 -> 82736 bytes icons/hdhive.ico | Bin 0 -> 15406 bytes icons/quark.ico | Bin 0 -> 67646 bytes package.json | 269 + package.v2.json | 272 +- .../agentresourceofficer/ARCHITECTURE.md | 223 + plugins.v2/agentresourceofficer/README.md | 212 + plugins.v2/agentresourceofficer/__init__.py | 26967 ++++++++++++++++ plugins.v2/agentresourceofficer/agenttool.py | 870 + .../agentresourceofficer/feishu_channel.py | 1885 ++ .../agentresourceofficer/requirements.txt | 3 + plugins.v2/agentresourceofficer/schemas.py | 259 + .../agentresourceofficer/services/__init__.py | 1 + .../services/hdhive_openapi.py | 1113 + .../services/p115_transfer.py | 823 + .../services/quark_transfer.py | 664 + .../airecognizerenhancer/ARCHITECTURE.md | 83 + plugins.v2/airecognizerenhancer/README.md | 2 + plugins.v2/airecognizerenhancer/__init__.py | 4 + .../AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md | 188 + .../AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md | 177 + plugins.v2/docs/MAINTENANCE_COMMANDS.md | 193 + plugins.v2/docs/PLUGIN_INSTALL.md | 172 + plugins.v2/feishucommandbridgelong/README.md | 109 + .../feishucommandbridgelong/__init__.py | 4111 +++ .../feishucommandbridgelong/requirements.txt | 1 + plugins.v2/hdhiveopenapi/__init__.py | 2012 ++ plugins.v2/quarksharesaver/README.md | 45 + plugins.v2/quarksharesaver/__init__.py | 1113 + .../skills/agent-resource-officer/README.md | 630 + .../skills/agent-resource-officer/SKILL.md | 736 + plugins/agentresourceofficer/ARCHITECTURE.md | 223 + plugins/agentresourceofficer/README.md | 212 + plugins/agentresourceofficer/__init__.py | 26967 ++++++++++++++++ plugins/agentresourceofficer/agenttool.py | 870 + .../agentresourceofficer/feishu_channel.py | 1885 ++ plugins/agentresourceofficer/requirements.txt | 3 + plugins/agentresourceofficer/schemas.py | 259 + .../agentresourceofficer/services/__init__.py | 1 + .../services/hdhive_openapi.py | 1113 + .../services/p115_transfer.py | 823 + .../services/quark_transfer.py | 664 + plugins/airecognizerenhancer/ARCHITECTURE.md | 83 + plugins/airecognizerenhancer/README.md | 2 + plugins/airecognizerenhancer/__init__.py | 4 + .../AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md | 188 + .../AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md | 177 + plugins/docs/MAINTENANCE_COMMANDS.md | 193 + plugins/docs/PLUGIN_INSTALL.md | 172 + plugins/feishucommandbridgelong/README.md | 109 + plugins/feishucommandbridgelong/__init__.py | 4111 +++ .../feishucommandbridgelong/requirements.txt | 1 + plugins/hdhiveopenapi/__init__.py | 2012 ++ plugins/quarksharesaver/README.md | 45 + plugins/quarksharesaver/__init__.py | 1113 + .../skills/agent-resource-officer/README.md | 630 + .../skills/agent-resource-officer/SKILL.md | 736 + scripts/archive-local-branches.py | 87 + scripts/audit-remote-branches.py | 206 + .../check-agent-resource-officer-feishu.py | 111 + scripts/check-doc-current-state.py | 151 + scripts/check-maintenance-commands.py | 88 + scripts/check-skills.sh | 133 + scripts/clean-generated.sh | 52 + scripts/create-draft-release.sh | 100 + scripts/generate-release-notes.sh | 105 + scripts/package-plugin.sh | 159 + scripts/package-skills.sh | 197 + scripts/patch-p115strmhelper-mp-compat.sh | 105 + scripts/pre-release-check.sh | 437 + scripts/print-release-summary.sh | 52 + scripts/print-skill-release-summary.sh | 43 + scripts/release-preflight.sh | 27 + scripts/repo-hygiene.sh | 31 + scripts/smoke-agent-resource-officer.py | 1776 + scripts/sync-package-v2.sh | 40 + scripts/sync-repo-layout.sh | 71 + scripts/update-draft-release-assets.sh | 111 + scripts/verify-ci-artifact.sh | 13 + scripts/verify-dist.sh | 142 + scripts/verify-release-assets.sh | 93 + scripts/verify-release-download.sh | 41 + scripts/verify-release-preflight-artifact.sh | 87 + scripts/verify-skill-dist.sh | 164 + scripts/write-dist-sha256.sh | 57 + skills/agent-resource-officer/CHANGELOG.md | 231 + .../agent-resource-officer/EXTERNAL_AGENTS.md | 192 + skills/agent-resource-officer/PROMPTS.md | 352 + skills/agent-resource-officer/README.md | 648 + skills/agent-resource-officer/SKILL.md | 755 + skills/agent-resource-officer/install.sh | 97 + .../scripts/aro_request.py | 2546 ++ .../hdhive-search-unlock-to-115/CHANGELOG.md | 12 + skills/hdhive-search-unlock-to-115/PROMPTS.md | 34 + skills/hdhive-search-unlock-to-115/README.md | 40 + skills/hdhive-search-unlock-to-115/SKILL.md | 161 + skills/hdhive-search-unlock-to-115/install.sh | 73 + .../scripts/hdhive_agent_tool.py | 230 + .../scripts/search_hdhive.py | 439 + .../scripts/unlock_hdhive.py | 262 + tools/README.md | 25 + tools/hdhive-cookie-export/README.md | 115 + .../hdhive-cookie-export/export_yc_cookie.py | 397 + tools/hdhive-cookie-export/requirements.txt | 1 + .../影巢Cookie导出.command | 69 + tools/quark-cookie-export/README.md | 69 + .../export_quark_cookie.py | 269 + tools/quark-cookie-export/requirements.txt | 1 + .../夸克Cookie导出.command | 23 + 145 files changed, 140196 insertions(+), 4 deletions(-) create mode 100644 AIRecognizerEnhancer/ARCHITECTURE.md create mode 100644 AIRecognizerEnhancer/README.md create mode 100644 AIRecognizerEnhancer/__init__.py create mode 100644 AgentResourceOfficer/ARCHITECTURE.md create mode 100644 AgentResourceOfficer/README.md create mode 100644 AgentResourceOfficer/__init__.py create mode 100644 AgentResourceOfficer/agenttool.py create mode 100644 AgentResourceOfficer/feishu_channel.py create mode 100644 AgentResourceOfficer/requirements.txt create mode 100644 AgentResourceOfficer/schemas.py create mode 100644 AgentResourceOfficer/services/__init__.py create mode 100644 AgentResourceOfficer/services/hdhive_openapi.py create mode 100644 AgentResourceOfficer/services/p115_transfer.py create mode 100644 AgentResourceOfficer/services/quark_transfer.py create mode 100644 FeishuCommandBridgeLong/README.md create mode 100644 FeishuCommandBridgeLong/__init__.py create mode 100644 FeishuCommandBridgeLong/requirements.txt create mode 100644 HdhiveOpenApi/README.md create mode 100644 QuarkShareSaver/README.md create mode 100644 QuarkShareSaver/__init__.py create mode 100644 docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md create mode 100644 docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md create mode 100644 docs/ALL_COMMANDS.md create mode 100644 docs/GITHUB_PUBLISH.md create mode 100644 docs/INDEX.md create mode 100644 docs/MAINTENANCE_COMMANDS.md create mode 100644 docs/PACKAGING.md create mode 100644 docs/PLUGIN_INSTALL.md create mode 100644 docs/REBUILD_AGENT_SUITE.md create mode 100644 docs/RELEASE_CHECKLIST.md create mode 100644 docs/RELEASE_v2.0.0-alpha.1.md create mode 100644 icons/agentresourceofficer.png create mode 100644 icons/agentresourceofficer.svg create mode 100644 icons/airecoginzerforwarder.png create mode 100644 icons/airecoginzerforwarder.svg create mode 100644 icons/airecognizerenhancer.svg create mode 100644 icons/feishucommandbridgelong.png create mode 100644 icons/hdhive.ico create mode 100644 icons/quark.ico create mode 100644 plugins.v2/agentresourceofficer/ARCHITECTURE.md create mode 100644 plugins.v2/agentresourceofficer/README.md create mode 100644 plugins.v2/agentresourceofficer/__init__.py create mode 100644 plugins.v2/agentresourceofficer/agenttool.py create mode 100644 plugins.v2/agentresourceofficer/feishu_channel.py create mode 100644 plugins.v2/agentresourceofficer/requirements.txt create mode 100644 plugins.v2/agentresourceofficer/schemas.py create mode 100644 plugins.v2/agentresourceofficer/services/__init__.py create mode 100644 plugins.v2/agentresourceofficer/services/hdhive_openapi.py create mode 100644 plugins.v2/agentresourceofficer/services/p115_transfer.py create mode 100644 plugins.v2/agentresourceofficer/services/quark_transfer.py create mode 100644 plugins.v2/airecognizerenhancer/ARCHITECTURE.md create mode 100644 plugins.v2/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md create mode 100644 plugins.v2/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md create mode 100644 plugins.v2/docs/MAINTENANCE_COMMANDS.md create mode 100644 plugins.v2/docs/PLUGIN_INSTALL.md create mode 100644 plugins.v2/feishucommandbridgelong/README.md create mode 100644 plugins.v2/feishucommandbridgelong/__init__.py create mode 100644 plugins.v2/feishucommandbridgelong/requirements.txt create mode 100644 plugins.v2/hdhiveopenapi/__init__.py create mode 100644 plugins.v2/quarksharesaver/README.md create mode 100644 plugins.v2/quarksharesaver/__init__.py create mode 100644 plugins.v2/skills/agent-resource-officer/README.md create mode 100644 plugins.v2/skills/agent-resource-officer/SKILL.md create mode 100644 plugins/agentresourceofficer/ARCHITECTURE.md create mode 100644 plugins/agentresourceofficer/README.md create mode 100644 plugins/agentresourceofficer/__init__.py create mode 100644 plugins/agentresourceofficer/agenttool.py create mode 100644 plugins/agentresourceofficer/feishu_channel.py create mode 100644 plugins/agentresourceofficer/requirements.txt create mode 100644 plugins/agentresourceofficer/schemas.py create mode 100644 plugins/agentresourceofficer/services/__init__.py create mode 100644 plugins/agentresourceofficer/services/hdhive_openapi.py create mode 100644 plugins/agentresourceofficer/services/p115_transfer.py create mode 100644 plugins/agentresourceofficer/services/quark_transfer.py create mode 100644 plugins/airecognizerenhancer/ARCHITECTURE.md create mode 100644 plugins/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md create mode 100644 plugins/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md create mode 100644 plugins/docs/MAINTENANCE_COMMANDS.md create mode 100644 plugins/docs/PLUGIN_INSTALL.md create mode 100644 plugins/feishucommandbridgelong/README.md create mode 100644 plugins/feishucommandbridgelong/__init__.py create mode 100644 plugins/feishucommandbridgelong/requirements.txt create mode 100644 plugins/hdhiveopenapi/__init__.py create mode 100644 plugins/quarksharesaver/README.md create mode 100644 plugins/quarksharesaver/__init__.py create mode 100644 plugins/skills/agent-resource-officer/README.md create mode 100644 plugins/skills/agent-resource-officer/SKILL.md create mode 100644 scripts/archive-local-branches.py create mode 100644 scripts/audit-remote-branches.py create mode 100755 scripts/check-agent-resource-officer-feishu.py create mode 100644 scripts/check-doc-current-state.py create mode 100644 scripts/check-maintenance-commands.py create mode 100755 scripts/check-skills.sh create mode 100755 scripts/clean-generated.sh create mode 100755 scripts/create-draft-release.sh create mode 100755 scripts/generate-release-notes.sh create mode 100755 scripts/package-plugin.sh create mode 100755 scripts/package-skills.sh create mode 100755 scripts/patch-p115strmhelper-mp-compat.sh create mode 100755 scripts/pre-release-check.sh create mode 100755 scripts/print-release-summary.sh create mode 100755 scripts/print-skill-release-summary.sh create mode 100644 scripts/release-preflight.sh create mode 100644 scripts/repo-hygiene.sh create mode 100644 scripts/smoke-agent-resource-officer.py create mode 100755 scripts/sync-package-v2.sh create mode 100755 scripts/sync-repo-layout.sh create mode 100755 scripts/update-draft-release-assets.sh create mode 100755 scripts/verify-ci-artifact.sh create mode 100755 scripts/verify-dist.sh create mode 100755 scripts/verify-release-assets.sh create mode 100755 scripts/verify-release-download.sh create mode 100755 scripts/verify-release-preflight-artifact.sh create mode 100755 scripts/verify-skill-dist.sh create mode 100755 scripts/write-dist-sha256.sh create mode 100644 skills/agent-resource-officer/CHANGELOG.md create mode 100644 skills/agent-resource-officer/EXTERNAL_AGENTS.md create mode 100644 skills/agent-resource-officer/PROMPTS.md create mode 100644 skills/agent-resource-officer/README.md create mode 100644 skills/agent-resource-officer/SKILL.md create mode 100755 skills/agent-resource-officer/install.sh create mode 100755 skills/agent-resource-officer/scripts/aro_request.py create mode 100644 skills/hdhive-search-unlock-to-115/CHANGELOG.md create mode 100644 skills/hdhive-search-unlock-to-115/PROMPTS.md create mode 100644 skills/hdhive-search-unlock-to-115/README.md create mode 100644 skills/hdhive-search-unlock-to-115/SKILL.md create mode 100755 skills/hdhive-search-unlock-to-115/install.sh create mode 100755 skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py create mode 100755 skills/hdhive-search-unlock-to-115/scripts/search_hdhive.py create mode 100755 skills/hdhive-search-unlock-to-115/scripts/unlock_hdhive.py create mode 100644 tools/README.md create mode 100644 tools/hdhive-cookie-export/README.md create mode 100644 tools/hdhive-cookie-export/export_yc_cookie.py create mode 100644 tools/hdhive-cookie-export/requirements.txt create mode 100755 tools/hdhive-cookie-export/影巢Cookie导出.command create mode 100644 tools/quark-cookie-export/README.md create mode 100644 tools/quark-cookie-export/export_quark_cookie.py create mode 100644 tools/quark-cookie-export/requirements.txt create mode 100755 tools/quark-cookie-export/夸克Cookie导出.command diff --git a/AIRecognizerEnhancer/ARCHITECTURE.md b/AIRecognizerEnhancer/ARCHITECTURE.md new file mode 100644 index 0000000..314622b --- /dev/null +++ b/AIRecognizerEnhancer/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# AI识别增强架构草案 + +`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。 + +## 设计目标 + +- 摆脱外部 AI Gateway 的强依赖 +- 直接使用 MoviePilot 已启用的 LLM 配置 +- 输出结构化识别结果,而不是只回传一段自由文本 + +## 模块分层 + +### 1. hooks + +负责接住识别失败事件和后续整理事件。 + +### 2. llm + +负责封装对 MP 当前 LLM 的调用: + +- 标准提示词 +- 结构化返回约束 +- 超时与错误兜底 + +### 3. normalize + +负责把 AI 输出转换成可继续进入 MP 整理链路的数据: + +- 标题 +- 年份 +- 类型 +- 季 +- 集 +- 置信度 + +### 4. actions + +负责根据结果执行后续动作: + +- 二次识别 +- 二次整理 +- 记录失败样本 + +## 首期配置模型 + +- `enabled` +- `notify` +- `debug` +- `confidence_threshold` +- `request_timeout` +- `max_retries` +- `save_failed_samples` + +## 二期规划 + +- 生成自定义识别词建议 +- 失败样本聚合分析 +- 提供给 MP Agent / Skill 直接调起 + +## 首个里程碑 + +第一个可用版本只追求: + +1. 原生识别失败后自动触发本地 LLM 判断 +2. 拿到结构化结果后自动二次整理 +3. 能明确记录“成功 / 放弃 / 失败原因” + +## 当前实现状态 + +- 已接住 `ChainEventType.NameRecognize` +- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出 +- 已提供手动调试接口用于验证标题识别结果 +- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议 +- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers` +- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制 +- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本 +- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除 +- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队 +- 已支持失败样本批量复查:可批量重跑并按结果批量出队 +- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库 +- 已支持低 token 精简摘要输出,适合作为智能体批处理入口 +- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地 +- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升 diff --git a/AIRecognizerEnhancer/README.md b/AIRecognizerEnhancer/README.md new file mode 100644 index 0000000..e10d001 --- /dev/null +++ b/AIRecognizerEnhancer/README.md @@ -0,0 +1,101 @@ +# AI识别增强 + +`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。 + +它的核心思路很简单: + +- 复用 MoviePilot 当前已经启用的 LLM 配置 +- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底 +- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链 + +## 适合什么场景 + +- 文件名比较脏,混有压制组、分辨率、语言、站点标记 +- 同一部剧经常出现英文名、别名、原名、翻译名混用 +- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定 +- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers` + +## 和 MoviePilot 原版智能体的区别 + +MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。 + +这和 `AI识别增强` 有重叠,但定位不同: + +- **MP 原版智能体** + - 更偏“一次性补救” + - 适合偶发失败、想省事的场景 + +- **AI识别增强** + - 更偏“识别失败治理层” + - 除了补救当前这次,还能: + - 保存失败样本 + - 汇总样本洞察 + - 生成 `CustomIdentifiers` 建议 + - 写入识别词 + - 重放 / 复查 / 批量出队 + +一句话区分: + +- 原版智能体:自动接管一次 +- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败 + +## 当前能力 + +- 监听 `ChainEventType.NameRecognize` +- 用当前 LLM 结构化判断标题、年份、类型、季集 +- 回写 `name / year / season / episode` +- 交回 MoviePilot 原生链路继续二次识别 +- 保存低置信度失败样本 +- 提供失败样本工作清单、洞察、重放、删除和清空能力 +- 生成并应用 `CustomIdentifiers` 建议 + +## 主要接口 + +- `GET /api/v1/plugin/AIRecognizerEnhancer/health` + - 查看插件状态、LLM 提供方、模型、阈值和超时配置 +- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize` + - 对单个标题做一次本地结构化识别测试 +- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples` + - 查看最近保存的失败样本 +- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist` + - 返回适合继续处理的失败样本摘要列表 +- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights` + - 汇总失败原因、重复问题和优先处理样本 +- `POST /api/v1/plugin/AIRecognizerEnhancer/replay_failed_sample` + - 用当前识别词和当前识别器重放复查某条失败样本 +- `POST /api/v1/plugin/AIRecognizerEnhancer/suggest_identifiers_from_sample` + - 直接基于失败样本生成识别词建议 +- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier` + - 把建议规则写入系统 `CustomIdentifiers` + +其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。 + +## 配置建议 + +- 先确认 MoviePilot 本身已经配置好可用的 LLM +- 建议保持“保存失败样本”开启 +- 如果你经常处理历史资源或网盘资源,建议定期查看: + - `failed_samples` + - `sample_worklist` + - `sample_insights` + +## 已验证情况 + +当前版本:`0.1.12` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +这版已经验证过: + +- 最新版 MoviePilot 下可以正常加载 +- 正常中文标题识别可用 +- 英文别名、韩文原名、中文别名可识别回标准媒体信息 +- 低置信度标题会落失败样本 +- `replay_failed_sample` 复查链可用 + +## 说明 + +- 这个插件不依赖外部 AI Gateway 回调链 +- 重点是增强识别,不负责替代 MoviePilot 全部整理流程 +- 如果你只是偶发整理失败,原版智能体可能已经够用 +- 如果你长期受命名混乱困扰,这个插件更有价值 diff --git a/AIRecognizerEnhancer/__init__.py b/AIRecognizerEnhancer/__init__.py new file mode 100644 index 0000000..eee252e --- /dev/null +++ b/AIRecognizerEnhancer/__init__.py @@ -0,0 +1,2043 @@ +import hmac +import asyncio +import inspect +import json +import re +import threading +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import Request +from langchain_core.prompts import ChatPromptTemplate +from pydantic import BaseModel, Field + +from app.chain.media import MediaChain +from app.core.config import settings +from app.core.event import eventmanager +from app.core.meta.words import WordsMatcher +from app.core.metainfo import MetaInfo +from app.db.systemconfig_oper import SystemConfigOper +try: + from app.helper.llm import LLMHelper +except ImportError: # MoviePilot 新版已迁移到 app.agent.llm + from app.agent.llm import LLMHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import ChainEventType, MediaType, SystemConfigKey + + +class AIRecognitionGuess(BaseModel): + name: str = Field(default="", description="标准化后的影视标题;无法判断时返回空字符串") + year: str = Field(default="", description="四位年份;无法判断时返回空字符串") + media_type: str = Field(default="unknown", description="movie、tv 或 unknown") + season: int = Field(default=0, description="剧集季号,电影填 0") + episode: int = Field(default=0, description="剧集集号,电影或未知填 0") + confidence: float = Field(default=0.0, description="0 到 1 之间的置信度") + reason: str = Field(default="", description="简短说明为什么这样判断") + + +class IdentifierSuggestion(BaseModel): + comment: str = Field(default="", description="可选注释,不带 #") + rule: str = Field(default="", description="一条 MoviePilot 自定义识别词规则") + confidence: float = Field(default=0.0, description="0 到 1 之间的置信度") + reason: str = Field(default="", description="为什么建议这条规则") + + +class IdentifierSuggestionBundle(BaseModel): + summary: str = Field(default="", description="整体建议摘要") + suggestions: List[IdentifierSuggestion] = Field(default_factory=list, description="建议规则列表") + + +class AIRecognizerEnhancer(_PluginBase): + plugin_name = "AI识别增强" + plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png" + plugin_version = "0.1.12" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "arrecognizerenhancer_" + plugin_order = 41 + auth_level = 1 + + _enabled = False + _debug = False + _confidence_threshold = 0.65 + _request_timeout = 25 + _max_retries = 2 + _save_failed_samples = True + _max_failed_samples = 200 + _auto_remove_applied_sample = True + _systemconfig: Optional[SystemConfigOper] = None + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._debug = bool(config.get("debug", False)) + self._confidence_threshold = self._safe_float(config.get("confidence_threshold"), 0.65) + self._request_timeout = self._safe_int(config.get("request_timeout"), 25) + self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2))) + self._save_failed_samples = bool(config.get("save_failed_samples", True)) + self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200))) + self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True)) + self._systemconfig = SystemConfigOper() + self._register_events() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + try: + eventmanager.disable_event_handler(self.on_chain_name_recognize) + except Exception: + pass + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _safe_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return default + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + for key in ("apikey", "api_key"): + token = str(body.get(key) or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = str(getattr(settings, "API_TOKEN", "") or "").strip() + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + def _register_events(self) -> None: + try: + eventmanager.register(ChainEventType.NameRecognize)(self.on_chain_name_recognize) + if self._enabled: + eventmanager.enable_event_handler(self.on_chain_name_recognize) + else: + eventmanager.disable_event_handler(self.on_chain_name_recognize) + except Exception as exc: + logger.warning(f"[AI识别增强] 注册链式识别事件失败: {exc}") + + @staticmethod + def _extract_title_path(event_data: Any) -> Tuple[str, str]: + title = "" + path = "" + if isinstance(event_data, dict): + title = ( + event_data.get("title") + or event_data.get("name") + or event_data.get("org_string") + or "" + ) + path = ( + event_data.get("path") + or event_data.get("file_path") + or event_data.get("org_string") + or "" + ) + else: + title = ( + getattr(event_data, "title", "") + or getattr(event_data, "name", "") + or getattr(event_data, "org_string", "") + or "" + ) + path = ( + getattr(event_data, "path", "") + or getattr(event_data, "file_path", "") + or getattr(event_data, "org_string", "") + or "" + ) + return str(title or "").strip(), str(path or "").strip() + + def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]: + try: + meta = MetaInfo(raw_text) + except Exception: + return {} + return { + "name": getattr(meta, "name", "") or "", + "year": getattr(meta, "year", "") or "", + "type": getattr(getattr(meta, "type", None), "to_agent", lambda: None)() or "", + "season": getattr(meta, "begin_season", None) or 0, + "episode": getattr(meta, "begin_episode", None) or 0, + "org_string": getattr(meta, "org_string", "") or "", + } + + @staticmethod + def _clean_guess_name(name: str) -> str: + text = str(name or "").strip() + if not text: + return "" + text = text.split("/")[0].strip().replace(".", " ") + return " ".join(text.split()) + + def _normalize_guess(self, guess: AIRecognitionGuess) -> AIRecognitionGuess: + name = self._clean_guess_name(guess.name) + year = str(guess.year or "").strip() + if not (len(year) == 4 and year.isdigit()): + year = "" + media_type = str(guess.media_type or "unknown").strip().lower() + if media_type not in {"movie", "tv"}: + media_type = "unknown" + season = max(0, self._safe_int(guess.season, 0)) + episode = max(0, self._safe_int(guess.episode, 0)) + confidence = min(1.0, max(0.0, self._safe_float(guess.confidence, 0.0))) + reason = str(guess.reason or "").strip() + return AIRecognitionGuess( + name=name, + year=year, + media_type=media_type, + season=season, + episode=episode, + confidence=confidence, + reason=reason, + ) + + def _sample_path(self) -> Path: + return self.get_data_path() / "failed_samples.jsonl" + + @staticmethod + def _sample_identity(payload: Dict[str, Any]) -> str: + return json.dumps( + { + "title": str(payload.get("title") or "").strip(), + "path": str(payload.get("path") or "").strip(), + "reason": str(payload.get("reason") or "").strip(), + }, + ensure_ascii=False, + sort_keys=True, + ) + + def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None: + sample_path = self._sample_path() + sample_path.parent.mkdir(parents=True, exist_ok=True) + trimmed = rows[-self._max_failed_samples:] + with sample_path.open("w", encoding="utf-8") as f: + for row in trimmed: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + def _record_failed_sample(self, payload: Dict[str, Any]) -> None: + if not self._save_failed_samples: + return + try: + rows = self._read_failed_samples(limit=1000) + rows.reverse() + identity = self._sample_identity(payload) + filtered = [row for row in rows if self._sample_identity(row) != identity] + filtered.append(payload) + self._write_failed_samples(filtered) + except Exception as exc: + logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}") + + def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]: + sample_path = self._sample_path() + if not sample_path.exists(): + return [] + rows: List[Dict[str, Any]] = [] + try: + with sample_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except Exception: + continue + except Exception as exc: + logger.warning(f"[AI识别增强] 读取失败样本失败: {exc}") + return [] + if limit > 0: + rows = rows[-limit:] + rows.reverse() + return rows + + def _clear_failed_samples(self) -> int: + rows = self._read_failed_samples(limit=1000) + sample_path = self._sample_path() + if sample_path.exists(): + sample_path.unlink() + return len(rows) + + def _remove_failed_sample(self, sample_index: Optional[Any], limit: int = 1000) -> Dict[str, Any]: + rows = self._read_failed_samples(limit=max(1, min(limit, 1000))) + if not rows: + return {"removed": False, "message": "暂无失败样本", "removed_count": 0} + index = self._safe_int(sample_index, 0) + if index < 0: + index = 0 + if index >= len(rows): + return { + "removed": False, + "message": f"失败样本索引超出范围,当前共有 {len(rows)} 条", + "removed_count": 0, + } + removed_sample = dict(rows[index] or {}) + del rows[index] + if rows: + rows.reverse() + self._write_failed_samples(rows) + else: + self._clear_failed_samples() + return { + "removed": True, + "message": "success", + "removed_count": 1, + "remaining_count": len(rows), + "removed_sample": removed_sample, + "removed_sample_index": index, + } + + def _remove_failed_samples(self, sample_indexes: List[Any], limit: int = 1000) -> Dict[str, Any]: + rows = self._read_failed_samples(limit=max(1, min(limit, 1000))) + if not rows: + return {"removed": False, "message": "暂无失败样本", "removed_count": 0, "remaining_count": 0} + normalized_indexes = sorted( + {self._safe_int(index, -1) for index in (sample_indexes or []) if self._safe_int(index, -1) >= 0}, + reverse=True, + ) + valid_indexes = [index for index in normalized_indexes if index < len(rows)] + if not valid_indexes: + return { + "removed": False, + "message": "没有可移除的有效样本索引", + "removed_count": 0, + "remaining_count": len(rows), + } + removed_samples: List[Dict[str, Any]] = [] + for index in valid_indexes: + removed_samples.append(dict(rows[index] or {})) + del rows[index] + if rows: + rows.reverse() + self._write_failed_samples(rows) + else: + self._clear_failed_samples() + removed_samples.reverse() + return { + "removed": True, + "message": "success", + "removed_count": len(valid_indexes), + "remaining_count": len(rows), + "removed_sample_indexes": sorted(valid_indexes), + "removed_samples": removed_samples, + } + + def _resolve_failed_sample( + self, + sample_index: Optional[Any] = None, + limit: int = 100, + ) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]: + samples = self._read_failed_samples(limit=max(1, min(limit, 200))) + if not samples: + return None, None, "暂无失败样本" + index = self._safe_int(sample_index, 0) + if index < 0: + index = 0 + if index >= len(samples): + return None, None, f"失败样本索引超出范围,当前共有 {len(samples)} 条" + row = dict(samples[index] or {}) + row["sample_index"] = index + return index, row, "" + + def _select_failed_sample_indexes( + self, + sample_indexes: Optional[List[Any]] = None, + limit: int = 10, + pool_limit: int = 200, + ) -> Tuple[List[int], List[Dict[str, Any]], str]: + current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000)))) + if not current_samples: + return [], [], "暂无失败样本" + if isinstance(sample_indexes, list) and sample_indexes: + selected_indexes: List[int] = [] + seen = set() + for raw in sample_indexes: + idx = self._safe_int(raw, -1) + if idx < 0 or idx >= len(current_samples) or idx in seen: + continue + seen.add(idx) + selected_indexes.append(idx) + else: + selected_indexes = [int(sample.get("sample_index", 0)) for sample in current_samples[: max(1, min(limit, 50))]] + if not selected_indexes: + return [], current_samples, "没有可处理的有效样本索引" + return selected_indexes, current_samples, "" + + def _inject_sample_indices(self, samples: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + indexed: List[Dict[str, Any]] = [] + for idx, sample in enumerate(samples): + row = dict(sample or {}) + row["sample_index"] = idx + indexed.append(row) + return indexed + + def _summarize_sample(self, sample: Dict[str, Any]) -> Dict[str, Any]: + sample = dict(sample or {}) + guess = sample.get("guess") or {} + verified = sample.get("verified_media_info") or {} + inferred_target = { + "name": verified.get("title") or guess.get("name") or "", + "year": verified.get("year") or guess.get("year") or "", + "media_type": self._normalize_media_type(verified.get("type") or guess.get("media_type")), + "season": self._safe_int(guess.get("season"), 0), + "episode": self._safe_int(guess.get("episode"), 0), + "tmdb_id": self._safe_int(verified.get("tmdb_id"), 0), + } + return { + "sample_index": sample.get("sample_index"), + "title": sample.get("title"), + "path": sample.get("path"), + "reason": sample.get("reason"), + "guess_name": guess.get("name"), + "guess_confidence": self._safe_float(guess.get("confidence"), 0.0), + "verified_title": verified.get("title"), + "verified_year": verified.get("year"), + "verified_tmdb_id": verified.get("tmdb_id"), + "inferred_target": inferred_target, + "can_auto_suggest": bool(inferred_target["name"]), + } + + def _target_from_sample(self, sample: Dict[str, Any]) -> Dict[str, Any]: + summary = self._summarize_sample(sample) + return summary.get("inferred_target") or {} + + @staticmethod + def _normalize_reason_tag(reason: Any) -> str: + text = str(reason or "").strip() + if not text: + return "unknown" + if ":" in text: + return text.split(":", 1)[0].strip() or "unknown" + return text + + @staticmethod + def _sample_group_key(summary: Dict[str, Any]) -> str: + target = summary.get("inferred_target") or {} + title = ( + str(target.get("name") or "").strip() + or str(summary.get("verified_title") or "").strip() + or str(summary.get("guess_name") or "").strip() + or str(summary.get("title") or "").strip() + ) + media_type = str(target.get("media_type") or "unknown").strip().lower() + season = int(target.get("season") or 0) + episode = int(target.get("episode") or 0) + return json.dumps( + { + "title": title.lower(), + "media_type": media_type, + "season": season, + "episode": episode, + }, + ensure_ascii=False, + sort_keys=True, + ) + + @staticmethod + def _sample_display_name(summary: Dict[str, Any]) -> str: + target = summary.get("inferred_target") or {} + title = ( + str(target.get("name") or "").strip() + or str(summary.get("verified_title") or "").strip() + or str(summary.get("guess_name") or "").strip() + or str(summary.get("title") or "").strip() + ) + if not title: + return "未命名样本" + media_type = str(target.get("media_type") or "").strip().lower() + season = int(target.get("season") or 0) + episode = int(target.get("episode") or 0) + suffix = "" + if media_type == "tv" and (season or episode): + suffix = f" S{season:02d}E{episode:02d}" + return f"{title}{suffix}" + + def _build_sample_insights(self, samples: List[Dict[str, Any]], top: int = 10) -> Dict[str, Any]: + summaries = [self._summarize_sample(sample) for sample in samples] + reason_counter = Counter() + title_counter = Counter() + group_counter = Counter() + for summary in summaries: + reason_counter[self._normalize_reason_tag(summary.get("reason"))] += 1 + title_counter[self._sample_display_name(summary)] += 1 + group_counter[self._sample_group_key(summary)] += 1 + + actionable: List[Dict[str, Any]] = [] + for summary in summaries: + duplicate_count = group_counter[self._sample_group_key(summary)] + priority_reasons: List[str] = [] + score = 0 + if duplicate_count >= 2: + score += min(duplicate_count, 5) + priority_reasons.append(f"同类样本重复出现 {duplicate_count} 次") + if summary.get("verified_tmdb_id"): + score += 3 + priority_reasons.append("已有 TMDB 命中") + if summary.get("can_auto_suggest"): + score += 2 + priority_reasons.append("可直接生成识别词") + confidence = self._safe_float(summary.get("guess_confidence"), 0.0) + if 0 < confidence < self._confidence_threshold: + gap = round(self._confidence_threshold - confidence, 2) + score += 1 + priority_reasons.append(f"距注入阈值还差 {gap}") + row = dict(summary) + row["duplicate_count"] = duplicate_count + row["priority_score"] = score + row["priority_reasons"] = priority_reasons + actionable.append(row) + + actionable.sort( + key=lambda item: ( + -int(item.get("priority_score") or 0), + -int(item.get("duplicate_count") or 0), + -self._safe_float(item.get("guess_confidence"), 0.0), + int(item.get("sample_index") or 0), + ) + ) + + repeated_groups = [ + {"title": name, "count": count} + for name, count in title_counter.most_common(top) + if count >= 2 + ] + + return { + "total_count": len(summaries), + "reason_counts": [ + {"reason": reason, "count": count} + for reason, count in reason_counter.most_common(top) + ], + "top_titles": [ + {"title": title, "count": count} + for title, count in title_counter.most_common(top) + ], + "repeated_groups": repeated_groups, + "priority_samples": actionable[:top], + } + + def _render_sample_brief(self, samples: List[Dict[str, Any]], top: int = 5) -> str: + summaries = [self._summarize_sample(sample) for sample in samples[: max(1, min(top, 20))]] + if not summaries: + return "当前没有失败样本。" + lines = [f"失败样本 {len(samples)} 条,展示前 {len(summaries)} 条:"] + for summary in summaries: + label = self._sample_display_name(summary) + confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2) + can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工" + lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}") + lines.append("下一步:可直接调用批量建议或批量复查接口。") + return "\n".join(lines) + + @staticmethod + def _render_batch_results_brief( + action_name: str, + requested_count: int, + success_count: int, + failed_count: int, + results: List[Dict[str, Any]], + ) -> str: + lines = [f"{action_name}:共处理 {requested_count} 条,成功 {success_count},失败 {failed_count}。"] + for item in results[:10]: + idx = item.get("sample_index") + if item.get("success"): + label = ( + ((item.get("source_sample") or {}).get("title")) + or ((item.get("target") or {}).get("name")) + or "样本" + ) + lines.append(f"{idx}. 成功 | {label}") + else: + lines.append(f"{idx}. 失败 | {item.get('message', '未知错误')}") + return "\n".join(lines) + + def _build_body_from_sample(self, body: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]], str]: + body = dict(body or {}) + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + sample_requested = body.get("use_latest_sample") or body.get("sample_index") is not None + if title or path: + return body, None, "" + if not sample_requested: + return body, None, "" + + sample_index, sample, message = self._resolve_failed_sample(body.get("sample_index"), limit=100) + if not sample: + return body, None, message + body["title"] = str(sample.get("title") or "").strip() + body["path"] = str(sample.get("path") or "").strip() + verified = sample.get("verified_media_info") or {} + guess = sample.get("guess") or {} + if not body.get("desired_name"): + body["desired_name"] = verified.get("title") or guess.get("name") or "" + if not body.get("desired_year"): + body["desired_year"] = verified.get("year") or guess.get("year") or "" + if not body.get("desired_media_type"): + body["desired_media_type"] = self._normalize_media_type( + verified.get("type") or guess.get("media_type") + ) + if body.get("desired_season") is None: + body["desired_season"] = guess.get("season") or 0 + if body.get("desired_episode") is None: + body["desired_episode"] = guess.get("episode") or 0 + if body.get("desired_tmdb_id") is None: + body["desired_tmdb_id"] = verified.get("tmdb_id") or 0 + body["sample_index"] = sample_index + return body, sample, "" + + def _build_prompt(self) -> ChatPromptTemplate: + return ChatPromptTemplate.from_messages( + [ + ( + "system", + """你是 MoviePilot 的影视文件名识别增强助手。 + +你的任务不是搜索 TMDB,也不是编造结果,而是根据文件名、路径和已有解析提示,尽量提炼出更适合 MoviePilot 二次识别的结构化信息。 + +规则: +1. 只依据输入内容推断,不要臆造不存在的信息。 +2. 如果不确定,请返回空标题,并把 media_type 设为 unknown,confidence 降低。 +3. title/name 只保留作品名,不要包含分辨率、制作组、音频编码、网盘标记等噪音。 +4. year 只有在比较确定时才给四位年份。 +5. 电影 season/episode 必须为 0。 +6. 剧集如果能确定季集就填写,否则保持 0。 +7. media_type 只能是 movie、tv、unknown。 +8. confidence 范围为 0 到 1。 +""", + ), + ( + "human", + """原始标题: +{title} + +原始路径: +{path} + +MoviePilot 当前基础解析提示: +{meta_hint} +""", + ), + ] + ) + + def _build_identifier_prompt(self) -> ChatPromptTemplate: + return ChatPromptTemplate.from_messages( + [ + ( + "system", + """你是 MoviePilot 自定义识别词规则助手。 + +你的任务是根据错误标题、当前解析结果和目标结果,生成尽量窄作用域、可直接用于 MoviePilot CustomIdentifiers 的规则。 + +支持格式只有四种: +1. 屏蔽词 +2. 替换词:被替换词 => 替换词 +3. 集偏移:前定位词 <> 后定位词 >> EP±N +4. 组合规则:被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N + +硬性要求: +1. 运算符两侧必须保留空格: => 、 <> 、 >> 、 && +2. 优先生成窄作用域规则,尽量带发布组、年份、季集、分辨率等锚点 +3. 不要生成过宽的裸屏蔽词,比如 1080p、WEB-DL、字幕 +4. 如果需要强制绑 TMDB,可使用 {{[tmdbid=xxx;type=tv/movies;s=1;e=14]}} 这种替换词 +5. comment 不带 #,rule 里不要再包 markdown 或代码块 +6. 如果没有把握,请返回空 suggestions +""", + ), + ( + "human", + """原始标题: +{title} + +原始路径: +{path} + +MoviePilot 当前基础解析: +{meta_hint} + +AI 识别增强结果: +{guess} + +二次校验到的媒体信息摘要: +{verified_summary} + +希望修正成的目标结果: +{target} +""", + ), + ] + ) + + @staticmethod + def _run_async_compatible(value: Any) -> Any: + """ + 兼容 MoviePilot 新版 `LLMHelper.get_llm()` 的异步返回。 + 在同步上下文直接 asyncio.run;如果当前线程已有事件循环,则开一个短线程执行。 + """ + if not inspect.isawaitable(value): + return value + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(value) + + result: Dict[str, Any] = {} + error: Dict[str, BaseException] = {} + + def _worker() -> None: + try: + result["value"] = asyncio.run(value) + except BaseException as exc: # noqa: BLE001 + error["exc"] = exc + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + thread.join() + if "exc" in error: + raise error["exc"] + return result.get("value") + + def _get_llm(self): + llm = LLMHelper.get_llm(streaming=False) + return self._run_async_compatible(llm) + + def _invoke_llm(self, title: str, path: str) -> AIRecognitionGuess: + raw_text = path or title + meta_hint = self._build_meta_hint(raw_text) + llm = self._get_llm() + prompt = self._build_prompt() + chain = ( + prompt + | llm.with_structured_output(AIRecognitionGuess).with_retry(stop_after_attempt=self._max_retries) + ) + result: AIRecognitionGuess = chain.invoke( + { + "title": title, + "path": path, + "meta_hint": meta_hint, + }, + config={"configurable": {"timeout": self._request_timeout}}, + ) + return self._normalize_guess(result) + + @staticmethod + def _normalize_media_type(value: Any) -> str: + if value == MediaType.MOVIE: + return "movie" + if value == MediaType.TV: + return "tv" + text = str(value or "").strip().lower() + if text in {"movie", "movies", "电影"}: + return "movie" + if text in {"tv", "电视剧", "剧集"}: + return "tv" + return "unknown" + + def _build_target(self, body: Dict[str, Any], result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + body = body or {} + result = result or {} + guess = result.get("guess") or {} + verified = result.get("verified_media_info") or {} + verified_type = self._normalize_media_type(verified.get("type")) + target = { + "name": str(body.get("desired_name") or verified.get("title") or guess.get("name") or "").strip(), + "year": str(body.get("desired_year") or verified.get("year") or guess.get("year") or "").strip(), + "media_type": self._normalize_media_type( + body.get("desired_media_type") or verified_type or guess.get("media_type") + ), + "season": self._safe_int( + body.get("desired_season"), + self._safe_int(guess.get("season"), 0), + ), + "episode": self._safe_int( + body.get("desired_episode"), + self._safe_int(guess.get("episode"), 0), + ), + "tmdb_id": self._safe_int(body.get("desired_tmdb_id") or verified.get("tmdb_id"), 0), + } + if len(target["year"]) != 4 or not target["year"].isdigit(): + target["year"] = "" + return target + + @staticmethod + def _compact_verified_summary(verified: Optional[Dict[str, Any]]) -> Dict[str, Any]: + verified = verified or {} + return { + "title": verified.get("title"), + "year": verified.get("year"), + "type": verified.get("type"), + "tmdb_id": verified.get("tmdb_id"), + "title_year": verified.get("title_year"), + "season_years": verified.get("season_years"), + "seasons": verified.get("seasons"), + "names": (verified.get("names") or [])[:8], + } + + @staticmethod + def _normalize_identifier_line(value: Any) -> str: + return " ".join(str(value or "").strip().split()) + + def _validate_identifier_rule(self, rule: str) -> bool: + rule = self._normalize_identifier_line(rule) + if not rule or rule.startswith("#"): + return False + if " => " in rule and " && " in rule and " >> " in rule and " <> " in rule: + return True + if " => " in rule: + return True + if " >> " in rule and " <> " in rule: + return True + return len(rule) >= 4 + + def _enrich_identifier_rule(self, rule: str, target: Dict[str, Any]) -> str: + rule = self._normalize_identifier_line(rule) + target_name = str((target or {}).get("name") or "").strip() + if not target_name or " => " not in rule: + return rule + left, right = rule.split(" => ", 1) + suffix = "" + replace_part = right + if " && " in right: + replace_part, extra = right.split(" && ", 1) + suffix = f" && {extra}" + if replace_part.startswith("{["): + replace_part = f"{target_name}{replace_part}" + return f"{left} => {replace_part}{suffix}" + + @staticmethod + def _clean_comment_line(comment: str) -> str: + text = str(comment or "").strip() + if not text: + return "" + return f"#{text.lstrip('#').strip()}" + + def _preview_custom_words(self, title: str, custom_words: List[str], target: Dict[str, Any]) -> Dict[str, Any]: + prepared_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words) + meta = MetaInfo(title=title, custom_words=custom_words) + preview = { + "prepared_title": prepared_title, + "applied_words": apply_words or [], + "applied": bool(apply_words), + "name": getattr(meta, "name", "") or "", + "year": getattr(meta, "year", "") or "", + "media_type": self._normalize_media_type(getattr(meta, "type", None)), + "season": getattr(meta, "begin_season", None) or 0, + "episode": getattr(meta, "begin_episode", None) or 0, + } + if target: + matched = True + if target.get("name"): + matched = matched and (preview["name"].strip().lower() == str(target["name"]).strip().lower()) + if target.get("year"): + matched = matched and (preview["year"] == target["year"]) + if target.get("media_type") and target.get("media_type") != "unknown": + matched = matched and (preview["media_type"] == target["media_type"]) + if target.get("season"): + matched = matched and (preview["season"] == target["season"]) + if target.get("episode"): + matched = matched and (preview["episode"] == target["episode"]) + preview["matched_target"] = matched + return preview + + def _preview_identifier_rule(self, title: str, rule: str, target: Dict[str, Any]) -> Dict[str, Any]: + preview = self._preview_custom_words(title=title, custom_words=[rule], target=target) + preview["applied"] = rule in (preview.get("applied_words") or []) + return preview + + def _preview_current_identifiers(self, title: str, target: Dict[str, Any]) -> Dict[str, Any]: + custom_words = self._get_custom_identifiers() + preview = self._preview_custom_words(title=title, custom_words=custom_words, target=target) + preview["custom_identifier_count"] = len(custom_words) + preview["applied_count"] = len(preview.get("applied_words") or []) + return preview + + @staticmethod + def _match_recognize_result_to_target(result: Dict[str, Any], target: Dict[str, Any]) -> bool: + if not target: + return bool(result.get("success")) + guess = result.get("guess") or {} + matched = True + if target.get("name"): + matched = matched and (str(guess.get("name") or "").strip().lower() == str(target.get("name") or "").strip().lower()) + if target.get("year"): + matched = matched and (str(guess.get("year") or "") == str(target.get("year") or "")) + if target.get("media_type") and target.get("media_type") != "unknown": + matched = matched and (str(guess.get("media_type") or "unknown") == str(target.get("media_type") or "unknown")) + if target.get("season"): + matched = matched and (int(guess.get("season") or 0) == int(target.get("season") or 0)) + if target.get("episode"): + matched = matched and (int(guess.get("episode") or 0) == int(target.get("episode") or 0)) + return bool(result.get("success")) and matched + + def _replay_failed_sample(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + sample_index, sample, message = self._resolve_failed_sample( + body.get("sample_index"), + limit=1000, + ) + if not sample: + return {"success": False, "message": message} + title = str(sample.get("title") or "").strip() + path = str(sample.get("path") or "").strip() + target = self._target_from_sample(sample) + identifier_preview = self._preview_current_identifiers(title=title, target=target) + recognize_result = self._recognize(title=title, path=path, record_failed_sample=False) + resolved_by_identifiers = bool(identifier_preview.get("applied")) and bool(identifier_preview.get("matched_target")) + resolved_by_recognizer = self._match_recognize_result_to_target(recognize_result, target) + resolved = resolved_by_identifiers or resolved_by_recognizer + removal_result = None + if resolved and bool(body.get("remove_if_resolved")): + removal_result = self._remove_failed_sample(sample_index, limit=1000) + return { + "success": True, + "message": "success", + "data": { + "source_sample_index": sample_index, + "source_sample": sample, + "target": target, + "identifier_preview": identifier_preview, + "recognize_result": recognize_result, + "resolved_by_identifiers": resolved_by_identifiers, + "resolved_by_recognizer": resolved_by_recognizer, + "resolved": resolved, + "sample_removed": bool(removal_result and removal_result.get("removed")), + "sample_removal_result": removal_result, + }, + } + + def _replay_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 10), 50)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + replay_results: List[Dict[str, Any]] = [] + resolved_indexes: List[int] = [] + for sample_index in selected_indexes: + replay = self._replay_failed_sample( + { + "sample_index": sample_index, + "remove_if_resolved": False, + } + ) + if not replay.get("success"): + replay_results.append( + { + "sample_index": sample_index, + "success": False, + "message": replay.get("message", "复查失败"), + } + ) + continue + data = replay.get("data") or {} + replay_results.append( + { + "sample_index": sample_index, + "success": True, + "resolved": bool(data.get("resolved")), + "resolved_by_identifiers": bool(data.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(data.get("resolved_by_recognizer")), + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "identifier_preview": data.get("identifier_preview"), + "recognize_result": data.get("recognize_result"), + } + ) + if data.get("resolved"): + resolved_indexes.append(sample_index) + + removal_result = None + if body.get("remove_if_resolved") and resolved_indexes: + removal_result = self._remove_failed_samples(resolved_indexes, limit=1000) + + success_count = sum(1 for item in replay_results if item.get("success")) + resolved_count = sum(1 for item in replay_results if item.get("resolved")) + unresolved_count = success_count - resolved_count + failed_count = len(replay_results) - success_count + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "resolved_count": resolved_count, + "unresolved_count": unresolved_count, + "failed_count": failed_count, + "sample_removed_count": int((removal_result or {}).get("removed_count") or 0), + "sample_removal_result": removal_result, + "results": replay_results, + }, + } + + def _suggest_identifiers_for_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 5), 20)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + results: List[Dict[str, Any]] = [] + success_count = 0 + for sample_index in selected_indexes: + suggest_body = dict(body) + suggest_body.pop("sample_indexes", None) + suggest_body["sample_index"] = sample_index + suggest_body["use_latest_sample"] = False + suggested = self._suggest_identifiers(suggest_body) + if suggested.get("success"): + success_count += 1 + data = suggested.get("data") or {} + results.append( + { + "sample_index": sample_index, + "success": True, + "summary": data.get("summary"), + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "suggestions": data.get("suggestions") or [], + } + ) + else: + results.append( + { + "sample_index": sample_index, + "success": False, + "message": suggested.get("message", "建议生成失败"), + "data": suggested.get("data"), + } + ) + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "failed_count": len(selected_indexes) - success_count, + "brief": self._render_batch_results_brief( + action_name="批量建议", + requested_count=len(selected_indexes), + success_count=success_count, + failed_count=len(selected_indexes) - success_count, + results=results, + ), + "results": results, + }, + } + + def _apply_suggested_identifier_internal(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + if body.get("title") is None and body.get("path") is None: + body["use_latest_sample"] = True if body.get("use_latest_sample") is None else body.get("use_latest_sample") + suggested = self._suggest_identifiers(body) + if not suggested.get("success"): + return suggested + data = suggested.get("data") or {} + suggestions = data.get("suggestions") or [] + suggestion_index = self._safe_int(body.get("suggestion_index"), 0) + if suggestion_index < 0: + suggestion_index = 0 + if suggestion_index >= len(suggestions): + return {"success": False, "message": f"建议索引超出范围,当前共有 {len(suggestions)} 条"} + chosen = suggestions[suggestion_index] + applied = self._append_custom_identifiers(chosen.get("lines") or []) + should_remove_sample = bool( + self._auto_remove_applied_sample if body.get("remove_sample") is None else body.get("remove_sample") + ) + removal_result = None + source_sample = data.get("source_sample") or {} + if should_remove_sample and source_sample.get("sample_index") is not None: + removal_result = self._remove_failed_sample(source_sample.get("sample_index"), limit=1000) + return { + "success": True, + "message": "success", + "data": { + "chosen_suggestion": chosen, + "apply_result": applied, + "source_sample_index": source_sample.get("sample_index"), + "source_sample": source_sample, + "sample_removed": bool(removal_result and removal_result.get("removed")), + "sample_removal_result": removal_result, + "target": data.get("target"), + }, + } + + def _apply_suggested_identifiers_for_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 5), 20)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + results: List[Dict[str, Any]] = [] + success_count = 0 + removable_indexes: List[int] = [] + should_remove_samples = bool( + self._auto_remove_applied_sample if body.get("remove_sample") is None else body.get("remove_sample") + ) + for sample_index in selected_indexes: + apply_body = dict(body) + apply_body.pop("sample_indexes", None) + apply_body["sample_index"] = sample_index + apply_body["use_latest_sample"] = False + apply_body["remove_sample"] = False + applied = self._apply_suggested_identifier_internal(apply_body) + if applied.get("success"): + success_count += 1 + data = applied.get("data") or {} + if should_remove_samples: + removable_indexes.append(sample_index) + results.append( + { + "sample_index": sample_index, + "success": True, + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "chosen_suggestion": data.get("chosen_suggestion"), + "apply_result": data.get("apply_result"), + "sample_removed": False, + } + ) + else: + results.append( + { + "sample_index": sample_index, + "success": False, + "message": applied.get("message", "写入失败"), + "data": applied.get("data"), + } + ) + removal_result = None + if should_remove_samples and removable_indexes: + removal_result = self._remove_failed_samples(removable_indexes, limit=1000) + removed_index_set = set((removal_result or {}).get("removed_sample_indexes") or []) + for item in results: + if item.get("success"): + item["sample_removed"] = item.get("sample_index") in removed_index_set + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "failed_count": len(selected_indexes) - success_count, + "sample_removed_count": int((removal_result or {}).get("removed_count") or 0), + "sample_removal_result": removal_result, + "brief": self._render_batch_results_brief( + action_name="批量写入", + requested_count=len(selected_indexes), + success_count=success_count, + failed_count=len(selected_indexes) - success_count, + results=results, + ), + "results": results, + }, + } + + def _build_exact_identifier_fallback(self, title: str, target: Dict[str, Any]) -> Optional[Dict[str, Any]]: + target_name = str((target or {}).get("name") or "").strip() + tmdb_id = self._safe_int((target or {}).get("tmdb_id"), 0) + media_type = self._normalize_media_type((target or {}).get("media_type")) + if not title or not target_name or not tmdb_id or media_type == "unknown": + return None + replace = target_name + target_year = str((target or {}).get("year") or "").strip() + if len(target_year) == 4 and target_year.isdigit(): + replace += f".{target_year}" + replace += f"{{[tmdbid={tmdb_id};type={'tv' if media_type == 'tv' else 'movie'}" + if media_type == "tv" and self._safe_int(target.get("season"), 0): + replace += f";s={self._safe_int(target.get('season'), 0)}" + if media_type == "tv" and self._safe_int(target.get("episode"), 0): + replace += f";e={self._safe_int(target.get('episode'), 0)}" + replace += "]}" + rule = f"{re.escape(title)} => {replace}" + preview = self._preview_identifier_rule(title=title, rule=rule, target=target) + if not preview.get("applied"): + return None + return { + "comment": "当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + "comment_line": "#当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + "rule": rule, + "confidence": 0.95, + "reason": "精确匹配当前标题并强制绑定目标 TMDB / 季集,作用域最窄,稳定性最高。", + "preview": preview, + "lines": [ + "#当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + rule, + ], + } + + def _invoke_identifier_llm( + self, + title: str, + path: str, + result: Dict[str, Any], + target: Dict[str, Any], + ) -> IdentifierSuggestionBundle: + llm = self._get_llm() + prompt = self._build_identifier_prompt() + chain = ( + prompt + | llm.with_structured_output(IdentifierSuggestionBundle).with_retry( + stop_after_attempt=self._max_retries + ) + ) + bundle: IdentifierSuggestionBundle = chain.invoke( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "guess": result.get("guess") or {}, + "verified_summary": self._compact_verified_summary(result.get("verified_media_info")), + "target": target, + }, + config={"configurable": {"timeout": self._request_timeout}}, + ) + return bundle + + def _suggest_identifiers(self, body: Dict[str, Any]) -> Dict[str, Any]: + body, source_sample, sample_message = self._build_body_from_sample(body) + if sample_message: + return {"success": False, "message": sample_message} + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + if not title and path: + title = Path(path).name + if not title: + return {"success": False, "message": "标题为空"} + + result = self._recognize(title=title, path=path, record_failed_sample=False) + target = self._build_target(body, result=result) + invoke_error = "" + try: + bundle = self._invoke_identifier_llm(title=title, path=path, result=result, target=target) + except Exception as exc: + bundle = IdentifierSuggestionBundle( + summary="识别词建议模型暂不可用,已自动回退到精确规则兜底。", + suggestions=[], + ) + invoke_error = str(exc) + + cleaned: List[Dict[str, Any]] = [] + for item in bundle.suggestions: + rule = self._enrich_identifier_rule(item.rule, target=target) + if not self._validate_identifier_rule(rule): + continue + comment_line = self._clean_comment_line(item.comment) + preview = self._preview_identifier_rule(title=title, rule=rule, target=target) + if not preview.get("applied"): + continue + if target and any(target.values()) and preview.get("matched_target") is False: + continue + cleaned.append( + { + "comment": item.comment.strip(), + "comment_line": comment_line, + "rule": rule, + "confidence": min(1.0, max(0.0, self._safe_float(item.confidence, 0.0))), + "reason": str(item.reason or "").strip(), + "preview": preview, + "lines": [line for line in [comment_line, rule] if line], + } + ) + + if not cleaned: + fallback = self._build_exact_identifier_fallback(title=title, target=target) + if fallback: + if invoke_error: + fallback["reason"] = f"{fallback.get('reason', '')} 当前识别词建议模型不可用,已自动切到精确规则兜底。".strip() + cleaned.append(fallback) + + if not cleaned: + return { + "success": False, + "message": f"识别词建议生成失败: {invoke_error}" if invoke_error else "没有生成可直接使用的识别词规则", + "data": { + "summary": bundle.summary, + "target": target, + "recognize_result": result, + }, + } + return { + "success": True, + "message": "success", + "data": { + "summary": bundle.summary, + "source_sample_index": (source_sample or {}).get("sample_index"), + "source_sample": source_sample, + "target": target, + "recognize_result": result, + "suggestions": cleaned, + }, + } + + def _get_custom_identifiers(self) -> List[str]: + if not self._systemconfig: + self._systemconfig = SystemConfigOper() + return self._systemconfig.get(SystemConfigKey.CustomIdentifiers) or [] + + def _append_custom_identifiers(self, lines: List[str]) -> Dict[str, Any]: + existing = self._get_custom_identifiers() + added: List[str] = [] + for line in lines: + normalized = str(line or "").rstrip() + if not normalized: + continue + if normalized in existing or normalized in added: + continue + added.append(normalized) + if added: + merged = existing + added + self._systemconfig.set(SystemConfigKey.CustomIdentifiers, merged) + return { + "added": added, + "added_count": len(added), + "total_count": len(self._get_custom_identifiers()), + } + + def _verify_guess(self, title: str, path: str, guess: AIRecognitionGuess) -> Optional[Dict[str, Any]]: + if not guess.name: + return None + try: + raw_text = path or title or guess.name + meta = MetaInfo(raw_text) + meta.name = guess.name + meta.year = guess.year or None + meta.begin_season = guess.season or None + meta.begin_episode = guess.episode or None + if guess.media_type == "tv" or meta.begin_season or meta.begin_episode: + meta.type = MediaType.TV + elif guess.media_type == "movie": + meta.type = MediaType.MOVIE + mediainfo = MediaChain().recognize_media(meta=meta, cache=False) + if not mediainfo: + return None + return mediainfo.to_dict() + except Exception as exc: + if self._debug: + logger.warning(f"[AI识别增强] 二次校验失败: {exc}") + return None + + def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]: + title = str(title or "").strip() + path = str(path or "").strip() + if not title and path: + title = Path(path).name + if not title: + return {"success": False, "message": "标题为空"} + try: + guess = self._invoke_llm(title, path) + except Exception as exc: + if record_failed_sample: + self._record_failed_sample( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "reason": f"llm_error:{exc}", + } + ) + return {"success": False, "message": f"LLM 调用失败: {exc}"} + + verified = self._verify_guess(title, path, guess) + passed = bool(guess.name and guess.confidence >= self._confidence_threshold) + if not passed and record_failed_sample: + self._record_failed_sample( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "guess": guess.model_dump(), + "verified_media_info": self._compact_verified_summary(verified), + "reason": "low_confidence_or_empty_name", + } + ) + return { + "success": passed, + "message": "success" if passed else "识别结果置信度不足,已放弃注入", + "guess": guess.model_dump(), + "verified_media_info": verified, + } + + def on_chain_name_recognize(self, event) -> None: + if not self._enabled: + return + event_data = getattr(event, "event_data", None) or {} + title, path = self._extract_title_path(event_data) + if not title and not path: + return + result = self._recognize(title=title, path=path) + if not result.get("success"): + if self._debug: + logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}") + return + guess = result.get("guess") or {} + if isinstance(event_data, dict): + if event_data.get("source_plugin"): + if self._debug: + logger.info(f"[AI识别增强] 已有插件处理识别结果,跳过覆盖: {event_data.get('source_plugin')}") + return + event_data["name"] = guess.get("name", "") + event_data["year"] = guess.get("year", "") + event_data["season"] = guess.get("season", 0) + event_data["episode"] = guess.get("episode", 0) + event_data["source_plugin"] = "AIRecognizerEnhancer" + event_data["confidence"] = guess.get("confidence", 0) + event_data["reason"] = guess.get("reason", "") + + async def api_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + llm_ready = bool(getattr(settings, "LLM_API_KEY", None)) + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "llm_ready": llm_ready, + "llm_provider": getattr(settings, "LLM_PROVIDER", ""), + "llm_model": getattr(settings, "LLM_MODEL", ""), + "confidence_threshold": self._confidence_threshold, + "request_timeout": self._request_timeout, + }, + } + + async def api_recognize(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + result = self._recognize(title=title, path=path) + return { + "success": result.get("success", False), + "message": result.get("message", ""), + "data": { + "guess": result.get("guess"), + "verified_media_info": result.get("verified_media_info"), + }, + } + + async def api_failed_samples(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + limit = max(1, min(limit, 100)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + + async def api_sample_worklist(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + limit = max(1, min(limit, 100)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + worklist = [self._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + + async def api_sample_insights(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 50) + limit = max(1, min(limit, 200)) + top = self._safe_int(request.query_params.get("top"), 10) + top = max(1, min(top, 20)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + insights = self._build_sample_insights(samples, top=top) + return { + "success": True, + "data": insights, + } + + async def api_sample_brief(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 5) + limit = max(1, min(limit, 20)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=100)) + return { + "success": True, + "data": { + "count": len(samples), + "text": self._render_sample_brief(samples, top=limit), + }, + } + + async def api_suggest_identifiers(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._suggest_identifiers(body) + + async def api_apply_identifiers(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + identifiers = body.get("identifiers") or [] + if not isinstance(identifiers, list): + return {"success": False, "message": "identifiers 必须是数组"} + result = self._append_custom_identifiers([str(line or "") for line in identifiers]) + return { + "success": True, + "message": "success", + "data": result, + } + + async def api_clear_failed_samples(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + cleared = self._clear_failed_samples() + return { + "success": True, + "message": "success", + "data": { + "cleared_count": cleared, + }, + } + + async def api_remove_failed_sample(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + result = self._remove_failed_sample(body.get("sample_index"), limit=1000) + if not result.get("removed"): + return {"success": False, "message": result.get("message", "移除失败"), "data": result} + return { + "success": True, + "message": "success", + "data": result, + } + + async def api_replay_failed_sample(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._replay_failed_sample(body) + + async def api_replay_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._replay_failed_samples(body) + + async def api_suggest_identifiers_from_sample(self, request: Request): + body = await request.json() + body["use_latest_sample"] = True if body.get("use_latest_sample") is None else body.get("use_latest_sample") + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + if body.get("sample_index") is None and body.get("use_latest_sample") is False: + body["use_latest_sample"] = True + return self._suggest_identifiers(body) + + async def api_suggest_identifiers_for_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._suggest_identifiers_for_failed_samples(body) + + async def api_apply_suggested_identifier(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._apply_suggested_identifier_internal(body) + + async def api_apply_suggested_identifiers_for_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._apply_suggested_identifiers_for_failed_samples(body) + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/health", + "endpoint": self.api_health, + "methods": ["GET"], + "summary": "检查 AI识别增强 的运行状态", + }, + { + "path": "/recognize", + "endpoint": self.api_recognize, + "methods": ["POST"], + "summary": "用当前 LLM 对失败标题做一次本地结构化识别测试", + }, + { + "path": "/failed_samples", + "endpoint": self.api_failed_samples, + "methods": ["GET"], + "summary": "查看最近保存的低置信度失败样本", + }, + { + "path": "/sample_worklist", + "endpoint": self.api_sample_worklist, + "methods": ["GET"], + "summary": "返回适合智能体使用的失败样本摘要列表", + }, + { + "path": "/sample_insights", + "endpoint": self.api_sample_insights, + "methods": ["GET"], + "summary": "汇总失败样本原因、重复问题和优先处理样本", + }, + { + "path": "/sample_brief", + "endpoint": self.api_sample_brief, + "methods": ["GET"], + "summary": "返回适合智能体低 token 消费的失败样本精简摘要", + }, + { + "path": "/suggest_identifiers", + "endpoint": self.api_suggest_identifiers, + "methods": ["POST"], + "summary": "根据标题和目标结果生成 MoviePilot 自定义识别词建议", + }, + { + "path": "/suggest_identifiers_from_sample", + "endpoint": self.api_suggest_identifiers_from_sample, + "methods": ["POST"], + "summary": "直接基于最近失败样本或指定样本生成自定义识别词建议", + }, + { + "path": "/suggest_identifiers_for_failed_samples", + "endpoint": self.api_suggest_identifiers_for_failed_samples, + "methods": ["POST"], + "summary": "批量为失败样本生成自定义识别词建议", + }, + { + "path": "/apply_identifiers", + "endpoint": self.api_apply_identifiers, + "methods": ["POST"], + "summary": "将确认后的自定义识别词追加写入系统 CustomIdentifiers", + }, + { + "path": "/clear_failed_samples", + "endpoint": self.api_clear_failed_samples, + "methods": ["POST"], + "summary": "清空失败样本文件", + }, + { + "path": "/remove_failed_sample", + "endpoint": self.api_remove_failed_sample, + "methods": ["POST"], + "summary": "按索引移除单条失败样本", + }, + { + "path": "/replay_failed_sample", + "endpoint": self.api_replay_failed_sample, + "methods": ["POST"], + "summary": "按当前识别词和当前识别器复查某条失败样本,并可在确认修复后自动出队", + }, + { + "path": "/replay_failed_samples", + "endpoint": self.api_replay_failed_samples, + "methods": ["POST"], + "summary": "批量复查失败样本,并可在确认修复后批量出队", + }, + { + "path": "/apply_suggested_identifier", + "endpoint": self.api_apply_suggested_identifier, + "methods": ["POST"], + "summary": "直接把最近失败样本或指定样本生成的建议规则写入 CustomIdentifiers,并按需移除该样本", + }, + { + "path": "/apply_suggested_identifiers_for_failed_samples", + "endpoint": self.api_apply_suggested_identifiers_for_failed_samples, + "methods": ["POST"], + "summary": "批量把失败样本生成的建议规则写入 CustomIdentifiers,并按需移除对应样本", + }, + ] + + def get_page(self) -> List[dict]: + llm_ready = bool(getattr(settings, "LLM_API_KEY", None)) + failed_samples_count = len(self._read_failed_samples(limit=200)) + custom_identifiers_count = len(self._get_custom_identifiers()) + llm_provider = getattr(settings, "LLM_PROVIDER", "—") + llm_model = getattr(settings, "LLM_MODEL", "—") + + def stat_card(title: str, value: Any, subtitle: str = "") -> dict: + content = [ + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mb-1"}, + "text": title, + }, + { + "component": "div", + "props": {"class": "text-h6 font-weight-bold"}, + "text": str(value), + }, + ] + if subtitle: + content.append( + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-1"}, + "text": subtitle, + } + ) + return { + "component": "VCard", + "props": {"variant": "tonal", "class": "pa-4 h-100"}, + "content": content, + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mb-4", + "title": "本地 LLM 识别兜底", + "text": "复用 MoviePilot 当前 LLM 配置,在原生识别失败时做结构化兜底,并把结果交回 MoviePilot 继续二次识别。", + }, + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mb-2"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("LLM 可用", "是" if llm_ready else "否", f"{llm_provider} / {llm_model}")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("自定义识别词", f"{custom_identifiers_count} 条", "系统 CustomIdentifiers")], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"variant": "outlined", "class": "pa-4 h-100"}, + "content": [ + { + "component": "div", + "props": {"class": "text-subtitle-1 font-weight-bold mb-2"}, + "text": "识别兜底", + }, + { + "component": "div", + "props": {"class": "text-body-2 text-medium-emphasis"}, + "text": "在 Chain NameRecognize 阶段回写 name / year / season / episode,供 MoviePilot 继续原生二次识别。", + }, + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-3"}, + "text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout} 秒", + }, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"variant": "outlined", "class": "pa-4 h-100"}, + "content": [ + { + "component": "div", + "props": {"class": "text-subtitle-1 font-weight-bold mb-2"}, + "text": "识别词闭环", + }, + { + "component": "div", + "props": {"class": "text-body-2 text-medium-emphasis"}, + "text": "失败样本可生成 CustomIdentifiers 建议,并按需追加写入系统配置。", + }, + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-3"}, + "text": f"写入后自动移除样本:{'是' if self._auto_remove_applied_sample else '否'}", + }, + ], + } + ], + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "当前版本已改为直接复用 MoviePilot 当前启用的 LLM 配置,在原生识别失败后做本地结构化兜底。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "enabled", "label": "启用 AI识别增强"}, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "debug", "label": "调试模式"}, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "save_failed_samples", "label": "保存低置信度样本"}, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "confidence_threshold", + "label": "置信度阈值", + "type": "number", + "hint": "低于该值的结果不注入 MoviePilot,默认 0.65", + "persistent-hint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "request_timeout", + "label": "LLM 请求超时(秒)", + "type": "number", + "hint": "默认 25 秒", + "persistent-hint": True, + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "max_retries", + "label": "结构化输出重试次数", + "type": "number", + "hint": "默认 2 次", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "max_failed_samples", + "label": "失败样本保留上限", + "type": "number", + "hint": "默认保留最近 200 条,并对重复样本自动去重", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "auto_remove_applied_sample", + "label": "写入识别词后自动移除对应失败样本", + }, + } + ], + } + ], + }, + ], + } + ] + return form, { + "enabled": False, + "debug": False, + "confidence_threshold": 0.65, + "request_timeout": 25, + "max_retries": 2, + "save_failed_samples": True, + "max_failed_samples": 200, + "auto_remove_applied_sample": True, + } diff --git a/AgentResourceOfficer/ARCHITECTURE.md b/AgentResourceOfficer/ARCHITECTURE.md new file mode 100644 index 0000000..8734421 --- /dev/null +++ b/AgentResourceOfficer/ARCHITECTURE.md @@ -0,0 +1,223 @@ +# Agent影视助手架构草案 + +`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。 + +## 设计目标 + +- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口” +- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务 +- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层 + +## 模块分层 + +### 1. adapters + +负责不同外部入口和外部平台接入: + +- `feishu` +- `hdhive` +- `quark` +- `pansou` +- 后续 `agent_tool` + +原则: + +- 只负责协议和输入输出转换 +- 不负责复杂业务编排 + +### 2. services + +负责核心业务能力: + +- `search_service` +- `unlock_service` +- `transfer_service` +- `signin_service` +- `user_service` + +原则: + +- 统一返回结构 +- 尽量不感知飞书、页面、CLI 等具体入口 + +### 3. session + +负责交互上下文: + +- 搜索候选缓存 +- 翻页状态 +- 选择上下文 +- 详情/审查补充信息(已支持候选页按需补主演) + +原则: + +- 入口层共享同一套会话数据 +- 后续优先支持内存 + 轻量持久化 + +### 4. models + +负责统一数据模型: + +- 搜索候选 +- 资源条目 +- 解锁结果 +- 转存结果 +- 用户信息 + +目标: + +- 减少旧插件之间字段名不一致的问题 + +## 首期配置模型 + +### 基础 + +- `enabled` +- `notify` +- `debug` + +### 影巢 + +- `hdhive_base_url` +- `hdhive_api_key` +- `hdhive_default_path` +- `hdhive_candidate_page_size` + +### 夸克 + +- `quark_cookie` +- `quark_default_path` +- `quark_timeout` +- `quark_auto_import_cookiecloud` + +### 飞书 + +- `feishu_enabled` +- `feishu_app_id` +- `feishu_app_secret` +- `feishu_verification_token` +- `feishu_allow_all` +- `feishu_allowed_chat_ids` +- `feishu_allowed_user_ids` + +### 智能体 / 工具层预留 + +- `agent_tools_enabled` +- `tool_debug` + +## 迁移映射 + +### 从 `QuarkShareSaver` + +优先迁入: + +- 分享链接解析 +- 目录创建 +- 转存执行 +- CookieCloud 自动导入 + +当前已开始拆出: + +- `services/quark_transfer.py` + +### 从 `P115StrmHelper` 协同层 + +当前已开始拆出: + +- `services/p115_transfer.py` + +### 从 `HdhiveOpenApi` + +随后迁入: + +- 搜索 +- 候选解析 +- 解锁 +- 用户信息 +- 配额 +- 分享管理 + +当前已开始拆出: + +- `services/hdhive_openapi.py` + +### 从 `HDHiveDailySign` + +补入: + +- 普通签到 +- 赌狗签到 +- 自动登录与状态记录 + +### 从 `FeishuCommandBridgeLong` + +最后收口: + +- 飞书长连接入口 +- 自然语言别名解析 +- 搜索/选择会话衔接 + +## 暂不迁入的内容 + +- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手` + +> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。 + +## P115StrmHelper 兼容补丁 + +新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。 + +仓库提供了幂等补丁脚本: + +```bash +MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh +``` + +补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。 + +## 115 轻量直转层 + +`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录: + +- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置 +- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive` +- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper` +- 直转失败时回退 `P115StrmHelper` 的分享转存主流程 + +这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。 +这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。 + +## 首个里程碑 + +第一个可用版本只追求三件事: + +1. 夸克分享链接直接转存 +2. 影巢搜索并解锁 +3. 飞书调用同一套执行服务 + +当前进度: + +- 已拆出夸克执行服务 +- 已拆出影巢基础 OpenAPI 服务 +- 已拆出 115 转存执行服务 +- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick) +- 主插件已具备: + - 夸克健康检查 + - 夸克转存 + - 影巢健康检查 + - 影巢搜索 + - 影巢关键词候选搜索 + - 影巢解锁 + - 115 依赖健康检查 + - 115 分享转存 + - 影巢解锁后自动路由到夸克执行层 + - 影巢解锁后自动路由到 115 执行层 + - 影巢会话搜索与按编号继续选择 + - 盘搜搜索与按编号继续执行 +- 统一智能入口对直链、盘搜、影巢三类输入的会话分流 +- 原生 Agent Tool 直接发起和轮询 115 扫码登录 +- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录` +- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行 +- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话 +- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API +- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令 diff --git a/AgentResourceOfficer/README.md b/AgentResourceOfficer/README.md new file mode 100644 index 0000000..59122a0 --- /dev/null +++ b/AgentResourceOfficer/README.md @@ -0,0 +1,212 @@ +# Agent影视助手 + +`Agent影视助手` 是这个仓库的主线插件,重点解决一件事: + +把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。 + +当前版本:`0.2.68` + +当前 helper 版本:`0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +如果你是第一次用这个仓库,先把这个插件跑通就够了。 + +--- + +## 适合谁 + +- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。 +- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。 +- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。 +- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。 +- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。 + +--- + +## 两种主要用法 + +### 1. 不使用外部智能体,只用飞书命令入口 + +如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。 + +配好后,直接在飞书里发: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。 + +### 2. 使用外部智能体 + +如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。 + +外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。 + +重点文档: + +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` + +### MCP 和 Skill 怎么分工 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。 + +- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。 +- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情和 Cookie 修复。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +--- + +## 核心命令 + +### 搜索 + +| 命令 | 作用 | +|---|---| +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢 | +| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 | + +### 转存 / 下载 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先生成下载计划 | + +注意: + +- `转存 <片名>` 默认是 115,不会自动改成夸克。 +- 只有明确说 `夸克转存 <片名>` 才走夸克。 +- `下载 <片名>` 是 PT 下载,不是云盘转存。 +- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。 +- 真正下载、转存、解锁、清空目录这类写入动作,都应先经过明确确认。 + +### 选择 / 翻页 + +```text +1 +1详情 +下载1 +n +``` + +- `1`:继续处理当前第 1 条结果。 +- `1详情`:查看第 1 条详情。 +- `下载1`:给第 1 条 PT 结果生成下载计划。 +- `n`:下一页。 + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 主要能力 + +### 云盘资源 + +- 盘搜搜索 +- 影巢搜索 / 解锁 +- 115 转存 +- 夸克转存 +- 云盘更新检查 +- 编号选择、详情、翻页 +- 智能建议与候选推荐 + +### MoviePilot 原生能力 + +- MP / PT 搜索 +- PT 下载计划 +- 订阅 +- 下载任务 +- 下载历史 +- 入库历史 +- 站点状态 / 下载器状态 +- 热门探索 / 推荐 + +### 账号与修复 + +- 115 扫码登录 / 状态检查 +- 影巢签到 / 签到日志 +- 影巢 Cookie 修复 +- 夸克 Cookie 修复 + +Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。 + +--- + +## 和旧插件的关系 + +`Agent影视助手` 是把旧的分散能力收成一条主线。 + +| 旧插件 | 主要用途 | 现在建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 | +| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 | + +旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。 + +--- + +## 新手最容易踩的坑 + +### 外部智能体乱改命令 + +常见错误: + +- 把 `云盘搜索` 偷换成 `盘搜搜索` +- 把 `下载` 当成云盘转存 +- 把 `15详情` 当成 `选择 15` +- 重排插件返回的编号 + +解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说: + +```text +校准影视技能 +``` + +### 跨机器地址填错 + +如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址: + +```text +ARO_BASE_URL=http://你的NAS地址:3000 +``` + +不要填 `127.0.0.1`,那只代表智能体自己这台机器。 + +### 夸克失败不一定是 Cookie 失效 + +分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。 + +--- + +## 进一步阅读 + +- [插件安装说明](../docs/PLUGIN_INSTALL.md) +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` diff --git a/AgentResourceOfficer/__init__.py b/AgentResourceOfficer/__init__.py new file mode 100644 index 0000000..53ffc7d --- /dev/null +++ b/AgentResourceOfficer/__init__.py @@ -0,0 +1,26967 @@ +import asyncio +import concurrent.futures +import copy +import hmac +import json +import os +import re +import threading +import time +import uuid +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen + +from fastapi import Request +try: + from apscheduler.triggers.cron import CronTrigger +except Exception: + CronTrigger = None +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.log import logger +except Exception: + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None +try: + from app.agent.tools.manager import moviepilot_tool_manager +except Exception: + moviepilot_tool_manager = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None +try: + from app import schemas as app_schemas +except Exception: + app_schemas = None +from app.plugins import _PluginBase + +from .services.hdhive_openapi import HDHiveOpenApiService +from .services.p115_transfer import P115TransferService +from .services.quark_transfer import QuarkTransferService +from .feishu_channel import FeishuChannel +from .agenttool import ( + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantMaintainTool, + AssistantPickTool, + AssistantPlansClearTool, + AssistantPlansTool, + AssistantPulseTool, + AssistantReadinessTool, + AssistantRecoverTool, + AssistantRequestTemplatesTool, + AssistantRouteTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSelfcheckTool, + AssistantStartupTool, + AssistantToolboxTool, + AssistantWorkflowTool, + FeishuChannelHealthTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + P115CancelPendingTool, + P115PendingTool, + P115QRCodeCheckTool, + P115QRCodeStartTool, + P115ResumePendingTool, + P115StatusTool, + ShareRouteTool, +) + + +class _JsonRequestShim: + def __init__(self, request: Request, body: Dict[str, Any], method: str = "POST") -> None: + self.method = str(method or "POST").upper() + self.headers = request.headers + self.query_params = request.query_params + self._body = body + + async def json(self) -> Dict[str, Any]: + return self._body + + +class _RequestContextShim: + def __init__(self, headers: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None) -> None: + default_headers = dict(headers or {}) + if settings is not None and not default_headers.get("Authorization"): + token = str(getattr(settings, "API_TOKEN", "") or "").strip() + if token: + default_headers["Authorization"] = f"Bearer {token}" + self.headers = default_headers + self.query_params = query_params or {} + + +class AgentResourceOfficer(_PluginBase): + plugin_name = "Agent影视助手" + plugin_desc = "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png" + plugin_version = "0.2.68" + request_templates_schema_version = "request_templates.v1" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "agentresourceofficer_" + plugin_order = 40 + auth_level = 1 + + _enabled = False + _notify = True + _debug = False + _quark_cookie = "" + _quark_default_path = "/飞书" + _quark_timeout = 30 + _quark_auto_import_cookiecloud = True + _pansou_base_url = "http://127.0.0.1:805" + _pansou_timeout = 20 + _hdhive_api_key = "" + _hdhive_base_url = "https://hdhive.com" + _hdhive_timeout = 30 + _hdhive_default_path = "/待整理" + _assistant_result_page_size = 10 + _assistant_cloud_result_page_size = 20 + _hdhive_candidate_page_size = 20 + _hdhive_resource_enabled = True + _hdhive_max_unlock_points = 20 + _hdhive_checkin_enabled = False + _hdhive_checkin_gambler_mode = False + _hdhive_checkin_once = False + _hdhive_checkin_cron = "0 8 * * *" + _hdhive_checkin_cookie = "" + _hdhive_checkin_auto_login = True + _hdhive_checkin_username = "" + _hdhive_checkin_password = "" + _p115_default_path = "/待整理" + _p115_client_type = "alipaymini" + _p115_cookie = "" + _p115_prefer_direct = True + _mp_download_save_path = "" + _assistant_default_pt_min_seeders = 3 + _assistant_default_auto_ingest_enabled = False + _assistant_default_auto_ingest_score_threshold = 90 + _assistant_default_confirm_score_threshold = 70 + _feishu_enabled = False + _feishu_allow_all = False + _feishu_reply_enabled = True + _feishu_reply_receive_id_type = "chat_id" + _feishu_app_id = "" + _feishu_app_secret = "" + _feishu_verification_token = "" + _feishu_allowed_chat_ids: List[str] = [] + _feishu_allowed_user_ids: List[str] = [] + _feishu_command_whitelist: List[str] = [] + _feishu_command_aliases = "" + _feishu_command_mode = "resource_officer" + + _quark_service: Optional[QuarkTransferService] = None + _hdhive_service: Optional[HDHiveOpenApiService] = None + _p115_service: Optional[P115TransferService] = None + _feishu_channel: Optional[FeishuChannel] = None + _session_cache: Dict[str, Dict[str, Any]] = {} + _session_lock = threading.RLock() + _agent_tools_reloaded = False + _candidate_actor_cache: Dict[str, List[str]] = {} + _candidate_actor_cache_lock = threading.Lock() + _session_store_key = "assistant_session_cache" + _session_retention_seconds = 7 * 24 * 60 * 60 + _execution_history_store_key = "assistant_execution_history" + _execution_history_limit = 100 + _execution_history: List[Dict[str, Any]] = [] + _workflow_plan_store_key = "assistant_workflow_plans" + _workflow_plan_limit = 50 + _workflow_plans: Dict[str, Dict[str, Any]] = {} + _assistant_preferences_store_key = "assistant_preferences" + _assistant_preferences_limit = 100 + _assistant_preferences: Dict[str, Dict[str, Any]] = {} + _hdhive_checkin_history_store_key = "hdhive_checkin_history" + _hdhive_checkin_history_limit = 60 + _hdhive_checkin_history: List[Dict[str, Any]] = [] + _agent_tools_reload_lock = threading.Lock() + _agent_tools_reload_version = "" + _agent_tools_reload_at = 0.0 + + @staticmethod + def _extract_first_url(text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", str(text or "")) + return match.group(0).rstrip(".,);]") if match else "" + + @staticmethod + def _format_pansou_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text or text.startswith("0001-01-01"): + return "" + text = text.replace("T", " ").replace("Z", "") + if len(text) >= 10: + text = text[:10].replace("-", "/") + return text.strip() + + @staticmethod + def _format_pansou_display_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("🕒"): + return text + match = re.match(r"^\d{4}[/-](\d{2}[/-]\d{2})(?:\b|$)", text) + if match: + text = match.group(1).replace("-", "/") + return f"🕒{text}" + + @staticmethod + def _normalize_search_prefix(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) + raw = re.sub(r"\s+", " ", raw).strip() + for pattern in ( + r"^(?:mp|pt)\s*搜索\s*(.*)$", + r"^原生\s*搜索\s*(.*)$", + ): + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return "mp", match.group(1).strip() + mappings = [ + ("更新检查", "update"), + ("更新搜索", "update"), + ("查更新", "update"), + ("更新", "update"), + ("资源决策", "smart_decision"), + ("智能决策", "smart_decision"), + ("智能执行", "smart_execute"), + ("智能搜执行", "smart_execute"), + ("智能计划", "smart_plan"), + ("智能搜计划", "smart_plan"), + ("云盘搜索", "cloud"), + ("云盘搜", "cloud"), + ("智能搜索", "smart"), + ("智能搜", "smart"), + ("MP搜索", "mp"), + ("MP 搜索", "mp"), + ("PT搜索", "mp"), + ("PT 搜索", "mp"), + ("pt搜索", "mp"), + ("pt 搜索", "mp"), + ("原生搜索", "mp"), + ("原生 搜索", "mp"), + ("搜索资源", "pansou"), + ("找资源", "pansou"), + ("搜索", "pansou"), + ("找", "pansou"), + ("1搜索", "pansou"), + ("2搜索", "hdhive"), + ("影巢搜索", "hdhive"), + ("yc", "hdhive"), + ("2", "hdhive"), + ("盘搜搜索", "pansou"), + ("盘搜", "pansou"), + ("ps", "pansou"), + ("1", "pansou"), + ] + for prefix, mode in mappings: + if raw == prefix: + return mode, "" + if raw.startswith(prefix + " "): + return mode, raw[len(prefix):].strip() + if raw.startswith(prefix): + remain = raw[len(prefix):].strip() + if remain: + return mode, remain + if raw.startswith("检查 "): + remain = raw[len("检查"):].strip() + if remain: + return "update", remain + return "", raw + + @staticmethod + def _extract_smart_decision_intent(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + patterns = [ + ("execute_now", [ + r"(?:直接|立即|马上|立刻)?确认执行$", + r"(?:直接|立即|马上|立刻)?确认$", + r"(?:直接|立即|马上|立刻)?执行$", + r"(?:直接|立即|马上|立刻)?下载$", + r"(?:直接|立即|马上|立刻)?转存$", + r"(?:直接|立即|马上|立刻)?解锁$", + r"(?:直接|立即|马上|立刻)?处理$", + ]), + ("make_plan", [ + r"(?:先)?生成计划$", + r"(?:先)?做计划$", + r"(?:先)?出计划$", + r"(?:先)?计划$", + r"(?:先)?待确认计划$", + ]), + ("show_detail", [ + r"详情$", + r"(?:先)?看详情$", + r"(?:先)?看一下$", + r"(?:先)?看看$", + r"(?:只)?看推荐$", + r"(?:只)?看结果$", + r"(?:先)?推荐一下$", + ]), + ] + compact = re.sub(r"\s+", "", raw) + for intent, tokens in patterns: + for token in tokens: + if re.search(token, compact, flags=re.IGNORECASE): + cleaned = re.sub(token, "", compact, flags=re.IGNORECASE).strip() + return cleaned or raw, intent + return raw, "" + + @classmethod + def _extract_mp_result_filter_intent(cls, text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + episode_patterns = [ + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*s\d{1,2}\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*(\d{1,3})\s*集\s*(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*([零〇一二两三四五六七八九十]{1,4})\s*集\s*(?:资源|结果)?", + ] + for pattern in episode_patterns: + match = re.search(pattern, raw, flags=re.IGNORECASE) + if not match: + continue + raw_episode = match.group(1) + if str(raw_episode).isdigit(): + episode = int(raw_episode) + else: + episode = cls._parse_simple_cjk_number(str(raw_episode)) or 0 + if episode <= 0: + continue + cleaned = (raw[:match.start()] + " " + raw[match.end():]).strip(" ::,,。") + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned or raw, f"episode:{episode}" + patterns = [ + r"(?:给我|帮我|只看|看看|看一下)?最新(?:一)?集(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下)?最新(?:两|2)集(?:资源|结果)?", + r"只(?:要|看)(?:当前)?最新", + ] + cleaned = raw + matched = False + for pattern in patterns: + new_value = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE) + if new_value != cleaned: + matched = True + cleaned = new_value + cleaned = re.sub(r"\s+", " ", cleaned).strip(" ::,,。") + return (cleaned or raw, "latest_episode") if matched else (raw, "") + + async def _assistant_smart_decision_followup_detail( + self, + request, + *, + session: str, + cache_key: str, + keyword: str, + compact: bool = False, + apikey: str = "", + media_type: str = "", + year: str = "", + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + ): + decision_result = await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + ) + state = self._load_session(cache_key) + if not isinstance(state, dict) or self._clean_text(state.get("kind")) != "assistant_smart_search": + return decision_result + return await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": apikey, + }) + ) + + @staticmethod + def _match_command_prefix(raw: str, prefixes: List[str]) -> Optional[Tuple[str, str]]: + text = str(raw or "").strip() + for prefix in prefixes: + if not text.startswith(prefix): + continue + remain = text[len(prefix):] + if not remain: + return prefix, "" + return prefix, remain.lstrip(" ::").strip() + return None + + @staticmethod + def _normalize_mp_recommend_request(value: Any, default_source: str = "tmdb_trending") -> Tuple[str, str]: + raw = str(value or "").strip() + compact = re.sub(r"[\s,。?!!?,、::]+", "", raw).lower() + allowed_sources = { + "tmdb_trending", + "tmdb_movies", + "tmdb_tvs", + "douban_hot", + "douban_movie_hot", + "douban_tv_hot", + "douban_showing", + "douban_movie_showing", + "douban_movie_top250", + "douban_tv_animation", + "bangumi_calendar", + } + if compact in allowed_sources: + if compact == "douban_showing": + return "douban_movie_showing", "movie" + return compact, "all" + source_aliases = { + "trending": ("tmdb_trending", "all"), + "tmdb": ("tmdb_trending", "all"), + "tmdb热门": ("tmdb_trending", "all"), + "tmdb电影": ("tmdb_movies", "movie"), + "tmdb剧集": ("tmdb_tvs", "tv"), + "tmdb电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "豆瓣电影": ("douban_movie_hot", "movie"), + "豆瓣热门电影": ("douban_movie_hot", "movie"), + "豆瓣热映": ("douban_movie_showing", "movie"), + "豆瓣电视剧": ("douban_tv_hot", "tv"), + "豆瓣剧集": ("douban_tv_hot", "tv"), + "豆瓣top250": ("douban_movie_top250", "movie"), + "top250": ("douban_movie_top250", "movie"), + "douban_showing": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "热映": ("douban_movie_showing", "movie"), + "院线": ("douban_movie_showing", "movie"), + "bangumi": ("bangumi_calendar", "tv"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "每日放送": ("bangumi_calendar", "tv"), + "动画番剧": ("bangumi_calendar", "tv"), + } + if compact in source_aliases: + return source_aliases[compact] + if "top250" in compact: + return "douban_movie_top250", "movie" + if any(token in compact for token in ["bangumi", "番剧", "每日放送", "今日放送", "今日动画"]): + return "bangumi_calendar", "tv" + if any(token in compact for token in ["正在热映", "热映", "院线"]): + return "douban_movie_showing", "movie" + if "豆瓣" in compact: + if "动画" in compact: + return "douban_tv_animation", "tv" + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "douban_tv_hot", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "douban_movie_hot", "movie" + return "douban_hot", "all" + if "tmdb" in compact: + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "tmdb_movies", "movie" + return "tmdb_trending", "all" + if any(token in compact for token in ["电视剧", "剧集", "剧集推荐", "热门剧", "热门电视剧"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "影视电影", "热门电影"]): + return "tmdb_movies", "movie" + return default_source or "tmdb_trending", "all" + + @classmethod + def _resolve_pan_path_value(cls, value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + alias_map = { + "分享": "/飞书", + "飞书": "/飞书", + "待整理": "/待整理", + "最新动画": "/最新动画", + } + mapped = alias_map.get(text, text) + return cls._normalize_path(mapped) + + @staticmethod + def _normalize_pick_action(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {"继续决策", "继续资源决策", "decision_continue"}: + return "decision_continue" + if text in {"换影巢", "切换影巢", "走影巢", "用影巢", "decision_hdhive"}: + return "decision_hdhive" + if text in {"换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decision_pansou"}: + return "decision_pansou" + if text in {"换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "走pt", "走原生", "走mp", "decision_mp_pt"}: + return "decision_mp_pt" + if text in {"保守一点", "更保守", "保守模式", "decision_conservative"}: + return "decision_conservative" + if text in {"激进一点", "更激进", "激进模式", "decision_aggressive"}: + return "decision_aggressive" + if text in {"只用夸克", "只有夸克", "仅夸克", "只要夸克", "decision_only_quark"}: + return "decision_only_quark" + if text in {"只用115", "只有115", "仅115", "只要115", "decision_only_115"}: + return "decision_only_115" + if text in {"两者都可", "云盘都可", "115和夸克都可", "115夸克都可", "decision_cloud_both"}: + return "decision_cloud_both" + if text in {"只走pt", "只走原生", "只用pt", "只用原生", "只走mp", "只用mp", "decision_only_mp_pt"}: + return "decision_only_mp_pt" + if text in {"只走盘搜", "只用盘搜", "decision_only_pansou"}: + return "decision_only_pansou" + if text in {"只走影巢", "只用影巢", "decision_only_hdhive"}: + return "decision_only_hdhive" + if text in {"不用盘搜", "关闭盘搜", "禁用盘搜", "decision_disable_pansou"}: + return "decision_disable_pansou" + if text in {"不用影巢", "关闭影巢", "禁用影巢", "decision_disable_hdhive"}: + return "decision_disable_hdhive" + if text in {"不用pt", "不用原生", "关闭pt", "关闭原生", "关闭mp", "decision_disable_mp_pt"}: + return "decision_disable_mp_pt" + if text in {"按保存偏好", "恢复偏好", "恢复默认偏好", "清除会话偏好", "decision_reset_preferences"}: + return "decision_reset_preferences" + if text in {"确认执行", "确认执行吧", "就执行吧", "直接来", "执行它", "确认处理", "确认转存", "确认解锁"}: + return "best_execute" + if text in {"先计划", "先做计划", "先生成计划", "先出计划", "还是先计划"}: + return "best_plan" + if text in {"先看详情", "先看推荐", "先看结果", "看最佳", "看看最佳", "先看一下"}: + return "best" + if text in {"best_execute", "execute_best", "执行最佳", "最佳执行", "立即执行最佳", "直接执行最佳"}: + return "best_execute" + if text in {"best_plan", "plan_best", "计划最佳", "最佳计划", "计划推荐", "推荐计划", "最优计划"}: + return "best_plan" + if text in {"detail", "details", "review", "详情", "审查"}: + return "detail" + if text in {"best", "best_result", "recommend_best", "最佳", "最佳片源", "推荐片源", "推荐下载", "最优"}: + return "best" + if text in {"plan", "dry_run", "make_plan", "计划", "生成计划", "计划选择", "计划处理", "转存计划", "解锁计划"}: + return "plan" + if text in {"n", "next", "next_page", "下一页", "下页"} or text.startswith("n "): + return "next_page" + return "" + + @staticmethod + def _normalize_smart_search_short_action(value: Any, *, state_kind: str = "") -> str: + if str(state_kind or "").strip() != "assistant_smart_search": + return "" + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return "best" + if compact in {"计划", "做计划", "plan"}: + return "best_plan" + if compact in {"确认", "执行", "确认吧", "执行吧", "确定执行", "execute", "run"}: + return "best_execute" + return "" + + @staticmethod + def _is_pending_plan_confirmation_text(value: Any) -> bool: + raw = str(value or "").strip() + if not raw: + return False + compact = re.sub(r"\s+", "", raw).lower() + if re.search(r"\d", compact): + return False + return compact in { + "确认", + "确认吧", + "确认执行", + "确认执行吧", + "执行", + "执行吧", + "execute", + "run", + "确定", + "确定执行", + "执行下载", + "确认下载", + "开始下载", + "下载吧", + } + + @classmethod + def _parse_pending_plan_numeric_confirmation(cls, value: Any) -> int: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + if not raw: + return 0 + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + # Only a bare number or explicit execution wording may confirm a saved plan. + # Keep "下载1" for generating/reviewing the PT download plan in the current + # search result; otherwise it can accidentally execute an older pending plan. + match = re.fullmatch(r"(?:执行|确认|执行计划|确认计划)?(\d+)", compact, flags=re.IGNORECASE) + if not match: + return 0 + return cls._safe_int(match.group(1), 0) + + def _is_pending_plan_numeric_confirmation(self, value: Any, plan: Optional[Dict[str, Any]]) -> bool: + index = self._parse_pending_plan_numeric_confirmation(value) + if index <= 0 or not isinstance(plan, dict): + return False + expected_choices: List[int] = [] + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + expected_choices.append(self._safe_int(execute_body.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(execute_body.get("choice") or execute_body.get("index"), 0)) + for action in plan.get("actions") or []: + if isinstance(action, dict): + expected_choices.append(self._safe_int(action.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(action.get("choice") or action.get("index"), 0)) + return index in {choice for choice in expected_choices if choice > 0} + + @classmethod + def _normalize_ai_reingest_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + result: Dict[str, Any] = {} + ai_session_kinds = { + "assistant_ai_failed_samples", + "assistant_ai_sample_worklist", + "assistant_ai_sample_insights", + "assistant_ai_replay", + } + saved_plan = dict((current_state.get("saved_plan") or {}).get("latest") or {}) + saved_workflow = cls._clean_text(saved_plan.get("workflow")) + has_pending_ai_replay_plan = ( + bool((current_state.get("saved_plan") or {}).get("has_pending")) + and saved_workflow == "ai_replay_failed_sample" + ) + if has_pending_ai_replay_plan and compact in {"确认", "确认吧", "执行", "执行吧", "确定", "确定执行", "run", "execute"}: + return {"action": "execute_plan"} + if kind not in ai_session_kinds: + return {} + keyword = cls._clean_text(current_state.get("keyword")) + if keyword and compact in {"诊断", "本地诊断", "重新诊断", "看诊断"}: + return {"action": "mp_local_diagnose", "keyword": keyword} + if keyword and compact in {"入库状态", "看入库状态", "状态"}: + return {"action": "mp_ingest_status", "keyword": keyword} + if compact in {"工作清单", "返回工作清单", "回工作清单"}: + return {"action": "ai_sample_worklist", "keyword": keyword} + if compact in {"失败样本", "返回失败样本", "回失败样本"}: + return {"action": "ai_failed_samples", "keyword": keyword} + if compact in {"样本洞察", "洞察", "返回样本洞察", "回样本洞察"}: + return {"action": "ai_sample_insights", "keyword": keyword} + raw = cls._clean_text(value) + replay_match = re.match(r"^\s*(重放|重识别|重跑)\s*(\d+)(?:\s+(.*))?$", raw) + if replay_match: + result = { + "action": "ai_replay_failed_sample", + "sample_index": replay_match.group(2), + "keyword": "", + "mode": "", + "remove_if_resolved": "true", + } + remain_text = cls._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + result["remove_if_resolved"] = "false" + return result + return {} + + @classmethod + def _normalize_mp_recommend_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:决策|资源决策|智能决策)\s*(\d+)\s*$", {"mode": "smart_decision"}), + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + (r"^\s*(?:确认|执行|直接执行)\s*(\d+)\s*$", {"mode": "smart_execute"}), + (r"^\s*(?:盘搜|ps)\s*(\d+)\s*$", {"mode": "pansou"}), + (r"^\s*(?:影巢|yc)\s*(\d+)\s*$", {"mode": "hdhive"}), + (r"^\s*(?:原生|mp|pt)\s*(\d+)\s*$", {"mode": "mp"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + selected_index = cls._safe_int(current_state.get("selected_index"), 0) + if selected_index <= 0: + items = current_state.get("items") if isinstance(current_state.get("items"), list) else [] + if items: + selected_index = 1 + if selected_index > 0: + no_index_aliases = { + "详情": {"action": "detail"}, + "查看详情": {"action": "detail"}, + "看详情": {"action": "detail"}, + "决策": {"mode": "smart_decision"}, + "资源决策": {"mode": "smart_decision"}, + "智能决策": {"mode": "smart_decision"}, + "计划": {"action": "plan"}, + "生成计划": {"action": "plan"}, + "先计划": {"action": "plan"}, + "确认": {"mode": "smart_execute"}, + "执行": {"mode": "smart_execute"}, + "直接执行": {"mode": "smart_execute"}, + "盘搜": {"mode": "pansou"}, + "ps": {"mode": "pansou"}, + "影巢": {"mode": "hdhive"}, + "yc": {"mode": "hdhive"}, + "原生": {"mode": "mp"}, + "mp": {"mode": "mp"}, + "pt": {"mode": "mp"}, + } + shortcut = no_index_aliases.get(raw) + if shortcut: + return {"index": selected_index, **shortcut} + return {} + + @classmethod + def _normalize_mp_recommend_followup( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + followup_aliases = { + "电影": ("tmdb_movies", "movie"), + "热门电影": ("tmdb_movies", "movie"), + "电视剧": ("tmdb_tvs", "tv"), + "热门电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "热映": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "tmdb": ("tmdb_trending", "all"), + "热门": ("tmdb_trending", "all"), + } + source_pair = followup_aliases.get(compact) + if not source_pair: + return {} + source_name, media_type = source_pair + return {"action": "mp_recommendations", "keyword": source_name, "type": media_type} + + @classmethod + def _normalize_recommend_handoff_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact not in {"回推荐", "回榜单", "返回推荐", "返回榜单", "推荐", "榜单"}: + switch_mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp", + "mp": "mp", + } + switch_mode = switch_mode_aliases.get(compact) + if not switch_mode: + return {} + return {"action": "switch_recommend_handoff_source", "mode": switch_mode} + return {"action": "return_to_recommend"} + + @classmethod + def _normalize_recommend_handoff_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact in {"决策", "资源决策", "智能决策"}: + return {"action": "return_to_smart_decision"} + if kind in {"assistant_pansou", "assistant_mp"}: + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return {"route_action": "best"} + if compact in {"计划", "做计划", "plan"}: + return {"route_action": "best_plan"} + if compact in {"确认", "确认吧", "确认执行", "执行", "执行吧", "确定执行", "run", "execute"}: + return {"action": "confirm_recommend_handoff"} + return {} + + @classmethod + def _normalize_recommend_source_compound_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_mp_recommend", "assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + source_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "原生": "mp", + "mp": "mp", + } + action_aliases = { + "详情": "best", + "看详情": "best", + "计划": "best_plan", + "确认": "best_execute", + "执行": "best_execute", + } + for alias, mode in source_aliases.items(): + if not compact.startswith(alias): + continue + remain = compact[len(alias):] + followup_action = action_aliases.get(remain) + if not followup_action: + continue + return { + "action": "recommend_source_compound", + "mode": mode, + "followup_action": followup_action, + } + return {} + + @classmethod + def _normalize_mp_recommend_direct_intent(cls, value: Any) -> Dict[str, Any]: + raw = cls._clean_text(value) + if not raw: + return {} + prefix_match = cls._match_command_prefix(raw, ["智能发现", "热门发现", "热门推荐", "推荐"]) + if not prefix_match: + return {} + _, remain = prefix_match + remain = cls._clean_text(remain) + if not remain: + return {} + suffix_aliases: List[Tuple[str, Dict[str, str]]] = [ + ("资源决策", {"mode": "smart_decision"}), + ("智能决策", {"mode": "smart_decision"}), + ("查看详情", {"action": "detail"}), + ("看详情", {"action": "detail"}), + ("确认执行", {"mode": "smart_execute"}), + ("直接执行", {"mode": "smart_execute"}), + ("生成计划", {"action": "plan"}), + ("先计划", {"action": "plan"}), + ("详情", {"action": "detail"}), + ("决策", {"mode": "smart_decision"}), + ("计划", {"action": "plan"}), + ("确认", {"mode": "smart_execute"}), + ("执行", {"mode": "smart_execute"}), + ("盘搜", {"mode": "pansou"}), + ("影巢", {"mode": "hdhive"}), + ("原生", {"mode": "mp"}), + ] + for suffix, action_payload in suffix_aliases: + separator = f" {suffix}" + if remain.endswith(separator): + keyword = cls._clean_text(remain[: -len(separator)]) + elif remain == suffix: + keyword = "" + else: + continue + if not keyword: + return {} + return {"keyword": keyword, "index": 1, **action_payload} + return {} + + @staticmethod + def _normalize_pick_mode(value: Any) -> str: + text = str(value or "").strip().lower() + compact = re.sub(r"\s+", "", text) + if not compact: + return "" + if any(token in compact for token in ["smart_execute", "smartexecute", "直接执行", "确认执行", "立即执行", "确认", "执行"]): + return "smart_execute" + if any(token in compact for token in ["smart_plan", "smartplan", "生成计划", "先计划", "计划"]): + return "smart_plan" + if any(token in compact for token in ["smart_decision", "smartdecision", "智能决策", "资源决策", "决策"]): + return "smart_decision" + if re.search(r"(^|[^a-z])smart($|[^a-z])", text): + return "smart_decision" + if any(token in compact for token in ["hdhive", "影巢", "影潮", "走影巢", "用影巢"]): + return "hdhive" + if re.search(r"(^|[^a-z])yc($|[^a-z])", text): + return "hdhive" + if any(token in compact for token in ["pansou", "盘搜", "走盘搜", "用盘搜"]): + return "pansou" + if re.search(r"(^|[^a-z])ps($|[^a-z])", text): + return "pansou" + if any(token in compact for token in ["mp", "原生", "moviepilot", "站点", "pt"]): + return "mp" + return "" + + @staticmethod + def _normalize_fullwidth_digits(value: Any) -> str: + return str(value or "").translate(str.maketrans("0123456789", "0123456789")) + + @staticmethod + def _parse_chinese_pick_number(value: Any) -> int: + text = str(value or "").strip() + if not text: + return 0 + text = text.replace("两", "二").replace("〇", "零") + digits = { + "零": 0, + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + if text.isdigit(): + return int(text) + if any(ch not in digits and ch not in {"十", "百"} for ch in text): + return 0 + if len(text) == 1: + return digits.get(text, 0) + total = 0 + remainder = text + if "百" in remainder: + left, _, remainder = remainder.partition("百") + hundreds = 1 if not left else digits.get(left, 0) + if hundreds <= 0: + return 0 + total += hundreds * 100 + if "十" in remainder: + left, _, right = remainder.partition("十") + tens = 1 if not left else digits.get(left, 0) + ones = 0 if not right else digits.get(right, 0) + if tens <= 0 or (right and ones <= 0): + return 0 + total += tens * 10 + ones + return total + if total and len(remainder) == 1: + return total + digits.get(remainder, 0) + return total + + @classmethod + def _normalize_pick_action_fragment(cls, value: Any) -> str: + compact = re.sub(r"[\s的地得一下]+", "", str(value or "").strip().lower()) + if not compact: + return "" + action = cls._normalize_pick_action(compact) + if action: + return action + if any(token in compact for token in ["详情", "明细", "查看", "看看", "看下", "看一下"]): + return "detail" + if "审查" in compact: + return "detail" + if "下载" in compact: + return "plan" + if any(token in compact for token in ["计划", "dryrun", "makeplan"]): + return "plan" + return "" + + @classmethod + def _parse_compact_pick_text(cls, value: Any) -> Tuple[int, str]: + raw = cls._normalize_fullwidth_digits(value) + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + if not compact: + return 0, "" + chinese_number = r"[零〇一二两三四五六七八九十百]+" + number_token = rf"(?:\d+|{chinese_number})" + + def number_value(token: str) -> int: + return cls._safe_int(token, 0) if token.isdigit() else cls._parse_chinese_pick_number(token) + + # Short forms: "16详情", "十六详情", "16", "十六". + match = re.fullmatch(rf"({number_token})(.*)", compact) + if match: + index = number_value(match.group(1)) + suffix = match.group(2) + action = cls._normalize_pick_action_fragment(suffix) + if index > 0 and (not suffix or action): + return index, action + + # Reversed short forms: "详情16", "详情十六", "计划16". + match = re.fullmatch(rf"(.+?)({number_token})", compact) + if match: + action = cls._normalize_pick_action_fragment(match.group(1)) + index = number_value(match.group(2)) + if index > 0 and action: + return index, action + + # Natural phrases: "我要看看 16 的详情", "帮我看第十六个详情". + natural_action = cls._normalize_pick_action_fragment(compact) + if natural_action: + digit_match = re.search(r"\d+", compact) + if digit_match: + return cls._safe_int(digit_match.group(0), 0), natural_action + for cn_match in re.finditer(chinese_number, compact): + token = cn_match.group(0) + # Avoid treating "看一下详情" as "pick 1 detail". + if len(token) == 1 and token in {"一", "二", "三"}: + continue + index = cls._parse_chinese_pick_number(token) + if index > 0: + return index, natural_action + return 0, "" + + @classmethod + def _parse_pick_text(cls, value: Any) -> Tuple[int, str, str, str]: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + action = cls._normalize_pick_action(raw) + if action: + return 0, "", action, "" + alias_pattern = r"^(?:/smart_pick|smart_pick|计划选择|计划处理|生成计划|转存计划|解锁计划|计划(?=\s*\d)|选择|选|继续|pick|plan|dry_run|make_plan)\s*" + alias_match = re.match(alias_pattern, raw, flags=re.IGNORECASE) + digit_match = re.match(r"^(\d+)(.*)$", raw) + compact_index, compact_action = cls._parse_compact_pick_text(raw) + if compact_index > 0 and not alias_match: + return compact_index, "", compact_action, "" + if not alias_match and not digit_match: + return 0, "", "", "" + if digit_match and not alias_match: + suffix = cls._clean_text(digit_match.group(2)) + if suffix and not ( + suffix.startswith("/") + or "=" in suffix + or cls._normalize_pick_mode(suffix) + or cls._normalize_pick_action(suffix) + or suffix.lower() in {"n", "next"} + ): + return 0, "", "", "" + text = re.sub(alias_pattern, "", raw, flags=re.IGNORECASE).strip() + if alias_match and cls._normalize_pick_action(alias_match.group(0).strip()) == "plan": + action = "plan" + index = 0 + path = "" + mode = "" + pick_action = action or "" + match = re.search(r"\d+", text) + if match: + index = cls._safe_int(match.group(0), 0) + elif text: + compact_index, compact_action = cls._parse_compact_pick_text(text) + if compact_index > 0: + index = compact_index + if compact_action: + pick_action = compact_action + for token in text.split(): + if "=" not in token: + if not pick_action: + pick_action = cls._normalize_pick_action(token) + continue + key, token_value = token.split("=", 1) + key = key.strip().lower() + token_value = token_value.strip() + if key in {"path", "dir", "目录", "位置"} and token_value: + path = cls._resolve_pan_path_value(token_value) + elif key in {"mode", "search_mode", "target", "方式", "来源", "渠道"} and token_value: + mode = cls._normalize_pick_mode(token_value) + if not mode: + mode = cls._normalize_pick_mode(text) + if not pick_action: + suffix = re.sub(r"\d+", " ", text, count=1).strip() + pick_action = cls._normalize_pick_action(suffix) + return index, path, pick_action, mode + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._notify = bool(config.get("notify", True)) + self._debug = bool(config.get("debug", False)) + self._quark_cookie = self._clean_text(config.get("quark_cookie")) + self._quark_default_path = self._normalize_path(config.get("quark_default_path") or "/飞书") + self._quark_timeout = self._safe_int(config.get("quark_timeout"), 30) + self._quark_auto_import_cookiecloud = bool(config.get("quark_auto_import_cookiecloud", True)) + self._pansou_base_url = self._clean_text(config.get("pansou_base_url") or "http://127.0.0.1:805").rstrip("/") + self._pansou_timeout = max(3, min(120, self._safe_int(config.get("pansou_timeout"), 20))) + self._hdhive_api_key = self._clean_text(config.get("hdhive_api_key")) + self._hdhive_base_url = self._clean_text(config.get("hdhive_base_url") or "https://hdhive.com").rstrip("/") + self._hdhive_timeout = self._safe_int(config.get("hdhive_timeout"), 30) + self._hdhive_default_path = self._normalize_path(config.get("hdhive_default_path") or "/待整理") + self._hdhive_candidate_page_size = max(5, min(20, self._safe_int(config.get("hdhive_candidate_page_size"), type(self)._hdhive_candidate_page_size))) + self._hdhive_resource_enabled = bool(config.get("hdhive_resource_enabled", True)) + self._hdhive_max_unlock_points = max(0, self._safe_int(config.get("hdhive_max_unlock_points"), 20)) + self._hdhive_checkin_enabled = bool(config.get("hdhive_checkin_enabled", False)) + self._hdhive_checkin_gambler_mode = bool(config.get("hdhive_checkin_gambler_mode", False)) + self._hdhive_checkin_once = bool(config.get("hdhive_checkin_once", False)) + self._hdhive_checkin_cron = self._clean_text(config.get("hdhive_checkin_cron") or "0 8 * * *") + self._hdhive_checkin_cookie = self._clean_text(config.get("hdhive_checkin_cookie")) + self._hdhive_checkin_auto_login = bool(config.get("hdhive_checkin_auto_login", True)) + self._hdhive_checkin_username = self._clean_text(config.get("hdhive_checkin_username")) + self._hdhive_checkin_password = self._clean_text(config.get("hdhive_checkin_password")) + self._p115_default_path = self._normalize_path(config.get("p115_default_path") or "/待整理") + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(config.get("p115_client_type")) + self._p115_cookie = self._clean_text(config.get("p115_cookie")) + self._p115_prefer_direct = bool(config.get("p115_prefer_direct", True)) + self._mp_download_save_path = self._clean_text(config.get("mp_download_save_path")) + self._assistant_default_pt_min_seeders = max(0, self._safe_int(config.get("assistant_default_pt_min_seeders"), 3)) + self._assistant_default_auto_ingest_enabled = bool(config.get("assistant_default_auto_ingest_enabled", False)) + self._assistant_default_auto_ingest_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_auto_ingest_score_threshold"), 90))) + self._assistant_default_confirm_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_confirm_score_threshold"), 70))) + self._feishu_enabled = bool(config.get("feishu_enabled", False)) + self._feishu_allow_all = bool(config.get("feishu_allow_all", False)) + self._feishu_reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self._feishu_reply_receive_id_type = self._clean_text(config.get("feishu_reply_receive_id_type") or "chat_id") + self._feishu_app_id = self._clean_text(config.get("feishu_app_id")) + self._feishu_app_secret = self._clean_text(config.get("feishu_app_secret")) + self._feishu_verification_token = self._clean_text(config.get("feishu_verification_token")) + self._feishu_allowed_chat_ids = FeishuChannel.split_lines(config.get("feishu_allowed_chat_ids")) + self._feishu_allowed_user_ids = FeishuChannel.split_lines(config.get("feishu_allowed_user_ids")) + self._feishu_command_whitelist = FeishuChannel.merge_command_whitelist( + FeishuChannel.split_commands(config.get("feishu_command_whitelist")) + ) + self._feishu_command_aliases = FeishuChannel.merge_command_aliases( + self._clean_text(config.get("feishu_command_aliases")) + ) + self._feishu_command_mode = self._clean_text(config.get("feishu_command_mode") or "resource_officer") + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + self._restore_persisted_sessions() + self._restore_execution_history() + self._restore_hdhive_checkin_history() + self._restore_workflow_plans() + self._restore_assistant_preferences() + self._agent_tools_reloaded = False + self._ensure_feishu_channel().configure(self._build_config()) + if self._enabled and self._feishu_enabled: + self._feishu_channel.start() + elif self._feishu_channel is not None: + self._feishu_channel.stop() + self._maybe_run_hdhive_checkin_once() + + def get_state(self) -> bool: + if self._enabled: + self._maybe_reload_agent_tools_once() + return self._enabled + + def get_agent_tools(self) -> List[type]: + return [ + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantPlansTool, + AssistantPlansClearTool, + AssistantRecoverTool, + AssistantPulseTool, + AssistantStartupTool, + AssistantMaintainTool, + AssistantToolboxTool, + AssistantRequestTemplatesTool, + AssistantSelfcheckTool, + AssistantReadinessTool, + FeishuChannelHealthTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantRouteTool, + AssistantPickTool, + AssistantWorkflowTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + ShareRouteTool, + P115QRCodeStartTool, + P115QRCodeCheckTool, + P115StatusTool, + P115PendingTool, + P115ResumePendingTool, + P115CancelPendingTool, + ] + + @staticmethod + def _reload_agent_tools() -> None: + if moviepilot_tool_manager is None: + return + try: + moviepilot_tool_manager._load_tools() + except Exception: + return + + def _maybe_reload_agent_tools_once(self) -> None: + if moviepilot_tool_manager is None: + return + now = time.time() + with self.__class__._agent_tools_reload_lock: + if ( + self.__class__._agent_tools_reload_version == self.plugin_version + and now - self.__class__._agent_tools_reload_at < 600 + ): + return + # Mark before reloading, because tool loading can query plugin states recursively. + self.__class__._agent_tools_reload_version = self.plugin_version + self.__class__._agent_tools_reload_at = now + self._reload_agent_tools() + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _parse_optional_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return None + + @classmethod + def _parse_bool_value(cls, value: Any, default: bool = False) -> bool: + parsed = cls._parse_optional_bool(value) + return bool(default) if parsed is None else bool(parsed) + + @staticmethod + def _normalize_path(value: Any) -> str: + return QuarkTransferService.normalize_path(value) + + @staticmethod + def _friendly_hdhive_error(message: str, capability: str) -> str: + text = str(message or "").strip() + lowered = text.lower() + if "premium" in lowered or "仅对 premium 用户开放" in text: + if capability == "checkin": + return "影巢 OpenAPI 签到当前需要 Premium 用户;普通用户可配置网页 Cookie 或账号密码启用网页签到兜底。" + return f"影巢 OpenAPI 的{capability}接口当前需要 Premium 用户。" + return text or f"影巢 {capability} 接口调用失败" + + @staticmethod + def _is_hdhive_premium_limited(message: str) -> bool: + text = str(message or "").strip() + lowered = text.lower() + return "premium" in lowered or "仅对 premium 用户开放" in text + + @staticmethod + def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + @classmethod + def _hdhive_daily_sign_config_paths(cls) -> List[Path]: + return [ + Path("/config/plugins/hdhivedailysign.json"), + Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), + ] + + @classmethod + def _hdhive_daily_sign_user_info_paths(cls) -> List[Path]: + return [ + Path("/config/logs/plugins/hdhivedailysign_user_info.json"), + Path("/Applications/Dockge/moviepilotv2/config/logs/plugins/hdhivedailysign_user_info.json"), + ] + + @classmethod + def _load_hdhive_daily_sign_config(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_config_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _load_hdhive_daily_sign_user_info(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_user_info_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _build_hdhive_account_snapshot(cls, snapshot: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(snapshot, dict) or not snapshot: + return {} + return { + "id": snapshot.get("id"), + "nickname": snapshot.get("nickname"), + "username": snapshot.get("nickname"), + "avatar_url": snapshot.get("avatar_url"), + "created_at": snapshot.get("created_at"), + "is_vip": False, + "source": "hdhivedailysign_snapshot", + "user_meta": { + "points": snapshot.get("points"), + "signin_days_total": snapshot.get("signin_days_total"), + }, + "warnings_nums": snapshot.get("warnings_nums"), + } + + @classmethod + def _extract_hdhive_account_fields(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload if isinstance(payload, dict) else {} + meta = data.get("user_meta") if isinstance(data.get("user_meta"), dict) else {} + return { + "nickname": data.get("nickname") or data.get("username") or "—", + "points": meta.get("points", data.get("points", "—")), + "signin_days_total": meta.get("signin_days_total", data.get("signin_days_total", "—")), + "is_vip": bool(data.get("is_vip")), + } + + def _get_hdhive_fallback_cookie(self) -> str: + own_cookie = self._clean_text(self._hdhive_checkin_cookie) + if own_cookie: + return own_cookie + config = self._load_hdhive_daily_sign_config() + return self._clean_text(config.get("cookie")) + + def _refresh_hdhive_checkin_cookie(self) -> Tuple[bool, str, str]: + if not self._hdhive_checkin_auto_login: + return False, "", "未启用影巢自动登录刷新 Cookie" + service = self._ensure_hdhive_service() + # Playwright sync API cannot run inside MoviePilot's asyncio loop; keep login isolated. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + service.login_for_cookie, + username=self._hdhive_checkin_username, + password=self._hdhive_checkin_password, + ) + try: + login_ok, cookie_string, login_message = future.result(timeout=max(60, self._hdhive_timeout * 4)) + except Exception as exc: + return False, "", f"影巢自动登录超时或异常: {exc}" + if not login_ok or not cookie_string: + return False, "", login_message or "影巢自动登录失败" + self._hdhive_checkin_cookie = cookie_string + try: + self.update_config(self._build_config()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢自动登录已获取 Cookie,但保存配置失败:{exc}") + return True, cookie_string, login_message or "影巢自动登录成功" + + def _run_hdhive_checkin(self, *, is_gambler: Optional[bool] = None, trigger: str = "Agent影视助手") -> Dict[str, Any]: + if not self._hdhive_checkin_enabled: + return self._hdhive_checkin_disabled_response() + service = self._ensure_hdhive_service() + final_gambler_mode = self._hdhive_checkin_gambler_mode if is_gambler is None else bool(is_gambler) + checkin_ok, result, checkin_message = service.perform_checkin( + is_gambler=final_gambler_mode, + trigger=trigger, + ) + if checkin_ok: + final_result = {"success": True, "message": result.get("message") or "success", "data": result} + self._record_hdhive_checkin_history(trigger=trigger, is_gambler=final_gambler_mode, result=final_result) + return final_result + + raw_message = result.get("message") or checkin_message + checkin_status_code = self._safe_int(result.get("status_code"), 0) if isinstance(result, dict) else 0 + should_try_web_fallback = ( + self._is_hdhive_premium_limited(raw_message) + or checkin_status_code in (404, 405) + or "405 not allowed" in self._clean_text(raw_message).lower() + or " None: + if not self._enabled or not self._hdhive_checkin_once: + return + self._hdhive_checkin_once = False + try: + self.update_config(self._build_config({"hdhive_checkin_once": False})) + except Exception as exc: + logger.warning(f"[Agent影视助手] 重置“立即影巢签到”开关失败:{exc}") + + def _run_once() -> None: + try: + result = self._run_hdhive_checkin(trigger="Agent影视助手 插件页立即签到") + status = "成功" if result.get("success") else "失败" + logger.info(f"[Agent影视助手] 插件页立即影巢签到{status}: {result.get('message')}") + except Exception as exc: + logger.error(f"[Agent影视助手] 插件页立即影巢签到异常: {exc}") + + threading.Thread(target=_run_once, name="aro-hdhive-checkin-once", daemon=True).start() + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._hdhive_checkin_enabled or not self._hdhive_checkin_cron: + return [] + if CronTrigger is None: + logger.warning("[Agent影视助手] apscheduler 不可用,无法注册影巢定时签到") + return [] + try: + trigger = CronTrigger.from_crontab(self._hdhive_checkin_cron) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢签到 Cron 配置无效:{self._hdhive_checkin_cron} {exc}") + return [] + return [{ + "id": "agentresourceofficer_hdhive_checkin", + "name": "Agent影视助手影巢签到", + "trigger": trigger, + "func": self._scheduled_hdhive_checkin, + "kwargs": {}, + }] + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "debug": self._debug, + "quark_cookie": self._quark_cookie, + "quark_default_path": self._quark_default_path, + "quark_timeout": self._quark_timeout, + "quark_auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + "pansou_base_url": self._pansou_base_url, + "pansou_timeout": self._pansou_timeout, + "hdhive_api_key": self._hdhive_api_key, + "hdhive_base_url": self._hdhive_base_url, + "hdhive_timeout": self._hdhive_timeout, + "hdhive_default_path": self._hdhive_default_path, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "hdhive_checkin_once": self._hdhive_checkin_once, + "hdhive_checkin_cron": self._hdhive_checkin_cron, + "hdhive_checkin_cookie": self._hdhive_checkin_cookie, + "hdhive_checkin_auto_login": self._hdhive_checkin_auto_login, + "hdhive_checkin_username": self._hdhive_checkin_username, + "hdhive_checkin_password": self._hdhive_checkin_password, + "p115_default_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie": self._p115_cookie, + "p115_prefer_direct": self._p115_prefer_direct, + "mp_download_save_path": self._mp_download_save_path, + "assistant_default_pt_min_seeders": self._assistant_default_pt_min_seeders, + "assistant_default_auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "assistant_default_auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "assistant_default_confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "feishu_enabled": self._feishu_enabled, + "feishu_allow_all": self._feishu_allow_all, + "feishu_reply_enabled": self._feishu_reply_enabled, + "feishu_reply_receive_id_type": self._feishu_reply_receive_id_type, + "feishu_app_id": self._feishu_app_id, + "feishu_app_secret": self._feishu_app_secret, + "feishu_verification_token": self._feishu_verification_token, + "feishu_allowed_chat_ids": "\n".join(self._feishu_allowed_chat_ids), + "feishu_allowed_user_ids": "\n".join(self._feishu_allowed_user_ids), + "feishu_command_whitelist": ",".join(self._feishu_command_whitelist), + "feishu_command_aliases": self._feishu_command_aliases, + "feishu_command_mode": self._feishu_command_mode, + } + if overrides: + config.update(overrides) + return config + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + async def _request_payload(self, request: Request) -> Dict[str, Any]: + if str(getattr(request, "method", "") or "").upper() == "GET": + return {} + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _ensure_quark_service(self) -> QuarkTransferService: + if self._quark_service is None: + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + else: + self._quark_service.set_cookie(self._quark_cookie) + self._quark_service.timeout = max(10, self._safe_int(self._quark_timeout, 30)) + self._quark_service.default_target_path = self._quark_default_path + self._quark_service.auto_import_cookiecloud = self._quark_auto_import_cookiecloud + self._quark_service.cookie_refresh_callback = self._refresh_quark_cookie_from_cookiecloud + return self._quark_service + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _refresh_quark_cookie_from_cookiecloud(self) -> str: + cookie, _message = self._load_cookiecloud_quark_cookie() + if cookie: + self._quark_cookie = cookie + return cookie + + def _ensure_hdhive_service(self) -> HDHiveOpenApiService: + if self._hdhive_service is None: + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + else: + self._hdhive_service.api_key = self._hdhive_api_key + self._hdhive_service.base_url = self._hdhive_base_url + self._hdhive_service.timeout = self._hdhive_timeout + return self._hdhive_service + + def _ensure_p115_service(self) -> P115TransferService: + if self._p115_service is None: + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + else: + self._p115_service.default_target_path = self._p115_default_path + self._p115_service.set_cookie(self._p115_cookie) + self._p115_service.prefer_direct = self._p115_prefer_direct + return self._p115_service + + def _apply_runtime_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = self._build_config(overrides) + self.update_config(config) + self.init_plugin(config) + return config + + @staticmethod + def _p115_client_type_items() -> List[Dict[str, str]]: + return [ + {"title": "支付宝小程序(推荐)", "value": "alipaymini"}, + {"title": "115 Android", "value": "115android"}, + {"title": "115 iOS", "value": "115ios"}, + {"title": "115 iPad", "value": "115ipad"}, + {"title": "115 TV", "value": "tv"}, + {"title": "微信小程序", "value": "wechatmini"}, + {"title": "Web", "value": "web"}, + ] + + @classmethod + def _p115_client_type_title(cls, value: str) -> str: + final_value = P115TransferService.normalize_qrcode_client_type(value) + for item in cls._p115_client_type_items(): + if item.get("value") == final_value: + return str(item.get("title") or final_value) + return final_value + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + if self._feishu_channel is not None: + self._feishu_channel.stop() + return + + def _ensure_feishu_channel(self) -> FeishuChannel: + if self._feishu_channel is None: + self._feishu_channel = FeishuChannel(self) + return self._feishu_channel + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/quark/health", + "endpoint": self.api_quark_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的夸克配置", + }, + { + "path": "/quark/transfer", + "endpoint": self.api_quark_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行夸克分享转存", + }, + { + "path": "/hdhive/health", + "endpoint": self.api_hdhive_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的影巢配置", + }, + { + "path": "/hdhive/account", + "endpoint": self.api_hdhive_account, + "methods": ["GET"], + "summary": "获取影巢当前账号信息", + }, + { + "path": "/hdhive/checkin", + "endpoint": self.api_hdhive_checkin, + "methods": ["POST"], + "summary": "执行影巢普通签到或赌狗签到", + }, + { + "path": "/hdhive/checkin/history", + "endpoint": self.api_hdhive_checkin_history, + "methods": ["GET"], + "summary": "查看插件保存的影巢签到日志", + }, + { + "path": "/hdhive/quota", + "endpoint": self.api_hdhive_quota, + "methods": ["GET"], + "summary": "获取影巢当前配额信息", + }, + { + "path": "/hdhive/usage_today", + "endpoint": self.api_hdhive_usage_today, + "methods": ["GET"], + "summary": "获取影巢今日用量统计", + }, + { + "path": "/hdhive/weekly_free_quota", + "endpoint": self.api_hdhive_weekly_free_quota, + "methods": ["GET"], + "summary": "获取影巢每周免费解锁额度", + }, + { + "path": "/hdhive/search", + "endpoint": self.api_hdhive_search, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源搜索", + }, + { + "path": "/hdhive/search_by_keyword", + "endpoint": self.api_hdhive_search_by_keyword, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢关键词候选搜索", + }, + { + "path": "/hdhive/unlock", + "endpoint": self.api_hdhive_unlock, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源解锁", + }, + { + "path": "/hdhive/unlock_and_route", + "endpoint": self.api_hdhive_unlock_and_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 解锁影巢资源并尝试自动路由到对应网盘执行层", + }, + { + "path": "/p115/health", + "endpoint": self.api_p115_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 转存依赖状态", + }, + { + "path": "/p115/qrcode", + "endpoint": self.api_p115_qrcode, + "methods": ["GET"], + "summary": "获取 Agent影视助手 的 115 扫码登录二维码", + }, + { + "path": "/p115/qrcode/check", + "endpoint": self.api_p115_qrcode_check, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 扫码登录状态", + }, + { + "path": "/p115/transfer", + "endpoint": self.api_p115_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行 115 分享转存", + }, + { + "path": "/p115/pending", + "endpoint": self.api_p115_pending, + "methods": ["GET", "POST"], + "summary": "查看指定会话中待继续的 115 任务", + }, + { + "path": "/p115/pending/resume", + "endpoint": self.api_p115_pending_resume, + "methods": ["POST"], + "summary": "继续执行指定会话中待处理的 115 任务", + }, + { + "path": "/p115/pending/cancel", + "endpoint": self.api_p115_pending_cancel, + "methods": ["POST"], + "summary": "取消指定会话中待处理的 115 任务", + }, + { + "path": "/share/route", + "endpoint": self.api_share_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 自动识别 115 / 夸克分享链接并执行对应转存", + }, + { + "path": "/feishu/health", + "endpoint": self.api_feishu_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 内置飞书入口状态", + }, + { + "path": "/assistant/route", + "endpoint": self.api_assistant_route, + "methods": ["POST"], + "summary": "统一智能入口:盘搜 / 影巢 / 直链分享", + }, + { + "path": "/assistant/pick", + "endpoint": self.api_assistant_pick, + "methods": ["POST"], + "summary": "统一智能入口的按编号继续执行", + }, + { + "path": "/assistant/capabilities", + "endpoint": self.api_assistant_capabilities, + "methods": ["GET"], + "summary": "查看统一智能入口支持的结构化参数、默认值与推荐调用方式", + }, + { + "path": "/assistant/readiness", + "endpoint": self.api_assistant_readiness, + "methods": ["GET"], + "summary": "检查 Agent影视助手 是否已准备好给外部智能体调用", + }, + { + "path": "/assistant/pulse", + "endpoint": self.api_assistant_pulse, + "methods": ["GET"], + "summary": "轻量启动探针:返回版本、关键服务状态、警告和最佳恢复建议", + }, + { + "path": "/assistant/startup", + "endpoint": self.api_assistant_startup, + "methods": ["GET"], + "summary": "启动聚合包:一次返回 pulse、自检、核心工具、端点、默认目录和恢复建议", + }, + { + "path": "/assistant/maintain", + "endpoint": self.api_assistant_maintain, + "methods": ["GET", "POST"], + "summary": "低风险维护:查看或执行过期会话、已执行计划清理", + }, + { + "path": "/assistant/toolbox", + "endpoint": self.api_assistant_toolbox, + "methods": ["GET"], + "summary": "轻量工具清单:返回推荐工具、端点、工作流、动作名、默认目录和命令示例", + }, + { + "path": "/assistant/request_templates", + "endpoint": self.api_assistant_request_templates, + "methods": ["GET", "POST"], + "summary": "轻量请求模板:返回外部智能体常用 assistant 请求模板", + }, + { + "path": "/assistant/selfcheck", + "endpoint": self.api_assistant_selfcheck, + "methods": ["GET"], + "summary": "轻量自检:确认 compact 协议、模板默认 compact 和布尔字符串解析是否正常", + }, + { + "path": "/assistant/history", + "endpoint": self.api_assistant_history, + "methods": ["GET"], + "summary": "查看最近执行历史,便于外部智能体判断上一步是否完成", + }, + { + "path": "/assistant/action", + "endpoint": self.api_assistant_action, + "methods": ["POST"], + "summary": "直接执行统一智能入口返回的动作模板名,适合外部智能体无映射继续执行", + }, + { + "path": "/assistant/actions", + "endpoint": self.api_assistant_actions, + "methods": ["POST"], + "summary": "批量执行多个动作模板,适合外部智能体一次请求串起多步工作流,减少往返", + }, + { + "path": "/assistant/workflow", + "endpoint": self.api_assistant_workflow, + "methods": ["GET", "POST"], + "summary": "运行预设工作流,适合外部智能体用更短参数完成常见资源任务", + }, + { + "path": "/assistant/preferences", + "endpoint": self.api_assistant_preferences, + "methods": ["GET", "POST", "DELETE"], + "summary": "读取、保存或重置智能体片源偏好画像,用于云盘和 PT 分源评分", + }, + { + "path": "/assistant/plan/execute", + "endpoint": self.api_assistant_plan_execute, + "methods": ["POST"], + "summary": "执行 dry_run 保存的工作流计划,避免外部智能体重复携带大 JSON", + }, + { + "path": "/assistant/plans", + "endpoint": self.api_assistant_plans, + "methods": ["GET"], + "summary": "查看 dry_run 保存的工作流计划,便于断线恢复和选择 plan_id", + }, + { + "path": "/assistant/plans/clear", + "endpoint": self.api_assistant_plans_clear, + "methods": ["POST"], + "summary": "清理 dry_run 保存的工作流计划", + }, + { + "path": "/assistant/recover", + "endpoint": self.api_assistant_recover, + "methods": ["GET", "POST"], + "summary": "查看或直接执行当前最推荐的恢复动作,给外部智能体提供单入口续跑能力", + }, + { + "path": "/assistant/session", + "endpoint": self.api_assistant_session_state, + "methods": ["GET", "POST"], + "summary": "查看统一智能入口当前会话状态与建议动作", + }, + { + "path": "/assistant/session/clear", + "endpoint": self.api_assistant_session_clear, + "methods": ["POST"], + "summary": "清理统一智能入口当前会话缓存", + }, + { + "path": "/assistant/sessions", + "endpoint": self.api_assistant_sessions, + "methods": ["GET"], + "summary": "列出当前活跃的统一智能入口会话,便于外部智能体恢复和接续", + }, + { + "path": "/assistant/sessions/clear", + "endpoint": self.api_assistant_sessions_clear, + "methods": ["POST"], + "summary": "按 session_id、类型或过滤条件批量清理统一智能入口会话", + }, + { + "path": "/session/hdhive/search", + "endpoint": self.api_session_hdhive_search, + "methods": ["POST"], + "summary": "创建影巢搜索会话并返回候选影片列表", + }, + { + "path": "/session/hdhive/pick", + "endpoint": self.api_session_hdhive_pick, + "methods": ["POST"], + "summary": "按编号继续影巢会话:候选选片或资源解锁落盘", + }, + ] + + def _build_hdhive_page_summary(self) -> str: + if not self._enabled: + return "插件未启用" + if not self._hdhive_api_key: + return "影巢 API Key 未配置" + service = self._ensure_hdhive_service() + account_ok, account_result, account_message = service.fetch_me() + quota_ok, quota_result, _quota_message = service.fetch_quota() + usage_ok, usage_result, _usage_message = service.fetch_usage_today() + + account = account_result.get("data") or {} + account_source = "hdhive_openapi" + if not account_ok and self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + account = fallback_account + account_ok = True + account_source = "hdhivedailysign_snapshot" + account_fields = self._extract_hdhive_account_fields(account) + quota = quota_result.get("data") or {} + usage = usage_result.get("data") or {} + + return ( + f"影巢账号:{'可用' if account_ok else '异常'}" + f"\n资源入口:{'开启' if self._hdhive_resource_enabled else '关闭'}" + f"\n单资源积分上限:{self._hdhive_max_unlock_points if self._hdhive_max_unlock_points > 0 else '不限制'}" + f"\n签到入口:{'开启' if self._hdhive_checkin_enabled else '关闭'}" + f"\n昵称:{account_fields.get('nickname', '—')}" + f"\n积分:{account_fields.get('points', '—')}" + f"\nVIP:{'是' if account_fields.get('is_vip') else '否'}" + f"\n累计签到:{account_fields.get('signin_days_total', '—')}" + f"\n今日剩余配额:{quota.get('endpoint_remaining', '—')}" + f"\n今日总调用:{usage.get('total_calls', '—')}" + f"\n账号来源:{'网页快照' if account_source == 'hdhivedailysign_snapshot' else 'OpenAPI'}" + ) + + def get_page(self) -> List[dict]: + quark_ready = "已配置" if self._quark_cookie else "未配置" + hdhive_ready = "已配置" if self._hdhive_api_key else "未配置" + p115_health_ok, p115_health, _p115_health_message = self._ensure_p115_service().health() + cookie_state = p115_health.get("cookie_state") or {} + if cookie_state.get("valid"): + p115_ready = "已配置扫码会话" + elif cookie_state.get("configured"): + p115_ready = "已配置但不是扫码会话" + else: + p115_ready = "复用 115 助手客户端" + hdhive_summary = self._build_hdhive_page_summary() + feishu_health = self._ensure_feishu_channel().health() + feishu_state = "已启用" if feishu_health.get("enabled") else "未启用" + feishu_running = "运行中" if feishu_health.get("running") else "未运行" + hdhive_lines = [line.strip() for line in str(hdhive_summary or "").splitlines() if line.strip()] + hdhive_compact_lines = hdhive_lines[:4] + if len(hdhive_lines) >= 6: + hdhive_compact_lines.append(f"{hdhive_lines[4]} / {hdhive_lines[5]}") + p115_cookie_message = cookie_state.get("message") or "当前会话可直接用于 115 直转" + + def text_line(text: str, css_class: str = "text-body-2 py-1") -> Dict[str, Any]: + return { + "component": "div", + "props": {"class": css_class}, + "text": text, + } + + def status_card(title: str, subtitle: str, lines: List[str], color: str = "primary") -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"variant": "tonal", "color": color, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1"}, + "text": title, + }, + { + "component": "VCardSubtitle", + "props": {"class": "text-body-2"}, + "text": subtitle, + }, + { + "component": "VCardText", + "props": {"class": "py-2"}, + "content": [text_line(line, "text-body-2 py-0") for line in lines], + }, + ], + } + + def section_card(title: str, lines: List[str], compact: bool = False) -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"flat": True, "border": True, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1" if compact else "text-subtitle-1 font-weight-bold"}, + "text": title, + }, + { + "component": "VCardText", + "props": {"class": "py-2"} if compact else {}, + "content": [text_line(line, "text-body-2 py-0") for line in lines] if compact else [text_line(line) for line in lines], + }, + ], + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "影巢", + hdhive_ready, + [ + f"默认目录:{self._hdhive_default_path}", + "能力:搜索 / 解锁 / 签到", + "API:/hdhive/account /checkin /quota", + ], + "success" if self._hdhive_api_key else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "115", + "可用" if p115_health_ok else "待修复", + [ + f"默认目录:{self._p115_default_path}", + f"登录方式:{p115_ready}", + f"扫码客户端:{self._p115_client_type_title(self._p115_client_type)}", + ], + "success" if p115_health_ok else "error", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "夸克", + quark_ready, + [ + f"默认目录:{self._quark_default_path}", + "能力:分享链接转存", + "入口:通用分享路由", + ], + "success" if self._quark_cookie else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "飞书", + f"{feishu_state},长连接:{feishu_running}", + [ + "模式:内置 Channel", + "健康检查:/feishu/health", + "建议:只保留一个飞书入口监听", + ], + "success" if feishu_health.get("running") else "secondary", + ) + ], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mt-3"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "智能体入口", + [ + "统一路由:/assistant/route", + "继续选择:/assistant/pick", + "工作流:/assistant/workflow", + "计划执行:/assistant/plan/execute", + "Agent Tool:搜索/选择、115 扫码、待任务查看/继续/取消、通用分享路由", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "账号与签到", + hdhive_compact_lines + + [ + f"115 Cookie:{p115_cookie_message}", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "盘搜服务", + [ + f"API 地址:{self._pansou_base_url}", + f"请求超时:{self._pansou_timeout} 秒", + "用法:发送“盘搜搜索 片名”“ps片名”或“1片名”。", + "说明:插件只负责调用 PanSou API,本机需要先运行 PanSou 服务。", + ], + compact=True, + ) + ], + } + ], + }, + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mt-4 mb-1", + "title": "统一资源入口", + }, + "content": [ + text_line( + "Agent影视助手支持三种接入模式:飞书直接发命令、外部智能体调用 skill/helper、MP 内置智能体调用 Agent Tool。", + "text-body-2 mb-3", + ), + text_line( + "不接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2 mb-3", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "如果你只想直接用插件或飞书入口,不需要额外安装 skill。\n" + "直接使用这些命令即可:搜索 片名 / 云盘搜索 片名 / 转存 片名 / 下载 片名 / 更新检查 片名。" + ), + }, + text_line( + "接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "插件页不再直接放大段接入提示词,避免复制到旧配置。\n" + "请按快速开始主页和外部智能体接入文档配置:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins\n\n" + "外部智能体接入文档:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md\n\n" + "如果客户端支持 MoviePilot 官方 MCP,也请按文档里的分工接入;资源流仍优先使用 agent-resource-officer skill/helper。\n" + "长会话跑偏时,可以直接对智能体说:校准影视技能。" + ), + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "插件把资源搜索、链接转存、扫码登录、飞书消息和智能体调用集中到一个入口。首次使用先配置默认目录、影巢 OpenAPI、夸克会话,以及需要的飞书机器人信息。调试模式仅排查问题时打开。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "debug", + "label": "调试模式", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下面这组是智能体默认评分策略,只影响还没有保存个人偏好的新会话。高分不代表一定执行;遇到影巢高积分、PT 低做种这类硬风险时,插件仍会拦截。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "mp_download_save_path", + "label": "PT 下载保存路径(可选)", + "placeholder": "MP 和 qB 在同一台机器可留空;不在同一台机器时填 qB 默认下载路径,如 /media/downloads/qb", + "hint": "只影响“下载 / MP搜索 / PT搜索”。MP 与 qB 分离时,填 qB WebUI 里的默认保存路径;同机一般不用填。", + "persistentHint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_pt_min_seeders", + "label": "PT 最低做种数", + "type": "number", + "placeholder": "3", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_confirm_score_threshold", + "label": "建议确认分数线", + "type": "number", + "placeholder": "70", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_auto_ingest_score_threshold", + "label": "自动入库分数线", + "type": "number", + "placeholder": "90", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "assistant_default_auto_ingest_enabled", + "label": "默认允许高分自动入库", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢用于资源搜索、解锁、配额查询和签到。资源入口关闭后,智能体和飞书都不会执行影巢搜索、解锁或转存;单资源积分上限默认 20 分,超过就拦截提醒,填 0 表示不限制。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_api_key", + "label": "影巢 API Key", + "rows": 2, + "placeholder": "填写影巢 OpenAPI 的 API Key", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_resource_enabled", + "label": "启用影巢资源搜索/解锁", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_max_unlock_points", + "label": "单资源积分上限", + "type": "number", + "placeholder": "20;填 0 不限制", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": "建议保留积分上限,避免智能体一步到位时误选高积分资源。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_base_url", + "label": "影巢 Base URL", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_timeout", + "label": "影巢超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_default_path", + "label": "影巢默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_candidate_page_size", + "label": "候选页大小", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢签到支持 OpenAPI 与网页兜底两种方式。OpenAPI 签到需要 Premium;普通用户建议优先使用本机“影巢Cookie导出.command”自动写回完整网页登录 Cookie。手工复制 Cookie 容易漏字段,导致看起来已填写但签到仍失败。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_enabled", + "label": "启用影巢签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_gambler_mode", + "label": "默认赌狗签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_once", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_cron", + "label": "影巢签到 Cron", + "placeholder": "0 8 * * *", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_checkin_cookie", + "label": "影巢网页 Cookie(非 Premium 兜底)", + "rows": 3, + "placeholder": "不建议手工填写;优先在 Edge 登录 hdhive.com 后运行“影巢Cookie导出.command”自动写回", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_auto_login", + "label": "自动刷新 Cookie", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_username", + "label": "影巢用户名/邮箱", + "placeholder": "用于 Cookie 失效时自动登录", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_password", + "label": "影巢密码", + "type": "password", + "placeholder": "仅保存在 MoviePilot 本机配置中", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "盘搜用于聚合公开网盘分享结果。请填写 MoviePilot 容器视角下可访问的 API 地址,不要按外部智能体机器的视角填写。普通“搜索/找片”默认先盘搜;“云盘搜索”固定比较盘搜 + 影巢。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_base_url", + "label": "盘搜 API 地址", + "placeholder": "http://host.docker.internal:805", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_timeout", + "label": "盘搜超时(秒)", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "夸克用于转存 pan.quark.cn 分享链接。优先使用 CookieCloud 或有效网页登录 Cookie;只有明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”时,才建议走 Cookie 修复。分享受限、41031、分享者封禁不属于 Cookie 失效。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "quark_cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "可手工填写,但更推荐 CookieCloud 或本机夸克Cookie导出工具自动写回", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_default_path", + "label": "夸克默认目录", + "placeholder": "/飞书", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_timeout", + "label": "夸克超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "quark_auto_import_cookiecloud", + "label": "允许自动刷新 Cookie", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "115 建议走扫码会话,不建议填网页版 Cookie。插件支持 /p115/qrcode 和 /p115/qrcode/check 两步扫码登录;手填 Cookie 仅作为高级兜底。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "p115_default_path", + "label": "115 默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "p115_client_type", + "label": "115 扫码客户端类型", + "items": self._p115_client_type_items(), + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "p115_prefer_direct", + "label": "115 优先直转", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "p115_cookie", + "label": "115 扫码会话 Cookie(高级,可选)", + "rows": 3, + "placeholder": "仅支持 UID/CID/SEID/KID 这类扫码客户端 Cookie;普通网页版 Cookie 不建议粘贴到这里", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "飞书入口默认关闭。开启后可以在飞书里发送搜索、云盘搜索、转存、夸克转存、下载、更新检查、115 登录和影巢签到等命令;同一个飞书机器人建议只配置一个接收入口。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_enabled", + "label": "启用内置飞书入口", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_allow_all", + "label": "允许所有飞书会话", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_reply_enabled", + "label": "发送飞书回复", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_id", + "label": "飞书 App ID", + "placeholder": "cli_xxxxxxxxx", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_secret", + "label": "飞书 App Secret", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_verification_token", + "label": "Verification Token", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "feishu_reply_receive_id_type", + "label": "回复 ID 类型", + "items": [ + {"title": "群聊 chat_id", "value": "chat_id"}, + {"title": "用户 open_id", "value": "open_id"}, + {"title": "用户 union_id", "value": "union_id"}, + {"title": "用户 user_id", "value": "user_id"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_chat_ids", + "label": "允许的群聊 Chat ID", + "rows": 3, + "placeholder": "一个一行;allow_all 关闭时生效", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_user_ids", + "label": "允许的用户 Open ID", + "rows": 3, + "placeholder": "一个一行", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_whitelist", + "label": "飞书命令白名单", + "rows": 3, + "placeholder": "逗号或换行分隔;留空时会自动合并当前主线命令。旧 STRM/刮削命令不再默认暴露,如需兼容旧环境可手动加入。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_aliases", + "label": "飞书命令别名", + "rows": 5, + "placeholder": FeishuChannel.default_command_aliases(), + "hint": "默认别名已统一走 Agent影视助手 route/pick:转存默认 115,夸克转存需显式发送;旧 STRM/刮削别名如需保留请手动添加。", + }, + } + ], + }, + ], + }, + ], + } + ] + return form, self._build_config() + + async def api_feishu_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + channel = self._ensure_feishu_channel() + return { + "success": True, + "message": "Agent影视助手 内置飞书入口状态", + "data": { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + }, + } + + async def api_quark_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_quark_service() + cookie_ok, cookie_message = service.check_cookie() + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "quark_cookie_configured": bool(self._quark_cookie), + "quark_cookie_valid": cookie_ok, + "default_target_path": self._quark_default_path, + "message": "" if cookie_ok else cookie_message, + }, + } + + async def api_quark_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_text = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_quark_service() + transfer_ok, result, transfer_message = service.transfer_share( + share_text, + access_code=access_code, + target_path=target_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_hdhive_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_hdhive_service() + ping_ok, result, ping_message, _status_code = service.request("GET", "/api/open/ping") + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "hdhive_api_key_configured": bool(self._hdhive_api_key), + "hdhive_ping_ok": ping_ok, + "base_url": self._hdhive_base_url, + "default_target_path": self._hdhive_default_path, + "resource_enabled": self._hdhive_resource_enabled, + "max_unlock_points": self._hdhive_max_unlock_points, + "checkin_enabled": self._hdhive_checkin_enabled, + "checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "checkin_cron": self._hdhive_checkin_cron, + "checkin_web_cookie_configured": bool(self._hdhive_checkin_cookie), + "checkin_auto_login_enabled": self._hdhive_checkin_auto_login, + "checkin_username_configured": bool(self._hdhive_checkin_username), + "message": "" if ping_ok else ping_message, + "raw": result, + }, + } + + async def api_hdhive_account(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + account_ok, result, account_message = service.fetch_me() + if not account_ok: + if self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + return { + "success": True, + "message": "当前返回的是网页用户快照", + "data": fallback_account, + } + return {"success": False, "message": self._friendly_hdhive_error(account_message, "账号"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_checkin(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + is_gambler = self._parse_bool_value(body.get("is_gambler"), self._hdhive_checkin_gambler_mode) + return self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 API") + + async def api_hdhive_checkin_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._hdhive_checkin_history_public_data(limit=limit) + return { + "success": True, + "message": self._format_hdhive_checkin_history_text(limit=limit), + "data": data, + } + + async def api_hdhive_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + quota_ok, result, quota_message = service.fetch_quota() + if not quota_ok: + return {"success": False, "message": self._friendly_hdhive_error(quota_message, "配额"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_usage_today(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + usage_ok, result, usage_message = service.fetch_usage_today() + if not usage_ok: + return {"success": False, "message": self._friendly_hdhive_error(usage_message, "今日用量"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_weekly_free_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + weekly_ok, result, weekly_message = service.fetch_weekly_free_quota() + if not weekly_ok: + return {"success": False, "message": self._friendly_hdhive_error(weekly_message, "每周免费额度"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + media_type = self._clean_text(body.get("media_type") or body.get("type") or "movie").lower() + tmdb_id = self._clean_text(body.get("tmdb_id")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = service.search_resources(media_type=media_type, tmdb_id=tmdb_id) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_search_by_keyword(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + candidate_limit = self._safe_int(body.get("candidate_limit"), self._hdhive_candidate_page_size) + result_limit = self._safe_int(body.get("limit"), 12) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + result_limit=result_limit, + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_unlock(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(body) + if not points_ok: + return {"success": False, "message": points_message, "data": {"resource_guard": points_data}} + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return {"success": False, "message": unlock_message, "data": result} + return {"success": True, "message": "success", "data": result} + + @staticmethod + def _new_session_id(prefix: str = "aro") -> str: + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + def _save_session(self, session_id: str, payload: Dict[str, Any]) -> None: + payload = dict(payload) + payload["updated_at"] = int(time.time()) + with self._session_lock: + self._session_cache[session_id] = payload + self._persist_relevant_sessions() + + def _load_session(self, session_id: str) -> Optional[Dict[str, Any]]: + with self._session_lock: + session = self._session_cache.get(session_id) + if not session: + return None + return dict(session) + + def _persist_relevant_sessions(self) -> None: + try: + data: Dict[str, Dict[str, Any]] = {} + with self._session_lock: + for session_id, payload in (self._session_cache or {}).items(): + session = dict(payload or {}) + if self._is_session_expired(session): + continue + if str(session_id).startswith("assistant::") or session.get("pending_p115") or str(session.get("kind") or "").strip() == "assistant_p115_login": + data[session_id] = session + self.save_data(key=self._session_store_key, value=data) + except Exception: + pass + + def _restore_persisted_sessions(self) -> None: + try: + restored = self.get_data(self._session_store_key) or {} + if isinstance(restored, dict): + with self._session_lock: + for session_id, payload in restored.items(): + if isinstance(payload, dict) and not self._is_session_expired(payload): + self._session_cache[str(session_id)] = dict(payload) + except Exception: + pass + + def _persist_execution_history(self) -> None: + try: + history = list(self._execution_history or [])[-self._execution_history_limit:] + self._execution_history = history + self.save_data(key=self._execution_history_store_key, value=history) + except Exception: + pass + + def _restore_execution_history(self) -> None: + try: + restored = self.get_data(self._execution_history_store_key) or [] + if isinstance(restored, list): + self._execution_history = [ + dict(item) + for item in restored[-self._execution_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._execution_history = [] + + def _persist_hdhive_checkin_history(self) -> None: + try: + history = list(self._hdhive_checkin_history or [])[-self._hdhive_checkin_history_limit:] + self._hdhive_checkin_history = history + self.save_data(key=self._hdhive_checkin_history_store_key, value=history) + except Exception: + pass + + def _restore_hdhive_checkin_history(self) -> None: + try: + restored = self.get_data(self._hdhive_checkin_history_store_key) or [] + if isinstance(restored, list): + self._hdhive_checkin_history = [ + dict(item) + for item in restored[-self._hdhive_checkin_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._hdhive_checkin_history = [] + + def _record_hdhive_checkin_history( + self, + *, + trigger: str, + is_gambler: bool, + result: Dict[str, Any], + ) -> None: + timestamp = int(time.time()) + payload = dict(result or {}) + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + entry = { + "id": self._new_session_id("hdhive-sign"), + "time": timestamp, + "time_text": self._format_unix_time(timestamp), + "trigger": self._clean_text(trigger), + "mode": "赌狗签到" if is_gambler else "普通签到", + "is_gambler": bool(is_gambler), + "success": bool(payload.get("success")), + "message_head": self._assistant_result_message_head(payload.get("message")), + "status": self._clean_text(data.get("status")) or ("成功" if payload.get("success") else "失败"), + "source": self._clean_text(data.get("source")), + "status_code": data.get("status_code"), + "login_retry": bool(((data.get("login") or {}) if isinstance(data.get("login"), dict) else {}).get("ok")), + "web_fallback": bool(data.get("web_fallback")) if isinstance(data, dict) else False, + } + self._hdhive_checkin_history.append(entry) + self._hdhive_checkin_history = self._hdhive_checkin_history[-self._hdhive_checkin_history_limit:] + self._persist_hdhive_checkin_history() + + def _hdhive_checkin_history_public_data(self, *, limit: int = 20) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), self._hdhive_checkin_history_limit) + items = list(reversed(self._hdhive_checkin_history or []))[:max_limit] + return { + "total": len(self._hdhive_checkin_history or []), + "limit": max_limit, + "items": items, + } + + def _format_hdhive_checkin_history_text(self, *, limit: int = 10) -> str: + data = self._hdhive_checkin_history_public_data(limit=limit) + items = data.get("items") or [] + if not items: + return "暂无影巢签到日志。" + lines = [f"影巢签到日志:最近 {len(items)} 条"] + for idx, item in enumerate(items, start=1): + ok_text = "成功" if item.get("success") else "失败" + parts = [ + f"{idx}. {item.get('time_text') or ''}", + f"{item.get('mode') or ''}", + ok_text, + ] + if item.get("trigger"): + parts.append(f"来源:{item.get('trigger')}") + if item.get("login_retry"): + parts.append("已自动刷新Cookie") + message = self._clean_text(item.get("message_head")) + if message: + parts.append(message) + lines.append(" | ".join(part for part in parts if part)) + return "\n".join(lines) + + def _hdhive_resource_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢资源入口已关闭:当前不会执行影巢搜索、解锁或转存。可在插件设置中开启“影巢资源搜索/解锁”。", + "data": { + "provider": "hdhive", + "resource_enabled": False, + "error_code": "hdhive_resource_disabled", + }, + } + + def _ensure_hdhive_resource_enabled(self) -> Tuple[bool, Dict[str, Any]]: + if self._hdhive_resource_enabled: + return True, {} + return False, self._hdhive_resource_disabled_response() + + def _hdhive_checkin_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢签到入口已关闭:如需执行签到,请先在插件设置中开启“影巢签到”。", + "data": { + "provider": "hdhive", + "checkin_enabled": False, + "error_code": "hdhive_checkin_disabled", + }, + } + + @staticmethod + def _resource_has_free_marker(item: Optional[Dict[str, Any]]) -> bool: + if not isinstance(item, dict) or not item: + return False + for key in ["unlock_points", "cost", "points", "price", "point_text"]: + text = str(item.get(key) or "").strip().lower() + if text in {"0", "free", "免费", "0分"}: + return True + blob = " ".join( + str(item.get(key) or "") + for key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "note", + "source", + "source_name", + ] + ).lower() + return "免费" in blob or " free " in f" {blob} " + + @staticmethod + def _resource_points_value(item: Optional[Dict[str, Any]]) -> Optional[int]: + if not item: + return None + raw = item.get("unlock_points") + if raw is None: + raw = item.get("cost") + if raw is None: + raw = item.get("points") + text = str(raw or "").strip() + if not text: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + if text.lower() == "free" or text == "免费": + return 0 + match = re.search(r"-?\d+", text) + if not match: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + try: + return int(match.group(0)) + except Exception: + return None + + def _check_hdhive_unlock_points_limit(self, resource: Optional[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]: + limit = max(0, self._safe_int(self._hdhive_max_unlock_points, 20)) + if limit <= 0: + return True, "", {"limit": limit, "points": None, "limited": False} + points = self._resource_points_value(resource) + title = self._clean_text((resource or {}).get("title") or (resource or {}).get("matched_title") or "该资源") + if points is None: + return True, "", {"limit": limit, "points": None, "limited": False, "reason": "unknown_points", "title": title} + if points > limit: + return False, ( + f"已阻止影巢解锁:{title} 需要 {points} 分,超过当前单资源积分上限 {limit} 分。" + "如确认要解锁,请提高上限或临时设为 0。" + ), {"limit": limit, "points": points, "limited": True, "reason": "points_over_limit"} + return True, "", {"limit": limit, "points": points, "limited": False} + + def _default_assistant_preferences(self) -> Dict[str, Any]: + return { + "schema_version": "preferences.v1", + "initialized": False, + "prefer_resolution": "4K", + "prefer_dolby_vision": True, + "prefer_hdr": True, + "prefer_chinese_subtitle": True, + "prefer_complete_series": True, + "prefer_cloud_provider": "", + "enable_pansou": True, + "enable_hdhive": True, + "enable_mp_pt": True, + "has_quark": True, + "has_115": True, + "cloud_default_path": self._hdhive_default_path, + "quark_default_path": self._quark_default_path, + "p115_default_path": self._p115_default_path, + "pt_require_free": False, + "pt_min_seeders": self._assistant_default_pt_min_seeders, + "pt_prefer_free": True, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "updated_at": 0, + } + + def _normalize_preference_key(self, session: Any = None, user_key: Any = None) -> str: + key = self._clean_text(user_key) + if key: + return key + session_name = self._clean_text(session) or "default" + return f"session:{session_name}" + + def _normalize_assistant_preferences(self, value: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + defaults = self._default_assistant_preferences() + payload = dict(value or {}) + if "resolution_priority" in payload and "prefer_resolution" not in payload: + choices = payload.get("resolution_priority") + if isinstance(choices, (list, tuple)) and choices: + payload["prefer_resolution"] = choices[0] + else: + payload["prefer_resolution"] = choices + if "subtitle_priority" in payload and "prefer_chinese_subtitle" not in payload: + subtitle_text = " ".join(str(item) for item in payload.get("subtitle_priority") or []) if isinstance(payload.get("subtitle_priority"), (list, tuple)) else str(payload.get("subtitle_priority") or "") + payload["prefer_chinese_subtitle"] = bool(re.search(r"中文|中字|简中|繁中|双语|chinese", subtitle_text, flags=re.IGNORECASE)) + if "pt_free_only" in payload and "pt_require_free" not in payload: + payload["pt_require_free"] = payload.get("pt_free_only") + if "preferred_cloud_drive" in payload and "prefer_cloud_provider" not in payload: + providers = payload.get("preferred_cloud_drive") + payload["prefer_cloud_provider"] = providers[0] if isinstance(providers, (list, tuple)) and providers else providers + if "available_sources" in payload: + sources = payload.get("available_sources") + if isinstance(sources, (list, tuple, set)): + source_list = [self._clean_text(item).lower() for item in sources] + if "enable_pansou" not in payload: + payload["enable_pansou"] = "pansou" in source_list or "盘搜" in source_list + if "enable_hdhive" not in payload: + payload["enable_hdhive"] = any(item in source_list for item in ["hdhive", "影巢", "影潮"]) + if "enable_mp_pt" not in payload: + payload["enable_mp_pt"] = any(item in source_list for item in ["mp_pt", "mp", "pt", "原生", "moviepilot"]) + if "available_providers" in payload: + providers = payload.get("available_providers") + if isinstance(providers, (list, tuple, set)): + provider_list = [self._clean_text(item).lower() for item in providers] + if "has_115" not in payload: + payload["has_115"] = "115" in provider_list + if "has_quark" not in payload: + payload["has_quark"] = "quark" in provider_list or "夸克" in provider_list + if "auto_ingest" in payload and "auto_ingest_enabled" not in payload: + payload["auto_ingest_enabled"] = payload.get("auto_ingest") + if "auto_execute_score_threshold" in payload and "auto_ingest_score_threshold" not in payload: + payload["auto_ingest_score_threshold"] = payload.get("auto_execute_score_threshold") + normalized = {**defaults, **payload} + normalized["schema_version"] = "preferences.v1" + normalized["initialized"] = bool(normalized.get("initialized")) + normalized["prefer_resolution"] = self._clean_text(normalized.get("prefer_resolution") or defaults["prefer_resolution"]).upper() + normalized["prefer_dolby_vision"] = self._parse_bool_value(normalized.get("prefer_dolby_vision"), True) + normalized["prefer_hdr"] = self._parse_bool_value(normalized.get("prefer_hdr"), True) + normalized["prefer_chinese_subtitle"] = self._parse_bool_value(normalized.get("prefer_chinese_subtitle"), True) + normalized["prefer_complete_series"] = self._parse_bool_value(normalized.get("prefer_complete_series"), True) + normalized["prefer_cloud_provider"] = self._clean_text(normalized.get("prefer_cloud_provider")).lower() + normalized["enable_pansou"] = self._parse_bool_value(normalized.get("enable_pansou"), True) + normalized["enable_hdhive"] = self._parse_bool_value(normalized.get("enable_hdhive"), True) + normalized["enable_mp_pt"] = self._parse_bool_value(normalized.get("enable_mp_pt"), True) + normalized["has_quark"] = self._parse_bool_value(normalized.get("has_quark"), True) + normalized["has_115"] = self._parse_bool_value(normalized.get("has_115"), True) + normalized["cloud_default_path"] = self._normalize_path(normalized.get("cloud_default_path") or self._hdhive_default_path) + normalized["quark_default_path"] = self._normalize_path(normalized.get("quark_default_path") or self._quark_default_path) + normalized["p115_default_path"] = self._normalize_path(normalized.get("p115_default_path") or self._p115_default_path) + normalized["pt_require_free"] = self._parse_bool_value(normalized.get("pt_require_free"), False) + normalized["pt_min_seeders"] = max(0, self._safe_int(normalized.get("pt_min_seeders"), 3)) + normalized["pt_prefer_free"] = self._parse_bool_value(normalized.get("pt_prefer_free"), True) + normalized["hdhive_max_unlock_points"] = max(0, self._safe_int(normalized.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + normalized["auto_ingest_enabled"] = self._parse_bool_value(normalized.get("auto_ingest_enabled"), False) + normalized["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("auto_ingest_score_threshold"), 90))) + normalized["confirm_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("confirm_score_threshold"), 70))) + normalized["updated_at"] = self._safe_int(normalized.get("updated_at"), 0) + return normalized + + def _restore_assistant_preferences(self) -> None: + try: + restored = self.get_data(self._assistant_preferences_store_key) or {} + if isinstance(restored, dict): + self._assistant_preferences = { + self._clean_text(key): self._normalize_assistant_preferences(value) + for key, value in restored.items() + if self._clean_text(key) and isinstance(value, dict) + } + except Exception: + self._assistant_preferences = {} + + def _persist_assistant_preferences(self) -> None: + try: + items = list((self._assistant_preferences or {}).items())[-self._assistant_preferences_limit:] + self._assistant_preferences = {key: value for key, value in items if key} + self.save_data(key=self._assistant_preferences_store_key, value=self._assistant_preferences) + except Exception: + pass + + def _assistant_preferences_public_data(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + questions = [ + "你更偏好 4K 还是 1080P?", + "是否优先杜比视界 / HDR?", + "是否必须中文字幕?", + "电视剧是否优先全集或完整季?", + "你可用哪些源?例如:只用盘搜、只用影巢、只用 MP/PT,或者关闭其中某些源。", + "你可用哪些云盘?例如:只有夸克、只有 115、两者都可。", + "PT 资源最低做种数是多少?默认 3。", + "影巢单资源最多愿意消耗多少积分?默认 20。", + "是否允许 90 分以上资源自动入库?默认关闭。", + ] + return { + "schema_version": "preferences.v1", + "key": key, + "initialized": bool(preferences.get("initialized")), + "preferences": preferences, + "needs_onboarding": not bool(preferences.get("initialized")), + "onboarding_questions": questions if not bool(preferences.get("initialized")) else [], + "defaults": self._default_assistant_preferences(), + } + + def _save_assistant_preferences( + self, + *, + session: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + current = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + incoming = dict(preferences or {}) + merged = {**current, **incoming} + merged["initialized"] = self._parse_bool_value(incoming.get("initialized"), True) if "initialized" in incoming else True + merged["updated_at"] = int(time.time()) + normalized = self._normalize_assistant_preferences(merged) + self._assistant_preferences[key] = normalized + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _reset_assistant_preferences(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + self._assistant_preferences.pop(key, None) + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _assistant_preferences_status_brief(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + data = self._assistant_preferences_public_data(session=session, user_key=user_key) + prefs = dict(data.get("preferences") or {}) + brief = { + "key": data.get("key"), + "initialized": bool(data.get("initialized")), + "needs_onboarding": bool(data.get("needs_onboarding")), + "summary": { + "prefer_resolution": self._clean_text(prefs.get("prefer_resolution")), + "prefer_dolby_vision": bool(prefs.get("prefer_dolby_vision")), + "prefer_hdr": bool(prefs.get("prefer_hdr")), + "prefer_chinese_subtitle": bool(prefs.get("prefer_chinese_subtitle")), + "prefer_complete_series": bool(prefs.get("prefer_complete_series")), + "prefer_cloud_provider": self._clean_text(prefs.get("prefer_cloud_provider")), + "enable_pansou": bool(prefs.get("enable_pansou")), + "enable_hdhive": bool(prefs.get("enable_hdhive")), + "enable_mp_pt": bool(prefs.get("enable_mp_pt")), + "has_quark": bool(prefs.get("has_quark")), + "has_115": bool(prefs.get("has_115")), + "pt_min_seeders": self._safe_int(prefs.get("pt_min_seeders"), 3), + "pt_require_free": bool(prefs.get("pt_require_free")), + "hdhive_max_unlock_points": self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points), + "auto_ingest_enabled": bool(prefs.get("auto_ingest_enabled")), + "auto_ingest_score_threshold": self._safe_int(prefs.get("auto_ingest_score_threshold"), 90), + }, + } + if brief["needs_onboarding"]: + brief["onboarding_questions"] = data.get("onboarding_questions") or [] + brief["recommended_action"] = "ask_user_preferences_then_save" + return brief + + def _assistant_default_preferences_template(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "prefer_resolution": prefs.get("prefer_resolution"), + "prefer_dolby_vision": prefs.get("prefer_dolby_vision"), + "prefer_hdr": prefs.get("prefer_hdr"), + "prefer_chinese_subtitle": prefs.get("prefer_chinese_subtitle"), + "prefer_complete_series": prefs.get("prefer_complete_series"), + "prefer_cloud_provider": prefs.get("prefer_cloud_provider"), + "enable_pansou": prefs.get("enable_pansou"), + "enable_hdhive": prefs.get("enable_hdhive"), + "enable_mp_pt": prefs.get("enable_mp_pt"), + "has_quark": prefs.get("has_quark"), + "has_115": prefs.get("has_115"), + "pt_require_free": prefs.get("pt_require_free"), + "pt_min_seeders": prefs.get("pt_min_seeders"), + "hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "p115_default_path": prefs.get("p115_default_path"), + "quark_default_path": prefs.get("quark_default_path"), + "auto_ingest_enabled": prefs.get("auto_ingest_enabled"), + "auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + } + + def _assistant_preferences_for_session(self, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + return self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + + def _assistant_source_enabled(self, preferences: Optional[Dict[str, Any]], source_type: str) -> bool: + prefs = self._normalize_assistant_preferences(preferences) + source = self._clean_text(source_type).lower() + if source == "pansou": + return bool(prefs.get("enable_pansou")) + if source == "hdhive": + return bool(prefs.get("enable_hdhive")) + if source in {"mp", "mp_pt", "pt"}: + return bool(prefs.get("enable_mp_pt")) + return True + + def _assistant_available_cloud_providers(self, preferences: Optional[Dict[str, Any]]) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + providers: List[str] = [] + if self._parse_bool_value(prefs.get("has_115"), True): + providers.append("115") + if self._parse_bool_value(prefs.get("has_quark"), True): + providers.append("quark") + return providers + + def _assistant_filter_cloud_items_by_preferences( + self, + items: Optional[List[Dict[str, Any]]], + preferences: Optional[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + available = set(self._assistant_available_cloud_providers(preferences)) + source_items = [dict(item or {}) for item in (items or []) if isinstance(item, dict)] + if not available: + return [] + if available == {"115", "quark"}: + return source_items + + filtered: List[Dict[str, Any]] = [] + for item in source_items: + provider = self._clean_text( + item.get("pan_type") + or item.get("provider") + or item.get("channel") + or item.get("network") + or item.get("netdisk") + or item.get("cloud") + ).lower() + if provider in {"quark", "夸克", "quarkcloud"}: + provider = "quark" + elif provider in {"115", "115cloud", "115网盘"}: + provider = "115" + if provider in available: + filtered.append(item) + return filtered + + def _assistant_smart_search_source_order( + self, + preferences: Optional[Dict[str, Any]], + source_order: Optional[List[str]] = None, + ) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + requested = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + order = requested or ["pansou", "hdhive", "mp_pt"] + normalized: List[str] = [] + for item in order: + if item in {"mp", "pt"}: + item = "mp_pt" + if item not in {"pansou", "hdhive", "mp_pt"} or item in normalized: + continue + if self._assistant_source_enabled(prefs, item): + normalized.append(item) + return normalized + + def _assistant_smart_source_availability( + self, + preferences: Optional[Dict[str, Any]], + *, + source_order: Optional[List[str]] = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + prefs = self._normalize_assistant_preferences(preferences) + available_cloud = self._assistant_available_cloud_providers(prefs) + order = self._assistant_smart_search_source_order(prefs, source_order=source_order) + source_labels = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + available: List[Dict[str, Any]] = [] + blocked: List[Dict[str, Any]] = [] + for source in ["pansou", "hdhive", "mp_pt"]: + entry = { + "source_type": source, + "label": source_labels[source], + } + if source not in order: + if not self._assistant_source_enabled(prefs, source): + blocked.append({**entry, "reason": f"当前偏好已关闭{source_labels[source]}"}) + else: + blocked.append({**entry, "reason": "当前决策顺序未包含该源"}) + continue + if source in {"pansou", "hdhive"} and not available_cloud: + blocked.append({**entry, "reason": "当前偏好未启用任何云盘"}) + continue + available.append({ + **entry, + "provider_scope": list(available_cloud) if source in {"pansou", "hdhive"} else ["pt"], + }) + if "115" not in available_cloud: + blocked.append({"source_type": "115", "label": "115", "reason": "当前偏好未启用 115"}) + if "quark" not in available_cloud: + blocked.append({"source_type": "quark", "label": "夸克", "reason": "当前偏好未启用夸克"}) + return available, blocked + + def _assistant_smart_decision_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + profile = self._clean_text(decision_profile).lower() + if not profile: + return prefs + updated = dict(prefs) + confirm_threshold = self._safe_int( + updated.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + if profile == "conservative": + updated["confirm_score_threshold"] = max(confirm_threshold, min(95, confirm_threshold + 10)) + elif profile == "aggressive": + updated["confirm_score_threshold"] = max(40, min(100, confirm_threshold - 10)) + return self._normalize_assistant_preferences(updated) + + def _assistant_smart_session_overrides_summary(self, overrides: Optional[Dict[str, Any]]) -> str: + raw = dict(overrides or {}) + if not raw: + return "" + payload = self._normalize_assistant_preferences(raw) + parts: List[str] = [] + if "has_quark" in raw or "has_115" in raw: + if payload.get("has_quark") and not payload.get("has_115"): + parts.append("只用夸克") + elif payload.get("has_115") and not payload.get("has_quark"): + parts.append("只用115") + elif payload.get("has_115") and payload.get("has_quark"): + parts.append("115 / 夸克都可") + if "enable_pansou" in raw or "enable_hdhive" in raw or "enable_mp_pt" in raw: + enabled_sources: List[str] = [] + if payload.get("enable_pansou"): + enabled_sources.append("盘搜") + if payload.get("enable_hdhive"): + enabled_sources.append("影巢") + if payload.get("enable_mp_pt"): + enabled_sources.append("MP/PT") + if enabled_sources: + parts.append("仅会话源:" + " / ".join(enabled_sources)) + return ";".join(parts[:2]) + + def _assistant_smart_merge_session_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + session_overrides: Optional[Dict[str, Any]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + merged = { + **self._normalize_assistant_preferences(preferences), + **dict(session_overrides or {}), + } + return self._assistant_smart_decision_preferences( + merged, + decision_profile=decision_profile, + ) + + def _assistant_smart_apply_session_override( + self, + session_overrides: Optional[Dict[str, Any]], + *, + adjust_action: str, + source_order: Optional[List[str]] = None, + ) -> Tuple[Dict[str, Any], List[str]]: + overrides = dict(session_overrides or {}) + order = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + normalized_action = self._clean_text(adjust_action).lower() + if normalized_action == "decision_only_quark": + overrides["has_quark"] = True + overrides["has_115"] = False + overrides["prefer_cloud_provider"] = "quark" + elif normalized_action == "decision_only_115": + overrides["has_quark"] = False + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "115" + elif normalized_action == "decision_cloud_both": + overrides["has_quark"] = True + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "" + elif normalized_action == "decision_only_mp_pt": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = True + order = ["mp_pt"] + elif normalized_action == "decision_only_pansou": + overrides["enable_pansou"] = True + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = False + order = ["pansou"] + elif normalized_action == "decision_only_hdhive": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = True + overrides["enable_mp_pt"] = False + order = ["hdhive"] + elif normalized_action == "decision_disable_pansou": + overrides["enable_pansou"] = False + elif normalized_action == "decision_disable_hdhive": + overrides["enable_hdhive"] = False + elif normalized_action == "decision_disable_mp_pt": + overrides["enable_mp_pt"] = False + elif normalized_action == "decision_reset_preferences": + overrides = {} + order = [] + return overrides, order + + def _parse_assistant_preferences_text(self, text: str) -> Dict[str, Any]: + raw = self._clean_text(text) + compact = re.sub(r"\s+", "", raw).lower() + payload: Dict[str, Any] = {} + if re.search(r"1080|fhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "1080P" + elif re.search(r"4k|2160|uhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "4K" + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}(杜比|dv)", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = False + elif re.search(r"杜比|dv|dolby", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = False + elif re.search(r"hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,6}(中字|中文|字幕)", raw, flags=re.IGNORECASE) or re.search(r"无字幕也可|字幕无所谓", raw): + payload["prefer_chinese_subtitle"] = False + elif re.search(r"中字|中文|简中|繁中|双语字幕|字幕", raw, flags=re.IGNORECASE): + payload["prefer_chinese_subtitle"] = True + + if re.search(r"不强求全集|不要全集|单集也可|更新也可", raw): + payload["prefer_complete_series"] = False + elif re.search(r"全集|完整季|整季|完结", raw): + payload["prefer_complete_series"] = True + + if re.search(r"夸克优先|优先夸克|quark", raw, flags=re.IGNORECASE): + payload["prefer_cloud_provider"] = "quark" + elif re.search(r"115优先|优先115", raw): + payload["prefer_cloud_provider"] = "115" + + if re.search(r"只用盘搜|仅用盘搜|只搜盘搜", raw): + payload["enable_pansou"] = True + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = False + elif re.search(r"只用影巢|仅用影巢|只搜影巢|只用影潮", raw): + payload["enable_pansou"] = False + payload["enable_hdhive"] = True + payload["enable_mp_pt"] = False + elif re.search(r"只用原生|只用pt|仅用pt|只搜pt|只搜原生|只要原生", raw, flags=re.IGNORECASE): + payload["enable_pansou"] = False + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}盘搜", raw): + payload["enable_pansou"] = False + elif re.search(r"(启用|使用|打开).{0,4}盘搜", raw): + payload["enable_pansou"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = False + elif re.search(r"(启用|使用|打开).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = False + elif re.search(r"(启用|使用|打开).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = True + + if re.search(r"只有夸克|仅有夸克|只用夸克|没有115|不用115", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + payload["has_115"] = False + elif re.search(r"只有115|仅有115|只用115|没有夸克|不用夸克", raw, flags=re.IGNORECASE): + payload["has_115"] = True + payload["has_quark"] = False + if re.search(r"(不要|不用|关闭|禁用).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = False + elif re.search(r"(启用|使用|打开).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}115", raw): + payload["has_115"] = False + elif re.search(r"(启用|使用|打开).{0,4}115", raw): + payload["has_115"] = True + + if re.search(r"pt.{0,4}(只要|必须).{0,4}免费|只下免费|只要免费", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = True + elif re.search(r"pt.{0,4}(不限|不强求).{0,4}免费|不只要免费|免费不强求", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = False + + seed_match = re.search(r"(?:做种|种子|seeders?|seeder)[^\d]{0,8}(\d+)", raw, flags=re.IGNORECASE) + if seed_match: + payload["pt_min_seeders"] = self._safe_int(seed_match.group(1), 3) + + points_match = re.search(r"(?:影巢|积分|解锁)[^\d]{0,10}(\d+)", raw) + if points_match: + payload["hdhive_max_unlock_points"] = self._safe_int(points_match.group(1), self._hdhive_max_unlock_points) + + if re.search(r"(不|不要|关闭|禁用).{0,4}自动入库", raw): + payload["auto_ingest_enabled"] = False + elif re.search(r"自动入库|自动下载|自动转存", raw): + payload["auto_ingest_enabled"] = True + + threshold_match = re.search(r"(?:自动入库(?:评分|分数|阈值)|评分阈值|分数阈值|阈值)[^\d]{0,10}(\d{2,3})", raw) + if threshold_match: + payload["auto_ingest_score_threshold"] = self._safe_int(threshold_match.group(1), 90) + + for key, target in [ + ("115目录", "p115_default_path"), + ("p115目录", "p115_default_path"), + ("夸克目录", "quark_default_path"), + ("quark目录", "quark_default_path"), + ("云盘目录", "cloud_default_path"), + ]: + match = re.search(rf"{re.escape(key)}\s*=\s*([^\s,,]+)", raw, flags=re.IGNORECASE) + if match: + payload[target] = self._normalize_path(match.group(1)) + + if "initialized" not in payload and payload: + payload["initialized"] = True + if "auto_ingest_score_threshold" in payload: + payload["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(payload["auto_ingest_score_threshold"], 90))) + if "pt_min_seeders" in payload: + payload["pt_min_seeders"] = max(0, self._safe_int(payload["pt_min_seeders"], 3)) + if "hdhive_max_unlock_points" in payload: + payload["hdhive_max_unlock_points"] = max(0, self._safe_int(payload["hdhive_max_unlock_points"], self._hdhive_max_unlock_points)) + return payload + + @staticmethod + def _score_text_blob(item: Any) -> str: + if isinstance(item, dict): + parts: List[str] = [] + for value in item.values(): + if isinstance(value, (dict, list, tuple)): + parts.append(AgentResourceOfficer._score_text_blob(value)) + elif value is not None: + parts.append(str(value)) + return " ".join(parts).lower() + if isinstance(item, (list, tuple)): + return " ".join(AgentResourceOfficer._score_text_blob(value) for value in item).lower() + return str(item or "").lower() + + @staticmethod + def _score_has_any(text: str, keywords: List[str]) -> bool: + return any(keyword.lower() in text for keyword in keywords) + + @classmethod + def _score_quality_rank(cls, item: Any) -> int: + text = cls._score_text_blob(item) + rank = 0 + if cls._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + rank += 60 + if cls._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比", "臻彩"]): + rank += 45 + if cls._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + rank += 35 + if cls._score_has_any(text, ["4k", "2160", "uhd"]): + rank += 25 + if cls._score_has_any(text, ["高码率", "remux", "原盘", "web-dl", "webrip"]): + rank += 15 + if cls._score_has_any(text, ["e01", "s01", "全集", "全季", "更新至", "更至"]): + rank += 10 + if cls._score_has_any(text, ["中字", "简中", "繁中", "内封", "内嵌", "字幕"]): + rank += 8 + return rank + + @staticmethod + def _extract_series_progress(text: str) -> Dict[str, int]: + blob = str(text or "").lower() + result = {"max_episode": 0, "episode_count": 0} + episodes: List[int] = [] + for pattern in ( + r"\be(\d{1,3})\b", + r"第\s*(\d{1,3})\s*集", + r"更新(?:至)?\s*(?:ep|e)?\s*0*(\d{1,3})", + r"更(?:至)?\s*0*(\d{1,3})\s*集", + ): + for match in re.finditer(pattern, blob, flags=re.IGNORECASE): + try: + episodes.append(int(match.group(1))) + except Exception: + continue + for pattern in ( + r"e0*(\d{1,3})\s*[-~—]\s*e?0*(\d{1,3})", + r"第?\s*0*(\d{1,3})\s*[-~—]\s*0*(\d{1,3})\s*集", + ): + match = re.search(pattern, blob, flags=re.IGNORECASE) + if not match: + continue + try: + start_ep = int(match.group(1)) + end_ep = int(match.group(2)) + except Exception: + continue + if end_ep >= start_ep > 0: + result["episode_count"] = max(result["episode_count"], end_ep - start_ep + 1) + episodes.extend([start_ep, end_ep]) + if episodes: + result["max_episode"] = max(episodes) + if result["episode_count"] <= 0 and len(set(episodes)) >= 2: + sorted_eps = sorted(set(episodes)) + if sorted_eps == list(range(sorted_eps[0], sorted_eps[-1] + 1)): + result["episode_count"] = len(sorted_eps) + return result + + @staticmethod + def _score_level(score: int) -> str: + if score >= 90: + return "excellent" + if score >= 70: + return "confirm" + return "low" + + def _score_decision( + self, + *, + score: int, + risk_reasons: List[str], + hard_risk_reasons: Optional[List[str]] = None, + preferences: Dict[str, Any], + default_action: str, + ) -> Dict[str, Any]: + threshold = max(1, min(100, self._safe_int(preferences.get("auto_ingest_score_threshold"), 90))) + confirm_threshold = max(1, min(100, self._safe_int(preferences.get("confirm_score_threshold"), 70))) + auto_enabled = self._parse_bool_value(preferences.get("auto_ingest_enabled"), False) + hard_risks = [self._clean_text(item) for item in (hard_risk_reasons or []) if self._clean_text(item)] + hard_risk = bool(hard_risks) + can_auto = bool(auto_enabled and not hard_risk and score >= threshold) + if can_auto: + recommended = default_action + elif score >= confirm_threshold and not hard_risk: + recommended = "ask_confirm" + else: + recommended = "do_not_auto" + return { + "score": score, + "score_level": self._score_level(score), + "risk_reasons": risk_reasons, + "hard_risk_reasons": hard_risks, + "can_auto_execute": can_auto, + "recommended_action": recommended, + "auto_ingest_enabled": auto_enabled, + "auto_ingest_score_threshold": threshold, + "confirm_score_threshold": confirm_threshold, + } + + def _score_cloud_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + provider = self._clean_text(item.get("pan_type") or item.get("channel")).lower() + media_type = self._clean_text(item.get("media_type") or item.get("type")).lower() + series_like = ( + media_type in {"tv", "series", "电视剧", "剧集", "番剧"} + or bool(re.search(r"\bs\d{1,2}\b|\be\d{1,3}\b|season|第\s*\d+\s*集|[全整]季|全集|完结|更至|更新至|短剧|剧集", text, flags=re.IGNORECASE)) + ) + series_progress = self._extract_series_progress(text) + + if "2160" in text or "4k" in text or "uhd" in text: + score += 25 + reasons.append("4K/UHD +25") + elif "1080" in text: + score += 16 + reasons.append("1080P +16") + elif "720" in text: + score += 6 + reasons.append("720P +6") + if resolution_pref and resolution_pref in text: + score += 5 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +5") + + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + score += 18 + reasons.append("杜比视界 +18") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + score += 12 + reasons.append("HDR +12") + if self._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + score += 4 + reasons.append("高帧率 +4") + + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "内封简繁", "双语", "官中"]): + score += 14 + reasons.append("中文字幕 +14") + elif self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True): + score -= 5 + risks.append("未识别到中文字幕") + + completeness_detected = self._score_has_any(text, ["全集", "全季", "完结", "complete", "全 ", "更至", "更0", "更新至"]) + if not completeness_detected and series_like and series_progress.get("episode_count", 0) >= 3: + completeness_detected = True + + if completeness_detected: + score += 12 + reasons.append("完整度信息 +12") + elif series_like and self._parse_bool_value(prefs.get("prefer_complete_series"), True): + score -= 6 + risks.append("未识别到全集/更新完整度") + + if series_like and series_progress.get("max_episode", 0) > 0: + progress_bonus = min(10, series_progress["max_episode"]) + if progress_bonus > 0: + score += progress_bonus + reasons.append(f"更新到 E{series_progress['max_episode']:02d} +{progress_bonus}") + if series_progress["max_episode"] >= 7: + latest_bonus = min(4, series_progress["max_episode"] - 6) + if latest_bonus > 0: + score += latest_bonus + reasons.append(f"更新进度领先 +{latest_bonus}") + if series_like and series_progress.get("episode_count", 0) >= 3: + pack_bonus = min(12, series_progress["episode_count"] + 2) + score += pack_bonus + reasons.append(f"连续剧集包 +{pack_bonus}") + if series_progress["episode_count"] >= series_progress.get("max_episode", 0) >= 3: + score += 4 + reasons.append("从 E01 连续覆盖到当前 +4") + + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + + prefer_provider = self._clean_text(prefs.get("prefer_cloud_provider")).lower() + if prefer_provider and provider and prefer_provider == provider: + score += 5 + reasons.append(f"匹配网盘偏好 {provider} +5") + if provider == "115" and not self._parse_bool_value(prefs.get("has_115"), True): + message = "当前偏好未启用 115" + risks.append(message) + hard_risks.append(message) + score -= 30 + if provider == "quark" and not self._parse_bool_value(prefs.get("has_quark"), True): + message = "当前偏好未启用夸克" + risks.append(message) + hard_risks.append(message) + score -= 30 + if target_path and target_path in { + self._clean_text(prefs.get("cloud_default_path")), + self._clean_text(prefs.get("p115_default_path")), + self._clean_text(prefs.get("quark_default_path")), + }: + score += 3 + reasons.append("匹配默认目录 +3") + + if source_type == "hdhive": + limit = max(0, self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + points = self._resource_points_value(item) + if points == 0: + score += 10 + reasons.append("影巢免费 +10") + elif points is None and limit > 0: + reasons.append("影巢未标积分") + elif limit > 0 and points is not None and points > limit: + score -= 30 + message = f"影巢积分 {points} 超过上限 {limit},禁止自动解锁" + risks.append(message) + hard_risks.append(message) + elif points is not None: + score += max(0, 10 - points) + reasons.append(f"影巢积分 {points}") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_ingest_cloud", + ) + return { + **decision, + "source_type": source_type, + "score_reasons": reasons, + } + + def _score_pt_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else item + meta = item.get("meta_info") if isinstance(item.get("meta_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + min_seeders = max(0, self._safe_int(prefs.get("pt_min_seeders"), 3)) + seeders = self._safe_int(torrent.get("seeders"), 0) + peers = self._safe_int(torrent.get("peers"), 0) + volume = self._clean_text(torrent.get("volume_factor") or item.get("volume_factor")).lower() + title = self._clean_text(torrent.get("title")) + site_name = self._clean_text(torrent.get("site_name")) + group_name = self._clean_text(meta.get("resource_team")) + media_title = self._clean_text(media.get("title")).lower() + media_year = self._clean_text(media.get("year")) + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + prefer_dovi = self._parse_bool_value(prefs.get("prefer_dolby_vision"), True) + prefer_hdr = self._parse_bool_value(prefs.get("prefer_hdr"), True) + prefer_subtitle = self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True) + prefer_complete = self._parse_bool_value(prefs.get("prefer_complete_series"), True) + + if seeders <= 0: + message = "做种数 0,禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 35 + elif seeders < min_seeders: + message = f"做种数 {seeders},低于阈值 {min_seeders},禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 25 + elif seeders >= 20: + score += 22 + reasons.append(f"做种数 {seeders} +22") + elif seeders >= 10: + score += 16 + reasons.append(f"做种数 {seeders} +16") + else: + score += 10 + reasons.append(f"做种数 {seeders} +10") + + if peers >= seeders * 5 and seeders < 5: + score -= 8 + risks.append("下载需求高但做种偏低") + elif peers >= max(8, seeders): + score += 6 + reasons.append(f"下载热度 {peers} +6") + elif peers >= 3: + score += 3 + reasons.append(f"下载热度 {peers} +3") + + if self._score_has_any(volume, ["free", "免费", "2xfree", "2x free", "freeleech"]): + score += 18 + reasons.append("免费/促销 +18") + elif self._parse_bool_value(prefs.get("pt_require_free"), False): + score -= 20 + message = "用户要求 PT 免费资源" + risks.append(message) + hard_risks.append(message) + elif self._parse_bool_value(prefs.get("pt_prefer_free"), True): + reasons.append("普通 PT 资源,未额外扣分") + + if "2160" in text or "4k" in text or "uhd" in text: + score += 20 + reasons.append("4K/UHD +20") + elif "1080" in text: + score += 12 + reasons.append("1080P +12") + elif "720" in text: + score += 4 + reasons.append("720P +4") + if resolution_pref and resolution_pref in text: + score += 6 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +6") + has_dovi = self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]) + has_hdr = self._score_has_any(text, ["hdr10", "hdr", "hlg"]) + if has_dovi: + score += 14 + reasons.append("杜比视界 +14") + elif prefer_dovi: + score -= 4 + risks.append("未识别到杜比视界") + if has_hdr: + score += 9 + reasons.append("HDR +9") + elif prefer_hdr: + score -= 2 + risks.append("未识别到 HDR") + if self._score_has_any(text, ["中字", "简中", "繁中", "双语", "内封", "字幕"]): + score += 10 + reasons.append("字幕信息 +10") + elif prefer_subtitle: + score -= 5 + risks.append("未识别到中文字幕") + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + if self._score_has_any(text, ["s01", "season", "全集", "全季", "complete", "完结", "更新至", "更至"]): + score += 6 + reasons.append("季/全集标识 +6") + elif prefer_complete and self._score_has_any(text, ["s01", "s02", "e01", "第1集", "season"]): + score -= 4 + risks.append("未识别到全集/完整季") + + if media_title: + if media_title in title.lower(): + score += 8 + reasons.append("标题匹配媒体名 +8") + else: + score -= 4 + risks.append("标题与识别媒体名匹配一般") + if media_year and media_year in title: + score += 4 + reasons.append(f"年份匹配 {media_year} +4") + if site_name: + score += 2 + reasons.append(f"站点标识 {site_name} +2") + if group_name: + score += 2 + reasons.append(f"发布组 {group_name} +2") + + size_text = self._clean_text(torrent.get("size") or item.get("size")) + size_value = 0.0 + size_match = re.search(r"(\d+(?:\.\d+)?)\s*(tb|gb|mb)", size_text, flags=re.IGNORECASE) + if size_match: + size_value = float(size_match.group(1)) + unit = size_match.group(2).lower() + if unit == "tb": + size_value *= 1024 + elif unit == "mb": + size_value /= 1024 + if size_value > 0: + if self._score_has_any(text, ["2160", "4k", "uhd", "remux", "原盘"]) and size_value < 8: + score -= 6 + risks.append(f"体积 {size_text} 偏小,需留意是否压制过度") + elif self._score_has_any(text, ["1080", "web-dl", "bluray"]) and size_value >= 4: + score += 3 + reasons.append(f"体积 {size_text} 较充足 +3") + if peers >= 30 and seeders >= min_seeders: + score += 4 + reasons.append("热度稳定 +4") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_download_pt", + ) + return { + **decision, + "source_type": "pt", + "score_reasons": reasons, + "seeders": seeders, + "peers": peers, + "min_seeders": min_seeders, + "volume_factor": volume, + "site_name": site_name, + "resource_team": group_name, + } + + def _attach_cloud_scores( + self, + items: List[Dict[str, Any]], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> List[Dict[str, Any]]: + return [ + { + **dict(item or {}), + "score": self._score_cloud_resource( + dict(item or {}), + preferences=preferences, + source_type=source_type, + target_path=target_path, + ), + } + for item in items + if isinstance(item, dict) + ] + + def _rank_pansou_items( + self, + items: List[Dict[str, Any]], + *, + channel_order: Optional[List[str]] = None, + limit_per_channel: int = 20, + ) -> List[Dict[str, Any]]: + grouped: Dict[str, List[Dict[str, Any]]] = {} + for item in items: + if not isinstance(item, dict): + continue + channel = self._normalize_pansou_channel_name(item.get("channel")) + grouped.setdefault(channel, []).append(dict(item)) + + def sort_key(entry: Dict[str, Any]) -> Tuple[Any, ...]: + score = entry.get("score") if isinstance(entry.get("score"), dict) else {} + score_value = self._safe_int(score.get("score"), 0) + hard_risk_count = len(score.get("hard_risk_reasons") or []) + risk_count = len(score.get("risk_reasons") or []) + dt = self._clean_text(entry.get("datetime")) + note = self._clean_text(entry.get("note")) + return ( + score_value, + self._score_quality_rank(entry), + -hard_risk_count, + -risk_count, + dt, + len(note), + ) + + ordered_channels = channel_order or ["115", "quark"] + ranked: List[Dict[str, Any]] = [] + for channel in ordered_channels: + channel_items = sorted(grouped.get(channel) or [], key=sort_key, reverse=True) + ranked.extend(channel_items[:max(1, limit_per_channel)]) + for index, item in enumerate(ranked, start=1): + item["index"] = index + return ranked + + def _public_pansou_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_item = { + "index": self._safe_int(item.get("index"), 0), + "channel": self._normalize_pansou_channel_name(item.get("channel")), + "note": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + "datetime": self._clean_text(item.get("datetime")), + "display_datetime": self._format_pansou_display_datetime(item.get("datetime")), + "password": self._clean_text(item.get("password")), + "url": self._clean_text(item.get("url")), + "brief_summary": self._pansou_item_brief_summary(item), + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + public_item[extra_key] = value + public_items.append(public_item) + return public_items + + @staticmethod + def _public_hdhive_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_items.append({ + "index": AgentResourceOfficer._safe_int(item.get("index"), 0), + "cloud_index": AgentResourceOfficer._safe_int(item.get("cloud_index"), 0), + "title": AgentResourceOfficer._clean_text(item.get("title")), + "pan_type": AgentResourceOfficer._clean_text(item.get("pan_type")), + "share_size": AgentResourceOfficer._list_text(item.get("share_size") or item.get("size")), + "source": AgentResourceOfficer._list_text(item.get("source")), + "subtitle_text": AgentResourceOfficer._resource_subtitle_text(item), + "episode_text": AgentResourceOfficer._resource_episode_text(item), + "remark": AgentResourceOfficer._resource_remark_text(item), + "points_text": AgentResourceOfficer._resource_points_text(item), + "video_resolution": item.get("video_resolution") or [], + "slug": AgentResourceOfficer._clean_text(item.get("slug")), + }) + return public_items + + @staticmethod + def _prepend_search_note(message: str, note: str) -> str: + clean_message = str(message or "").strip() + clean_note = str(note or "").strip() + if not clean_note: + return clean_message + if not clean_message: + return clean_note + return f"{clean_note}\n\n{clean_message}" + + def _assistant_finalize_pansou_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + items: List[Dict[str, Any]], + total: int, + target_path: str, + action_name: str, + search_scope: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_pansou_text(keyword, items, total, page=1, page_size=page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + public_items = self._public_pansou_items(items) + result_data = { + "action": action_name, + "ok": True, + "items": public_items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + "total_pages": max(1, (len(items) + page_size - 1) // page_size) if items else 1, + "decision_summary": self._assistant_pansou_entry_summary(items), + "search_scope": search_scope, + } + if recommend_handoff: + saved_state = self._load_session(cache_key) or {} + result_data.update(self._assistant_recommend_handoff_short_metadata(saved_state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(saved_state) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data=result_data), + } + + @staticmethod + def _pick_cloud_hdhive_candidate( + keyword: str, + candidates: List[Dict[str, Any]], + year: str = "", + ) -> Dict[str, Any]: + clean_year = AgentResourceOfficer._clean_text(year)[:4] + if not candidates: + return {} + if len(candidates) == 1: + return dict(candidates[0] or {}) + if not clean_year: + return {} + matched = [ + dict(item or {}) + for item in candidates + if AgentResourceOfficer._clean_text((item or {}).get("year"))[:4] == clean_year + ] + if len(matched) == 1: + return matched[0] + return {} + + def _format_pansou_body_lines(self, items: List[Dict[str, Any]], total: int) -> List[str]: + count_115 = len([x for x in items if x.get("channel") == "115"]) + count_quark = len([x for x in items if x.get("channel") == "quark"]) + lines = [f"共找到 {total} 条结果,当前展示 115 {count_115} 条、夸克 {count_quark} 条:"] + seen_115 = False + seen_quark = False + for cached in items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + return lines + + @classmethod + def _format_pansou_list_title(cls, item: Dict[str, Any]) -> str: + channel = cls._normalize_pansou_channel_name(item.get("channel")) + emoji = "📺" if channel == "115" else "🗄" if channel == "quark" else "🔗" + title = cls._clean_text(item.get("note") or item.get("title") or "未命名资源") + title = re.sub(r"^\s*\[(?:115|quark|QUARK)\]\s*", "", title).strip() + title = re.sub(r"^\s*#(?:剧集|电影|动漫|资源)\s*", "", title).strip() + title = cls._truncate_text(title, 110) + if title.startswith(("📺", "🗄", "🗃", "📁", "🔗")): + return title + return f"{emoji} {title}".strip() + + def _format_pansou_text( + self, + keyword: str, + items: List[Dict[str, Any]], + total: int, + *, + page: int = 1, + page_size: int = 10, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(items), page=page, page_size=page_size) + page_items = items[start:end] + count_115 = len([x for x in page_items if x.get("channel") == "115"]) + count_quark = len([x for x in page_items if x.get("channel") == "quark"]) + lines = [ + f"盘搜搜索:{keyword}", + f"共找到 {total} 条结果,当前第 {safe_page}/{total_pages} 页(本次缓存 {len(items)} 条候选),本页 115 {count_115} 条、夸克 {count_quark} 条:", + ] + seen_115 = False + seen_quark = False + for cached in page_items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + first_visible_index = self._safe_int((page_items[0] or {}).get("index"), 1) if page_items else 1 + next_quark_hint = next((self._safe_int(item.get("index"), 0) for item in page_items if item.get("channel") == "quark"), 0) + suggestion_lines = self._format_pansou_selection_suggestion(page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + if next_quark_hint > 0 and next_quark_hint != first_visible_index: + lines.append(f"本页夸克结果从 {next_quark_hint} 开始编号;例如直接回复“{next_quark_hint}”即可处理本页第 1 条夸克结果。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _human_resource_signal_text(cls, title: str, reasons: List[str]) -> str: + blob = f"{title} {' '.join(reasons or [])}".lower() + signals: List[str] = [] + if any(token in blob for token in ["4k", "2160", "uhd", "臻彩"]): + signals.append("画质规格更高") + if any(token in blob for token in ["hdr", "dv", "杜比", "dolby", "高码率", "60fps", "60帧"]): + signals.append("视频规格更完整") + if any(token in blob for token in ["e01", "s01", "全集", "全季", "连续", "更新至", "更至", "覆盖到当前"]): + signals.append("集数覆盖更明确") + if any(token in blob for token in ["中字", "简中", "繁中", "字幕", "内封", "内嵌"]): + signals.append("字幕信息更清楚") + if any(token in blob for token in ["免费", "积分 0", "影巢免费"]): + signals.append("成本更低") + if not signals: + return "综合画质、完整度和可转存性更均衡" + return "、".join(signals[:3]) + + @classmethod + def _human_resource_provider_text(cls, provider: str) -> str: + normalized = cls._clean_text(provider).lower() + if normalized == "115" or "115" in normalized: + return "115 资源更适合做主收藏和后续入库" + if normalized == "quark" or "夸克" in normalized: + return "夸克资源更适合走夸克转存链路" + return "这个资源整体更适合优先处理" + + @classmethod + def _human_resource_suggestion_line(cls, best_index: int, best_title: str, provider: str, reasons: List[str]) -> str: + title_part = f":{best_title}" if best_title else "" + provider_text = cls._human_resource_provider_text(provider) + signal_text = cls._human_resource_signal_text(best_title, reasons) + return f"优先选 {best_index}{title_part}。{provider_text},主要优势是{signal_text}。" + + @classmethod + def _human_quark_fallback_line(cls, quark_index: int, quark_title: str, reasons: List[str]) -> str: + title_part = f":{quark_title}" if quark_title else "" + signal_text = cls._human_resource_signal_text(quark_title, reasons) + return f"如果这次明确只走夸克,备选 {quark_index}{title_part}。它在夸克结果里相对更稳,优势是{signal_text}。" + + def _format_pansou_selection_suggestion(self, items: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(items, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:4] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_items = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_hdhive_resource_body_lines( + self, + resources: List[Dict[str, Any]], + *, + candidate: Optional[Dict[str, Any]] = None, + start_index: int = 1, + ) -> List[str]: + lines: List[str] = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {len(resources)} 条") + current_provider = "" + for offset, item in enumerate(resources, start=start_index): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + lines.append(self._format_hdhive_resource_summary_line(item, offset)) + lines.append("") + return [line for line in lines if line is not None] + + @classmethod + def _format_hdhive_resource_summary_line(cls, item: Dict[str, Any], index: int) -> str: + provider = str(item.get("pan_type") or "?").lower() + emoji = "📺" if provider == "115" else "🗄" if provider == "quark" else "🔗" + title = cls._clean_text(item.get("title") or "未命名资源") + points_text = cls._resource_points_text(item) + share_size = cls._list_text(item.get("share_size") or item.get("size")) + episode = cls._resource_episode_text(item) + resolution = "/".join(item.get("video_resolution") or []) or "" + source = cls._list_text(item.get("source")) + subtitle = cls._resource_subtitle_text(item) + remark = cls._resource_remark_text(item) + detail_parts = [ + points_text, + share_size, + episode, + resolution, + source, + subtitle, + remark, + ] + details = " · ".join(part for part in detail_parts if part) + return f"{index}. {emoji} {title}" + (f" · {details}" if details else "") + + def _format_hdhive_selection_suggestion(self, resources: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(resources, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + quark_items = [ + dict(item or {}) + for item in (resources or []) + if isinstance(item, dict) and str(item.get("pan_type") or "").lower() == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("pick_index") or best_quark.get("index"), 0) + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_cloud_selection_suggestion( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> List[str]: + combined: List[Dict[str, Any]] = [] + for item in pansou_items or []: + if isinstance(item, dict): + combined.append(dict(item)) + for item in hdhive_resources or []: + if not isinstance(item, dict): + continue + current = dict(item) + cloud_index = self._safe_int( + current.get("cloud_index") or current.get("pick_index") or current.get("index"), + 0, + ) + current["index"] = cloud_index + current["pick_index"] = cloud_index + combined.append(current) + summary = self._score_summary(combined, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_candidates = [ + dict(item or {}) + for item in combined + if isinstance(item, dict) + and self._normalize_pansou_channel_name(item.get("channel") or item.get("pan_type")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_candidates) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index") or best_quark.get("cloud_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _assistant_cloud_entry_summary( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> Dict[str, Any]: + preferred_index = 0 + if pansou_items: + preferred_index = self._safe_int((pansou_items[0] or {}).get("index"), 0) + elif hdhive_resources: + preferred_index = self._safe_int((hdhive_resources[0] or {}).get("cloud_index") or (hdhive_resources[0] or {}).get("index"), 0) + if preferred_index <= 0: + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(preferred_index), + "fallback_command": f"选择 {preferred_index} 详情", + "compact_commands": [str(preferred_index), f"选择 {preferred_index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _assistant_finalize_cloud_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + target_path: str, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_cloud_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_cloud", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "pansou_items": pansou_items, + "pansou_total": pansou_total, + "hdhive_resources": hdhive_resources, + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidates": hdhive_candidates, + "page": 1, + "page_size": page_size, + }, + ) + text_message = self._format_cloud_text( + keyword=keyword, + pansou_items=pansou_items, + pansou_total=pansou_total, + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=1, + page_size=page_size, + ) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search", + "ok": True, + "items": self._public_pansou_items(pansou_items), + "hdhive_resources": self._public_hdhive_items(hdhive_resources), + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidate_count": len(hdhive_candidates or []), + "page": 1, + "page_size": page_size, + "total_pages": max(1, ((len(pansou_items) + len(hdhive_resources)) + page_size - 1) // page_size) if (pansou_items or hdhive_resources) else 1, + "decision_summary": self._assistant_cloud_entry_summary(pansou_items, hdhive_resources), + "search_scope": "pansou_and_hdhive", + }), + } + + def _format_cloud_text( + self, + *, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + page: int = 1, + page_size: int = 20, + ) -> str: + total_items = len(pansou_items) + len(hdhive_resources) + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(total_items, page=page, page_size=page_size) + start_index = start + 1 + end_index = min(total_items, end) + lines = [f"云盘搜索:{keyword}"] + if total_items > 0: + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {start_index}-{end_index} / 共 {total_items} 条已展开结果:") + pansou_page_items = [item for item in pansou_items if start < self._safe_int(item.get('index'), 0) <= end] + hdhive_page_items = [item for item in hdhive_resources if start < self._safe_int(item.get('cloud_index') or item.get('index'), 0) <= end] + lines.extend(["", "盘搜结果"]) + if pansou_page_items: + lines.extend(self._format_pansou_body_lines(pansou_page_items, pansou_total)) + elif pansou_items: + lines.append("本页无盘搜结果") + else: + lines.append("暂无结果") + lines.extend(["", "影巢结果"]) + if hdhive_page_items: + lines.extend(self._format_hdhive_resource_body_lines( + hdhive_page_items, + candidate=hdhive_candidate, + start_index=self._safe_int((hdhive_page_items[0] or {}).get("cloud_index") or (hdhive_page_items[0] or {}).get("index"), 1), + )) + elif hdhive_resources: + lines.append("本页无影巢结果") + elif hdhive_candidates: + lines.append(f"候选影片 {len(hdhive_candidates)} 个,当前未自动展开。") + lines.append(f"如需细看影巢,请发送:影巢搜索 {keyword}") + else: + lines.append("暂无结果") + suggestion_lines = self._format_cloud_selection_suggestion(pansou_page_items, hdhive_page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + lines.append("如需只看单一来源,可继续发:盘搜搜索 片名 / 影巢搜索 片名。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + def _assistant_finalize_hdhive_candidates( + self, + *, + session: str, + cache_key: str, + keyword: str, + candidates: List[Dict[str, Any]], + media_type: str, + year: str, + target_path: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + self._save_session( + cache_key, + { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or self._hdhive_default_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": True, + "candidates": candidates, + "search_scope": "hdhive", + }), + } + + @staticmethod + def _format_score_label(item: Dict[str, Any]) -> str: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + if not score: + return "" + try: + value = int(float(score.get("score") or 0)) + except Exception: + value = 0 + action = AgentResourceOfficer._clean_text(score.get("recommended_action")) + if score.get("can_auto_execute"): + suffix = "可自动入库" + elif action == "ask_confirm": + suffix = "建议确认" + else: + suffix = "不建议自动" + return f"{value}分 {suffix}" + + @staticmethod + def _score_decision_label(value: Any) -> str: + action = AgentResourceOfficer._clean_text(value) + if action == "ask_confirm": + return "建议确认" + if action == "do_not_auto": + return "不建议自动" + if action == "auto_ingest_cloud": + return "可自动入库" + if action == "auto_download_pt": + return "可自动下载" + return action or "待判断" + + def _score_brief_item(self, item: Dict[str, Any], fallback_index: int = 0) -> Dict[str, Any]: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + if not score: + return {} + torrent = current.get("torrent_info") if isinstance(current.get("torrent_info"), dict) else {} + index = self._safe_int( + current.get("pick_index") or current.get("index") or fallback_index, + fallback_index, + ) + title = ( + self._clean_text(torrent.get("title")) + or self._clean_text(current.get("note")) + or self._clean_text(current.get("title") or current.get("matched_title")) + or "未命名资源" + ) + provider = ( + self._clean_text(current.get("pan_type") or current.get("channel")) + or self._clean_text(torrent.get("site_name")) + or self._clean_text(current.get("site")) + ) + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + brief = { + "index": index, + "title": title[:160], + "provider": provider, + "source_type": self._clean_text(score.get("source_type")), + "score": self._safe_int(score.get("score"), 0), + "quality_rank": self._score_quality_rank(current), + "score_level": self._clean_text(score.get("score_level")), + "recommended_action": self._clean_text(score.get("recommended_action")), + "can_auto_execute": bool(score.get("can_auto_execute")), + "score_reasons": reasons[:3], + "risk_reasons": risks[:2], + "hard_risk_reasons": hard_risks[:2], + } + points_text = self._resource_points_text(current) if current and brief.get("source_type") != "pt" else "" + if points_text and points_text != "积分未知": + brief["points_text"] = points_text + seeders = torrent.get("seeders") if torrent else score.get("seeders") + if seeders is not None: + brief["seeders"] = seeders + volume = self._clean_text(torrent.get("volume_factor") if torrent else score.get("volume_factor")) + if volume: + brief["volume_factor"] = volume + size = self._clean_text(current.get("share_size") or current.get("size") or torrent.get("size")) + if size: + brief["size"] = size + return brief + + def _score_summary(self, items: List[Dict[str, Any]], *, limit: int = 5) -> Dict[str, Any]: + scored: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + brief = self._score_brief_item(item, fallback_index=index) + if brief: + scored.append(brief) + scored.sort(key=lambda value: ( + 1 if value.get("can_auto_execute") else 0, + self._safe_int(value.get("score"), 0), + self._safe_int(value.get("quality_rank"), 0), + ), reverse=True) + auto_count = len([item for item in scored if item.get("can_auto_execute")]) + confirm_count = len([item for item in scored if item.get("recommended_action") == "ask_confirm"]) + blocked_count = len([item for item in scored if item.get("hard_risk_reasons")]) + warning_count = len([item for item in scored if item.get("risk_reasons")]) + summary = { + "total_scored": len(scored), + "auto_count": auto_count, + "confirm_count": confirm_count, + "blocked_count": blocked_count, + "warning_count": warning_count, + "best": scored[0] if scored else None, + "top_recommendations": scored[:max(1, min(10, self._safe_int(limit, 5)))], + } + summary["decision"] = self._score_summary_decision(summary) + return summary + + def _score_summary_decision(self, summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: + data = dict(summary or {}) + best = data.get("best") if isinstance(data.get("best"), dict) else {} + if not best: + return { + "stage": "no_scored_items", + "label": "暂无评分结果", + "requires_confirmation": False, + "prefer_plan_first": True, + "decision_hint": "当前没有可评分条目,先完成搜索或选择后再判断。", + "command_policy": "none", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "", + "fallback_command": "", + "compact_commands": [], + "recommended_commands": [], + } + choice = self._safe_int(best.get("index"), 0) + source_type = self._clean_text(best.get("source_type")).lower() + recommended_action = self._clean_text(best.get("recommended_action")) + title = self._clean_text(best.get("title")) + hard_risks = [self._clean_text(value) for value in (best.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + is_pt = source_type == "pt" + detail_command = f"选择 {choice}" if is_pt else f"选择 {choice} 详情" + plan_command = f"下载{choice}" if is_pt else f"计划选择 {choice}" + commands = [command for command in [detail_command if choice else "", plan_command if choice else "", "执行计划"] if command] + if best.get("can_auto_execute"): + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},已达到自动化阈值,但默认仍建议先生成计划再执行。" + stage = "auto_candidate" + elif hard_risks: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险,不能自动执行。" + stage = "blocked" + elif recommended_action == "ask_confirm": + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},风险可控,建议先看详情再决定。" + stage = "confirm" + else: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低,不建议直接处理。" + stage = "low_score" + return { + "stage": stage, + "label": self._score_decision_label("auto_download_pt" if best.get("can_auto_execute") and is_pt else "auto_ingest_cloud" if best.get("can_auto_execute") else recommended_action), + "source_type": source_type, + "choice": choice, + "title": title[:160], + "score": self._safe_int(best.get("score"), 0), + "requires_confirmation": not bool(best.get("can_auto_execute")), + "prefer_plan_first": True, + "command_policy": "read_then_confirm_write" if len(commands) > 1 else "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": bool(len(commands) > 1), + "can_auto_run_preferred": bool(commands), + "preferred_command": commands[0] if commands else "", + "fallback_command": commands[1] if len(commands) > 1 else "", + "compact_commands": commands[:2], + "recommended_commands": commands, + "decision_hint": hint, + "top_hard_risk": hard_risks[0] if hard_risks else "", + "top_warning": risks[0] if risks else "", + } + + @staticmethod + def _format_score_summary_decision_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + decision = data.get("decision") if isinstance(data.get("decision"), dict) else {} + if not decision: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(decision.get("label")) + hint = AgentResourceOfficer._clean_text(decision.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (decision.get("compact_commands") or decision.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"决策建议:{label}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("下一步:" + " / ".join(commands)) + return lines + + def _best_scored_source_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not candidates: + return {} + return max( + candidates, + key=lambda item: ( + 1 if (item.get("score") or {}).get("can_auto_execute") else 0, + self._safe_int((item.get("score") or {}).get("score"), 0), + self._score_quality_rank(item), + -self._safe_int(item.get("index") or item.get("pick_index"), 0), + ), + ) + + def _format_cloud_item_detail_text(self, item: Dict[str, Any], *, title: str = "云盘资源详情") -> str: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + score_summary = self._score_summary([current], limit=1) if score else {} + index = self._safe_int(current.get("index") or current.get("pick_index"), 0) + name = ( + self._clean_text(current.get("note")) + or self._clean_text(current.get("title")) + or self._clean_text(current.get("matched_title")) + or "未命名资源" + ) + provider = self._clean_text(current.get("channel") or current.get("pan_type") or current.get("provider")) + size = self._clean_text(current.get("share_size") or current.get("size")) + resolution = self._clean_text(current.get("resolution")) + source = self._clean_text(current.get("source") or current.get("source_name")) + datetime_text = self._format_pansou_display_datetime(current.get("datetime")) + password = self._clean_text(current.get("password")) + url = self._clean_text(current.get("url") or current.get("share_url") or current.get("link")) + brief = self._clean_text(current.get("brief_summary") or self._pansou_item_brief_summary(current)) + points = self._resource_points_text(current) + lines = [title] + if index: + lines.append(f"编号:{index}") + lines.append(f"资源:{name}") + if provider: + lines.append(f"网盘:{provider}") + if points: + lines.append(f"积分:{points}") + if resolution: + lines.append(f"分辨率:{resolution}") + if size: + lines.append(f"大小:{size}") + if source: + lines.append(f"来源:{source}") + if datetime_text: + lines.append(f"日期:{datetime_text}") + if brief: + lines.append(f"摘要:{brief}") + if password: + lines.append(f"提取码:{password}") + if url: + lines.append(f"链接:{url}") + if score: + lines.append("") + lines.append("智能建议") + lines.append(f"评分:{self._safe_int(score.get('score'), 0)} / {self._clean_text(score.get('score_level')) or '-'}") + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + lines.extend(self._format_score_summary_decision_lines(score_summary)) + return "\n".join(lines) + + def _assistant_scoring_policy_public_data(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "schema_version": "scoring_policy.v1", + "owner": "plugin_rules", + "agent_role": "explain_and_confirm_only", + "summary": "评分由插件内置规则执行;外部智能体只读取 score_summary、解释原因、请求确认,不能绕过硬风险。", + "global_decision": { + "auto_execute_requires": [ + "auto_ingest_enabled=true", + "score >= auto_ingest_score_threshold", + "hard_risk_reasons 为空", + ], + "confirm_range": "confirm_score_threshold <= score < auto_ingest_score_threshold 且无硬风险", + "default_source": "plugin_config_then_session_preferences", + "default_confirm_score_threshold": prefs.get("confirm_score_threshold"), + "default_auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + "auto_ingest_default": prefs.get("auto_ingest_enabled"), + }, + "cloud": { + "source_types": ["hdhive", "pansou", "115", "quark"], + "positive_signals": [ + "4K/UHD", + "杜比视界/HDR", + "中文字幕", + "全集/完整季/更新完整度", + "REMUX/原盘/Web-DL/高码率", + "匹配网盘偏好", + "匹配默认目录", + ], + "hard_gates": [ + "影巢积分超过 hdhive_max_unlock_points 时禁止自动解锁", + "影巢积分未知时禁止自动解锁", + ], + "default_hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "pansou_cost": "无积分成本,主要按质量、完整度、字幕和网盘类型评分", + }, + "pt": { + "source_types": ["moviepilot_native_search", "torrent_search", "subscribe_search"], + "positive_signals": [ + "做种数高", + "免费/促销/FreeLeech", + "4K/UHD", + "杜比视界/HDR", + "字幕信息", + "REMUX/原盘/Web-DL/高码率", + "季/全集标识", + ], + "hard_gates": [ + "做种数 0 禁止自动下载", + "做种数低于 pt_min_seeders 禁止自动下载", + "用户要求免费时,非免费资源禁止自动下载", + ], + "default_pt_min_seeders": prefs.get("pt_min_seeders"), + "volume_factor_note": "免费/促销明显加分;普通资源默认中性,不强行判废", + }, + "score_summary_contract": { + "read_fields": [ + "best", + "top_recommendations", + "decision", + "score", + "score_level", + "recommended_action", + "can_auto_execute", + "score_reasons", + "risk_reasons", + "hard_risk_reasons", + ], + "decision_fields": [ + "stage", + "label", + "choice", + "decision_hint", + "preferred_command", + "fallback_command", + "compact_commands", + "recommended_commands", + ], + "blocked_count": "只统计 hard_risk_reasons,不统计缺字幕等软提醒", + "warning_count": "统计 risk_reasons,用于解释需要用户确认的原因", + "do_not_parse": "不要解析自然语言 message 来判断自动化,优先读取 score_summary", + }, + } + + @staticmethod + def _format_bytes_size(value: Any) -> str: + try: + size = float(value or 0) + except Exception: + return "" + if size <= 0: + return "" + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f}{units[index]}" if index else f"{int(size)}B" + + def _mp_context_preview_item(self, context: Any, index: int, preferences: Dict[str, Any]) -> Dict[str, Any]: + torrent = getattr(context, "torrent_info", None) + meta = getattr(context, "meta_info", None) + media = getattr(context, "media_info", None) + item = { + "index": index, + "cache_index": index, + "torrent_info": { + "title": self._clean_text(getattr(torrent, "title", "")), + "size": self._format_bytes_size(getattr(torrent, "size", None)), + "seeders": getattr(torrent, "seeders", None), + "peers": getattr(torrent, "peers", None), + "site_name": self._clean_text(getattr(torrent, "site_name", "")), + "volume_factor": self._clean_text(getattr(torrent, "volume_factor", "")), + "page_url": self._clean_text(getattr(torrent, "page_url", "")), + }, + "meta_info": { + "season_episode": self._clean_text(getattr(meta, "season_episode", "")), + "resource_team": self._clean_text(getattr(meta, "resource_team", "")), + "video_encode": self._clean_text(getattr(meta, "video_encode", "")), + "edition": self._clean_text(getattr(meta, "edition", "")), + "resource_pix": self._clean_text(getattr(meta, "resource_pix", "")), + }, + "media_info": { + "title": self._clean_text(getattr(media, "title", "")), + "year": self._clean_text(getattr(media, "year", "")), + "tmdb_id": getattr(media, "tmdb_id", None), + "douban_id": getattr(media, "douban_id", None), + }, + } + item["score"] = self._score_pt_resource(item, preferences=preferences) + return item + + @classmethod + def _display_pt_title(cls, title: Any) -> str: + text = cls._clean_text(title or "未命名资源") + # Some chat frontends auto-link dotted release names such as + # Spider-Man.2002.1080p. Insert zero-width breaks for display only. + return re.sub(r"(?<=[A-Za-z0-9])\.(?=[A-Za-z0-9])", ".\u200b", text) + + @classmethod + def _pt_title_badges(cls, title: Any) -> str: + text = cls._clean_text(title).lower() + badges: List[str] = [] + if cls._score_has_any(text, ["2160", "4k", "uhd"]): + badges.append("✨4K") + elif cls._score_has_any(text, ["1080"]): + badges.append("🎞️1080p") + if cls._score_has_any(text, ["dolby vision", "dovi", " dv ", ".dv.", "杜比视界"]): + badges.append("⚡DV") + if cls._score_has_any(text, ["hdr10", "hdr", "hlg"]): + badges.append("🔶HDR") + if cls._score_has_any(text, ["remux", "原盘"]): + badges.append("💿REMUX") + return " ".join(badges) + + @staticmethod + def _pt_promotion_text(value: Any) -> str: + text = str(value or "").strip() or "普通" + lowered = text.lower() + if "免费" in text or "free" in lowered: + return f"🆓 {text}" + if "%" in text or "x" in lowered: + return f"🎁 {text}" + return f"🎫 {text}" + + def _sort_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + ranked = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) + ] + ranked.sort( + key=lambda item: ( + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + self._score_quality_rank(item), + self._safe_int((item.get("score") or {}).get("score"), 0), + -self._safe_int(item.get("index"), 0), + ), + reverse=True, + ) + for index, item in enumerate(ranked, start=1): + item["index"] = index + item["display_index"] = index + return ranked + + def _renumber_mp_display_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + renumbered: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], start=1): + if not isinstance(item, dict): + continue + current = dict(item) + current["source_index"] = self._safe_int( + current.get("source_index") or current.get("index") or current.get("display_index"), + index, + ) + current["index"] = index + current["display_index"] = index + renumbered.append(current) + return renumbered + + def _assistant_mp_selection_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + state = self._load_session(cache_key) or {} + state_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if state_items: + return [ + dict(item or {}) + for item in state_items + if isinstance(item, dict) + ] + return self._mp_search_all_preview_items(cache_key, preferences=preferences) + + def _assistant_mp_item_by_display_index( + self, + *, + choice: int, + cache_key: str, + preferences: Dict[str, Any], + ) -> Tuple[Dict[str, Any], List[int]]: + items = self._assistant_mp_selection_items(cache_key, preferences) + selected = next( + ( + dict(item or {}) + for item in items + if self._safe_int((item or {}).get("index"), 0) == choice + ), + {}, + ) + available = [ + self._safe_int((item or {}).get("index"), 0) + for item in items + if isinstance(item, dict) and self._safe_int(item.get("index"), 0) > 0 + ] + return selected, available + + @staticmethod + def _page_bounds(total_items: int, page: int = 1, page_size: int = 20) -> Tuple[int, int, int, int]: + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (max(0, total_items) + safe_page_size - 1) // safe_page_size) if total_items else 1 + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + end = start + safe_page_size + return safe_page, safe_page_size, total_pages, start, end + + def _mp_search_cache_preview( + self, + cache_key: str, + preferences: Dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + all_items: List[Dict[str, Any]] = [] + for index, context in enumerate(results, 1): + all_items.append(self._mp_context_preview_item(context, index, preferences)) + all_items = self._sort_mp_preview_items(all_items) + effective_page_size = max(1, self._safe_int(limit, page_size)) + return self._slice_mp_preview_items(all_items, page=page, page_size=effective_page_size) + + def _mp_search_all_preview_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + return self._sort_mp_preview_items([ + self._mp_context_preview_item(context, index, preferences) + for index, context in enumerate(results, 1) + ]) + + def _slice_mp_preview_items( + self, + items: List[Dict[str, Any]], + *, + page: int = 1, + page_size: int = 20, + ) -> List[Dict[str, Any]]: + _safe_page, _safe_page_size, _total_pages, start, end = self._page_bounds( + len(items), + page=page, + page_size=page_size, + ) + return [ + dict(item or {}) + for item in items[start:end] + if isinstance(item, dict) + ] + + def _format_mp_search_text( + self, + keyword: str, + message_text: str, + preview: List[Dict[str, Any]], + *, + total: int = 0, + page: int = 1, + page_size: int = 20, + result_filter: str = "", + latest_episode: int = 0, + episode_filter: int = 0, + ) -> str: + header = message_text.strip().splitlines()[0] if message_text else f"MP 原生搜索:{keyword}" + lines = [header] + if preview: + total_results = max(self._safe_int(total, 0), len(preview)) + safe_page_size = max(1, self._safe_int(page_size, self._assistant_result_page_size)) + total_pages = max(1, (total_results + safe_page_size - 1) // safe_page_size) + score_summary = self._score_summary(preview, limit=5) + lines.append("") + if result_filter == "latest_episode" and latest_episode > 0: + lines.append(f"最新集筛选:当前最高 E{latest_episode:02d},仅展示包含该集数的候选。") + elif result_filter.startswith("episode:") and episode_filter > 0: + lines.append(f"集数筛选:仅展示包含 E{episode_filter:02d} 的候选。") + lines.append(f"当前第 {max(1, page)}/{total_pages} 页,共 {total_results} 条结果(按做种数优先排序):") + lines.append("PT 资源:") + for item in preview: + torrent = item.get("torrent_info") or {} + score = item.get("score") or {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + badge_suffix = f" {badges}" if badges else "" + lines.append(f"{item.get('index')}. 🧲【{torrent.get('site_name') or '未知站点'}】 {title}{badge_suffix}") + details = [ + f"🌱 做种:{torrent.get('seeders') if torrent.get('seeders') is not None else '?'}", + self._pt_promotion_text(torrent.get("volume_factor")), + f"💾 {torrent.get('size') or '未知'}", + f"⭐ {self._format_score_label(item)}", + ] + hard_risks = score.get("hard_risk_reasons") or [] + risks = score.get("risk_reasons") or [] + risks = [risk for risk in risks if risk not in hard_risks] + if hard_risks: + details.append("硬风险:" + ";".join(str(item) for item in hard_risks[:2])) + elif risks: + details.append("提醒:" + ";".join(str(item) for item in risks[:2])) + lines.append(" " + " | ".join(details)) + decision_lines = self._format_score_summary_decision_lines(score_summary) + if result_filter == "latest_episode" or result_filter.startswith("episode:"): + normalized_decision_lines: List[str] = [] + for line in decision_lines: + if line.startswith("下一步:"): + continue + if line.startswith("建议:"): + line = line.replace( + "建议先看详情再决定", + "可直接生成下载计划,计划不会立即执行", + ) + normalized_decision_lines.append(line) + decision_lines = normalized_decision_lines + lines.extend(decision_lines) + if page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + best_index = self._safe_int(((score_summary.get("best") or {}) if isinstance(score_summary, dict) else {}).get("index"), 0) + if (result_filter == "latest_episode" or result_filter.startswith("episode:")) and best_index > 0: + lines.append(f"操作提示:建议回复“{best_index}”或“下载{best_index}”生成下载计划,不会立即下载。") + lines.append(f"如需先核对站点详情,可回复“{best_index}详情”。") + else: + lines.append("操作提示:回复编号或“下载N”生成下载计划;回复“N详情”看详情。") + lines.append("计划生成后,再回复“执行计划”或同一个编号确认执行。") + return "\n".join(line for line in lines if line) + + async def _assistant_mp_media_detail( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "", + year: str = "", + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_media_detail( + keyword=keyword, + media_type=media_type, + year=year, + ) + item = result.get("item") if isinstance(result.get("item"), dict) else {} + self._save_session(cache_key, { + "kind": "assistant_mp_media_detail", + "stage": "media_detail", + "keyword": keyword, + "items": [item] if item else [], + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "媒体识别完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": bool(result.get("success")), + "keyword": keyword, + "media_type": media_type, + "year": year, + "item": item, + }), + } + + async def _assistant_mp_media_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + preferences: Dict[str, Any], + page: int = 1, + page_size: int = 10, + result_filter: str = "", + ) -> Dict[str, Any]: + message_text = self._ensure_feishu_channel()._execute_media_search(keyword, cache_key) + failed_prefixes = ("MP 原生搜索失败", "未识别到媒体信息", "搜索资源失败") + route_ok = not any(message_text.startswith(prefix) for prefix in failed_prefixes) + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + total = len((cache or {}).get("results") or []) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + filtered_items = all_items + latest_episode = 0 + episode_filter = 0 + effective_filter = self._clean_text(result_filter) + if effective_filter == "latest_episode": + latest_items, latest_episode = self._latest_episode_mp_items(all_items) + if latest_items: + filtered_items = self._renumber_mp_display_items(latest_items) + total = len(filtered_items) + elif effective_filter.startswith("episode:"): + episode_filter = self._safe_int(effective_filter.split(":", 1)[1], 0) + episode_items = self._episode_filter_mp_items(all_items, episode_filter) + if episode_items: + filtered_items = self._renumber_mp_display_items(episode_items) + total = len(filtered_items) + preview = self._slice_mp_preview_items(filtered_items, page=page, page_size=page_size) if filtered_items else self._mp_search_cache_preview(cache_key, preferences=preferences, page=page, page_size=page_size) + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": keyword, + "items": preview, + "all_items": filtered_items, + "raw_all_items": all_items, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "target_path": "", + }) + return { + "success": route_ok, + "message": self._format_mp_search_text( + keyword, + message_text, + preview, + total=total, + page=page, + page_size=page_size, + result_filter=effective_filter, + latest_episode=latest_episode, + episode_filter=episode_filter, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search", + "ok": route_ok, + "keyword": keyword, + "source_type": "pt", + "items": preview, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "total_pages": max(1, (max(0, total) + max(1, self._safe_int(page_size, self._assistant_result_page_size)) - 1) // max(1, self._safe_int(page_size, self._assistant_result_page_size))) if total else 1, + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "score_summary": self._score_summary(preview, limit=5), + "preferences": preferences, + }), + } + + async def _assistant_mp_candidate_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "auto", + year: str = "", + page: int = 1, + pending_action: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type=self._clean_text(media_type or "auto").lower() or "auto", + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "success": False, + "message": f"MP 候选解析失败:{search_message}", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": False, + "keyword": clean_keyword, + "error_code": "candidate_search_failed", + "result": result, + }), + } + candidates = result.get("candidates") or [] + page_size = self._hdhive_candidate_page_size + self._save_session(cache_key, { + "kind": "assistant_mp_candidate", + "stage": "candidate", + "keyword": clean_keyword, + "media_type": self._clean_text(media_type or "auto").lower() or "auto", + "year": self._clean_text(year), + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + "target_path": target_path or "", + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }) + message = self._format_mp_candidate_lines(candidates, page=page, page_size=page_size) + if isinstance(pending_action, dict) and pending_action: + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名生成待确认下载计划,不会直接下载。", + ) + elif pending_mode == "cloud_transfer_execute": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名继续搜索云盘资源并转存。", + ) + pending_label = self._clean_text(pending_action.get("label")) + if pending_label: + message = f"{message}\n选定影片后将继续:{pending_label}" + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": True, + "keyword": clean_keyword, + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }), + } + + async def _assistant_mp_result_detail( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + item, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not item: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice}。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + torrent = item.get("torrent_info") or {} + meta = item.get("meta_info") or {} + media = item.get("media_info") or {} + score = item.get("score") or {} + lines = [ + f"MP 搜索结果详情 #{choice}", + f"标题:🧲 {self._display_pt_title(torrent.get('title'))} {self._pt_title_badges(torrent.get('title'))}".strip(), + f"站点:【{torrent.get('site_name') or '未知站点'}】", + f"大小:💾 {torrent.get('size') or '未知'}", + f"热度:🌱 做种 {torrent.get('seeders') if torrent.get('seeders') is not None else '?'} | 下载 {torrent.get('peers') if torrent.get('peers') is not None else '?'} | {self._pt_promotion_text(torrent.get('volume_factor'))}", + ] + media_text = " / ".join(str(part) for part in [ + media.get("title"), + media.get("year"), + f"TMDB:{media.get('tmdb_id')}" if media.get("tmdb_id") else "", + f"豆瓣:{media.get('douban_id')}" if media.get("douban_id") else "", + ] if part) + if media_text: + lines.append(f"媒体:{media_text}") + meta_text = " / ".join(str(part) for part in [ + meta.get("season_episode"), + meta.get("resource_pix"), + meta.get("video_encode"), + meta.get("edition"), + meta.get("resource_team"), + ] if part) + if meta_text: + lines.append(f"识别标签:{meta_text}") + score_label = self._format_score_label(item) + if score_label: + lines.append(f"评分:{score_label}") + reasons = [str(value) for value in (score.get("score_reasons") or []) if value] + hard_risks = [str(value) for value in (score.get("hard_risk_reasons") or []) if value] + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + risks = [risk for risk in risks if risk not in hard_risks] + score_summary = self._score_summary([item], limit=1) + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + if torrent.get("page_url"): + lines.append(f"详情页:{torrent.get('page_url')}") + lines.extend(self._format_score_summary_decision_lines(score_summary)) + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": (cache or {}).get("keyword") or current_state.get("keyword") or "", + "items": current_state.get("items") or self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=max(1, self._safe_int(current_state.get("page"), 1)), + page_size=max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + ), + "all_items": current_state.get("all_items") or [], + "raw_all_items": current_state.get("raw_all_items") or [], + "selected_index": choice, + "total": len(results), + "page": max(1, self._safe_int(current_state.get("page"), 1)), + "page_size": max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + "result_filter": current_state.get("result_filter") or "", + "latest_episode": self._safe_int(current_state.get("latest_episode"), 0), + "episode_filter": self._safe_int(current_state.get("episode_filter"), 0), + "target_path": current_state.get("target_path") or "", + **({"recommend_handoff": dict(current_state.get("recommend_handoff") or {})} if current_state.get("recommend_handoff") else {}), + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": True, + "choice": choice, + "item": item, + "score_summary": score_summary, + }), + } + + async def _assistant_mp_best_result_detail( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_best_detail", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = await self._assistant_mp_result_detail( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if result.get("success"): + result["message"] = f"当前评分最高的 PT 候选是 #{choice}\n{result.get('message') or ''}".strip() + data = dict(result.get("data") or {}) + data["action"] = "mp_search_best_detail" + data["best_choice"] = choice + result["data"] = data + return result + + async def _assistant_mp_best_download_plan( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_best_download_plan", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_best_download", + message="最佳片源下载计划已生成", + ) + if result.get("success"): + result["message"] = "\n".join(line for line in [ + f"已选择当前评分最高的 PT 候选:#{choice}", + result.get("message") or "", + ] if line) + data = dict(result.get("data") or {}) + data["choice"] = choice + data["item"] = best + result["data"] = data + return result + + def _assistant_format_download_plan_choice(self, *, rank: int, plan_id: str, item: Dict[str, Any]) -> str: + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + score = item.get("score") if isinstance(item.get("score"), dict) else {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + site = self._clean_text(torrent.get("site_name")) or "未知站点" + seeders = torrent.get("seeders") if torrent.get("seeders") is not None else "?" + size = self._clean_text(torrent.get("size")) or "未知" + score_label = self._format_score_label(item) + risk_text = self._assistant_score_warning_text(score, limit=1).replace("风险提示:", "") + suffix = f" | 提醒:{risk_text}" if risk_text else "" + badge_text = f" {badges}" if badges else "" + return ( + f"{rank}. {plan_id} | 资源 #{self._safe_int(item.get('index'), 0)} | " + f"🧲【{site}】 {title}{badge_text}\n" + f" 🌱 做种:{seeders} | {self._pt_promotion_text(torrent.get('volume_factor'))} | 💾 {size} | ⭐ {score_label}{suffix}" + ) + + async def _assistant_attach_download_plan_choices( + self, + result: Dict[str, Any], + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + limit: int = 3, + ) -> Dict[str, Any]: + if not isinstance(result, dict) or not result.get("success"): + return result + result_data = result.get("data") if isinstance(result.get("data"), dict) else {} + preview = [ + dict(item or {}) + for item in (result_data.get("items") or []) + if isinstance(item, dict) + ] + if not preview: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + scored.sort( + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + reverse=True, + ) + choices = scored[:max(1, min(3, self._safe_int(limit, 3)))] + if not choices: + return result + group_id = self._new_session_id("mpdl") + plan_rows: List[str] = [] + plan_items: List[Dict[str, Any]] = [] + for rank, item in enumerate(choices, start=1): + plan_result = self._assistant_mp_download_plan_response( + choice=self._safe_int(item.get("index"), 0), + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download_choice", + message=f"下载方案 {rank} 已生成", + ) + if not plan_result.get("success"): + continue + plan_data = plan_result.get("data") or {} + plan_id = self._clean_text(plan_data.get("plan_id")) + if plan_id and isinstance(self._workflow_plans.get(plan_id), dict): + execute_body = self._workflow_plans[plan_id].get("execute_body") + if not isinstance(execute_body, dict): + execute_body = {} + execute_body.update({ + "plan_rank": rank, + "multi_plan_group": group_id, + }) + self._workflow_plans[plan_id]["execute_body"] = execute_body + self._workflow_plans[plan_id]["plan_rank"] = rank + self._workflow_plans[plan_id]["plan_choice"] = self._safe_int(item.get("index"), 0) + self._workflow_plans[plan_id]["multi_plan_group"] = group_id + self._persist_workflow_plans() + plan_rows.append(self._assistant_format_download_plan_choice(rank=rank, plan_id=plan_id, item=item)) + plan_items.append({ + "rank": rank, + "plan_id": plan_id, + "choice": self._safe_int(item.get("index"), 0), + "item": item, + }) + if not plan_items: + return result + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + **current_state, + "kind": "assistant_mp_download_plans", + "stage": "download_plan_choices", + "pending_plan_group": group_id, + "plan_choices": plan_items, + "source_search": result.get("data") or {}, + }) + merged = { + "success": True, + "message": "\n".join([ + f"已按当前偏好生成 {len(plan_items)} 个待确认下载方案,均未实际下载:", + *plan_rows, + "回复方案编号 1/2/3 执行对应方案;回复“选择 资源编号”可先看详情。", + ]), + } + data = { + "action": "mp_download_plan_choices", + "ok": True, + "pending_plan_group": group_id, + "plan_choices": plan_items, + "ready_to_execute": True, + "write_effect": "state", + } + data["source_search"] = result.get("data") or {} + data["source_search_message"] = self._clean_text(result.get("message")) + merged["data"] = self._assistant_response_data(session=session, data=data) + return merged + + def _assistant_smart_search_stop_after_source( + self, + candidate: Optional[Dict[str, Any]], + preferences: Optional[Dict[str, Any]], + ) -> bool: + current = dict(candidate or {}) + if not current: + return False + score_value = self._safe_int(current.get("score"), 0) + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + hard_risks = current.get("hard_risk_reasons") or [] + recommended_action = self._clean_text(current.get("recommended_action")) + return score_value >= threshold and not hard_risks and recommended_action != "not_recommended" + + def _assistant_smart_search_candidate_from_cloud_item( + self, + item: Dict[str, Any], + *, + source_type: str, + ) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + provider = self._clean_text(item.get("pan_type") or item.get("channel") or item.get("provider")).lower() + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action == "ask_confirm": + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"计划选择 {index}" if index > 0 else "" + detail_command = f"选择 {index} 详情" if index > 0 else "" + return { + "source_type": source_type, + "title": self._clean_text(item.get("note") or item.get("title") or item.get("matched_title") or "未命名资源"), + "year": self._clean_text(item.get("year") or item.get("matched_year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "provider": provider or self._clean_text(item.get("source") or source_type), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "points_text": self._resource_points_text(item), + "provider": provider, + "size": self._clean_text(item.get("share_size") or item.get("size")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + def _assistant_smart_search_candidate_from_pt_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action in {"ask_confirm", "auto_download_pt"}: + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"下载{index}" if index > 0 else "" + detail_command = f"选择 {index}" if index > 0 else "" + return { + "source_type": "mp_pt", + "title": self._clean_text(torrent.get("title") or media.get("title") or "未命名资源"), + "year": self._clean_text(media.get("year")), + "media_type": self._clean_text(item.get("media_type") or media.get("type") or "tv"), + "provider": self._clean_text(torrent.get("site_name") or "pt"), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "seeders": self._safe_int(score.get("seeders"), self._safe_int(torrent.get("seeders"), 0)), + "volume_factor": self._clean_text(score.get("volume_factor") or torrent.get("volume_factor")), + "site_name": self._clean_text(score.get("site_name") or torrent.get("site_name")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + async def _assistant_smart_search_hdhive_resources( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + target_path: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return { + "source_type": "hdhive", + "ok": False, + "skipped": True, + "reason": disabled.get("message") or "影巢资源入口已关闭", + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "source_type": "hdhive", + "ok": False, + "reason": search_message, + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + candidates = result.get("candidates") or [] + if not candidates: + return { + "source_type": "hdhive", + "ok": True, + "reason": "未找到影巢候选", + "state": { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + "candidates": [], + "items": [], + "score_summary": {}, + } + + selected_candidate: Dict[str, Any] = {} + selected_preview: List[Dict[str, Any]] = [] + selected_summary: Dict[str, Any] = {} + for candidate in candidates[:3]: + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or media_type or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + continue + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path, + ) + if not preview: + continue + summary = self._score_summary(preview, limit=5) + best = summary.get("best") or {} + current_best = selected_summary.get("best") if isinstance(selected_summary.get("best"), dict) else {} + if not selected_preview or self._safe_int(best.get("score"), 0) > self._safe_int(current_best.get("score"), 0): + selected_candidate = dict(candidate or {}) + selected_preview = preview + selected_summary = summary + if self._assistant_smart_search_stop_after_source( + self._assistant_smart_search_candidate_from_cloud_item(self._best_scored_source_item(preview), source_type="hdhive"), + preferences, + ): + break + + state: Dict[str, Any] + if selected_preview: + state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "selected_candidate": selected_candidate, + "resources": selected_preview, + "page": 1, + "page_size": self._assistant_result_page_size, + } + else: + state = { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + } + return { + "source_type": "hdhive", + "ok": True, + "reason": search_message, + "state": state, + "candidates": candidates, + "items": selected_preview, + "score_summary": selected_summary, + } + + def _assistant_smart_search_decision_summary( + self, + *, + keyword: str, + preferences: Dict[str, Any], + checked: List[Dict[str, Any]], + best_candidate: Dict[str, Any], + available_sources: Optional[List[Dict[str, Any]]] = None, + blocked_sources: Optional[List[Dict[str, Any]]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + auto_threshold = self._safe_int( + prefs.get("auto_ingest_score_threshold"), + self._assistant_default_auto_ingest_score_threshold, + ) + source_names = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + preferred_command = self._clean_text(best_candidate.get("next_command")) + fallback_command = self._clean_text(best_candidate.get("detail_command")) + detail_command = "先看详情" if best_candidate.get("choice") else "" + detail_short_command = "详情" if best_candidate.get("choice") else "" + title = self._clean_text(best_candidate.get("title")) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + write_intent_commands = {"计划最佳", "执行最佳", "确认执行", "先计划"} + decision_mode = "show_detail" + confirm_required = False + decision_reason = "" + confirmation_prompt = "" + if not best_candidate: + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "decision_hint": "已按当前偏好尝试可用源,但没有找到符合条件的资源。", + "decision_mode": "not_recommended", + "decision_reason": "所有可用源都没有给出符合当前偏好的候选。", + "preferred_command": "", + "fallback_command": "", + "detail_command": "", + "detail_short_command": "", + "confirmation_prompt": "", + "plan_short_command": "", + "confirm_short_command": "", + "compact_commands": [], + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": False, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": "show_only", + } + if hard_risks: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最高分是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')},但存在硬风险。" + decision_mode = "not_recommended" + decision_reason = "当前最高分候选存在硬风险,不能作为直接推荐执行项。" + confirmation_prompt = "先看详情,或换源后再试。" + elif score_value >= threshold: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前首选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分)。" + if score_value >= auto_threshold: + decision_mode = "execute_now" + decision_reason = "当前首选分数已达到高可信区间,可以作为立即执行首选,但写入仍需明确意图。" + preferred_command = "执行最佳" + fallback_command = "计划最佳" + confirm_required = True + confirmation_prompt = "确认执行;如果想保守一点,回复:先计划 或 先看详情。" + else: + decision_mode = "make_plan" + decision_reason = "当前首选已达到建议确认阈值,优先生成待确认计划。" + preferred_command = "计划最佳" + fallback_command = "执行最佳" + confirm_required = True + confirmation_prompt = "先计划;如果想直接落地,回复:确认执行;如果想先检查资源,回复:先看详情。" + else: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最佳候选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分),但还没达到优先阈值。" + decision_mode = "show_detail" + decision_reason = "当前最佳候选分数偏低,优先查看详情或尝试切换搜索源。" + preferred_command = fallback_command or preferred_command + fallback_command = "计划最佳" if best_candidate.get("choice") and not hard_risks else "" + confirmation_prompt = "先看详情;如果仍要继续,回复:先计划 或 换影巢 / 换盘搜 / 换PT。" + if title: + hint = f"{hint} {title}" + auto_run_command = "" + confirm_command = "" + display_command = preferred_command or detail_command + preferred_requires_confirmation = preferred_command in write_intent_commands + fallback_requires_confirmation = fallback_command in write_intent_commands + if preferred_requires_confirmation: + confirm_command = preferred_command + if detail_command and detail_command != preferred_command: + command_policy = "read_then_confirm_write" + auto_run_command = detail_command + display_command = detail_command + recommended_agent_behavior = "auto_continue_then_wait_confirmation" + else: + command_policy = "wait_user_confirmation" + recommended_agent_behavior = "wait_user_confirmation" + can_auto_run_preferred = False + else: + command_policy = "safe_read_only" + can_auto_run_preferred = bool(preferred_command) + recommended_agent_behavior = "show_only" if decision_mode == "not_recommended" else "auto_continue" + display_command = preferred_command or detail_command + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "threshold": threshold, + "auto_ingest_score_threshold": auto_threshold, + "preferred_source": source_type, + "preferred_score": score_value, + "preferred_title": title[:160], + "decision_hint": hint.strip(), + "decision_mode": decision_mode, + "decision_reason": decision_reason or hint.strip(), + "preferred_command": preferred_command, + "fallback_command": fallback_command, + "detail_command": detail_command if best_candidate.get("choice") and not hard_risks else "", + "detail_short_command": detail_short_command if best_candidate.get("choice") and not hard_risks else "", + "confirmation_prompt": confirmation_prompt, + "plan_command": "计划最佳" if best_candidate.get("choice") and not hard_risks else "", + "plan_short_command": "计划" if best_candidate.get("choice") and not hard_risks else "", + "execute_command": "执行最佳" if best_candidate.get("choice") and not hard_risks else "", + "confirm_short_command": "确认" if best_candidate.get("choice") and not hard_risks else "", + "compact_commands": [ + command + for command in [preferred_command, fallback_command, detail_short_command or detail_command] + if command + ][:2], + "command_policy": command_policy, + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": fallback_requires_confirmation, + "can_auto_run_preferred": can_auto_run_preferred, + "auto_run_command": auto_run_command, + "confirm_command": confirm_command, + "display_command": display_command, + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": confirm_required, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": recommended_agent_behavior, + } + + def _assistant_smart_decision_entry_message( + self, + *, + keyword: str, + decision_summary: Dict[str, Any], + available_sources: List[Dict[str, Any]], + blocked_sources: List[Dict[str, Any]], + ) -> str: + checked = decision_summary.get("checked_sources") or [] + checked_text = " -> ".join( + { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + }.get(self._clean_text(item).lower(), self._clean_text(item)) + for item in checked + if self._clean_text(item) + ) + lines = [f"资源决策:{keyword}"] + if checked_text: + lines.append(f"已检查:{checked_text}") + if available_sources: + lines.append("可用源:" + " / ".join(self._clean_text(item.get("label")) for item in available_sources if self._clean_text(item.get("label")))) + if blocked_sources: + blocked_text = ";".join( + f"{self._clean_text(item.get('label'))}:{self._clean_text(item.get('reason'))}" + for item in blocked_sources[:3] + if self._clean_text(item.get("label")) and self._clean_text(item.get("reason")) + ) + if blocked_text: + lines.append("已跳过:" + blocked_text) + if self._clean_text(decision_summary.get("decision_hint")): + lines.append(self._clean_text(decision_summary.get("decision_hint"))) + if self._clean_text(decision_summary.get("decision_reason")): + lines.append("结论:" + self._clean_text(decision_summary.get("decision_reason"))) + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + confirmation_prompt = self._clean_text(decision_summary.get("confirmation_prompt")) + if preferred_command: + lines.append(f"首选:{preferred_command}") + if fallback_command and fallback_command != preferred_command: + lines.append(f"备选:{fallback_command}") + if confirmation_prompt: + lines.append("确认链:" + confirmation_prompt) + return "\n".join(line for line in lines if line).strip() + + def _assistant_smart_source_item_by_choice( + self, + source_states: Dict[str, Any], + *, + source_type: str, + choice: int, + ) -> Dict[str, Any]: + current_state = source_states.get(source_type) if isinstance(source_states, dict) else {} + current_state = current_state if isinstance(current_state, dict) else {} + if source_type == "pansou": + items = current_state.get("items") or [] + elif source_type == "hdhive": + items = current_state.get("resources") or current_state.get("items") or [] + else: + items = current_state.get("items") or [] + for item in items: + if self._safe_int((item or {}).get("index") or (item or {}).get("pick_index"), 0) == choice: + return dict(item or {}) + return {} + + def _assistant_smart_best_plan_response( + self, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的智能搜索结果,请先发送:智能搜索 片名 或 智能计划 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_search_session_not_found", + }), + } + best_candidate = current_state.get("best_candidate") if isinstance(current_state.get("best_candidate"), dict) else {} + decision_summary = current_state.get("decision_summary") if isinstance(current_state.get("decision_summary"), dict) else {} + checked = current_state.get("sources_checked") if isinstance(current_state.get("sources_checked"), list) else [] + source_states = current_state.get("source_states") if isinstance(current_state.get("source_states"), dict) else {} + if not best_candidate: + return { + "success": False, + "message": "智能搜索当前没有可用于生成计划的候选,请先换片名,或调整偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_not_found", + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best_candidate.get("risk_reasons") or []) if self._clean_text(value)] + origin = self._clean_text(current_state.get("origin")).lower() + final_path = ( + target_path + or self._clean_text(current_state.get("target_path")) + or self._clean_text(((source_states.get(source_type) or {}) if isinstance(source_states, dict) else {}).get("target_path")) + ) + + if hard_risks: + head = self._clean_text(decision_summary.get("decision_hint")) or "当前首选存在硬风险。" + tail = ";".join(hard_risks[:2]) + return { + "success": False, + "message": f"{head}\n未生成计划:{tail}", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_blocked", + "best_candidate": best_candidate, + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + result: Dict[str, Any] + if source_type == "pansou": + selected = self._assistant_smart_source_item_by_choice(source_states, source_type="pansou", choice=choice) or dict(best_candidate.get("raw_item") or {}) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return { + "success": False, + "message": "当前盘搜首选缺少可转存链接,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_share_url", + "best_candidate": best_candidate, + }), + } + provider_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": self._clean_text(selected.get("password")), + "path": final_path or provider_path, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "pansou", + "path": final_path or provider_path, + }, + message="智能搜索待确认计划已生成", + score_items=[selected], + extra_data={ + "choice": choice, + "source_type": "pansou", + "target_path": final_path or provider_path, + "selected_item": selected, + }, + ) + elif source_type == "hdhive": + resource = self._assistant_smart_source_item_by_choice(source_states, source_type="hdhive", choice=choice) or dict(best_candidate.get("raw_item") or {}) + slug = self._clean_text(resource.get("slug")) + if not slug: + return { + "success": False, + "message": "当前影巢首选缺少 slug,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_hdhive_slug", + "best_candidate": best_candidate, + }), + } + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path or self._hdhive_default_path, + "resource": resource, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "hdhive", + "path": final_path or self._hdhive_default_path, + }, + message="智能搜索待确认计划已生成", + score_items=[resource], + extra_data={ + "choice": choice, + "source_type": "hdhive", + "target_path": final_path or self._hdhive_default_path, + "selected_resource": resource, + }, + ) + elif source_type == "mp_pt": + preferences = self._assistant_preferences_for_session(session=session) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="smart_resource_plan", + message="智能搜索待确认计划已生成", + ) + else: + return { + "success": False, + "message": "当前首选来源暂不支持统一计划生成,请先查看详情后手动选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_unsupported_source", + "best_candidate": best_candidate, + }), + } + + data = dict(result.get("data") or {}) + adjusted_decision_summary = dict(decision_summary) + if origin == "mp_recommend" and choice and not hard_risks: + adjusted_decision_summary.update({ + "decision_mode": "make_plan", + "decision_reason": "推荐会话已为当前首项生成待确认计划;建议先看详情,再决定是否确认执行。", + "preferred_command": "确认", + "fallback_command": "详情", + "detail_command": "详情", + "detail_short_command": "详情", + "confirmation_prompt": "先看详情;如果确认无误,回复:确认。", + "plan_command": "计划", + "plan_short_command": "计划", + "execute_command": "确认", + "confirm_short_command": "确认", + "compact_commands": ["确认", "详情"], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + }) + data.update({ + "source_type": source_type, + "best_candidate": best_candidate, + "decision_summary": adjusted_decision_summary, + "sources_checked": checked, + "smart_plan_auto_selected": True, + }) + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in adjusted_decision_summary: + data[key] = adjusted_decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + if choice and not data.get("choice"): + data["choice"] = choice + result["data"] = data + message_lines = [ + "已根据智能搜索结果自动选择当前首选并生成待确认计划", + f"首选:{source_type} #{choice} | {score_value}分" + (f" | {title}" if title else ""), + result.get("message") or "", + ] + if risks: + message_lines.append("风险提示:" + ";".join(risks[:2])) + result["message"] = "\n".join(line for line in message_lines if line) + return result + + async def _assistant_execute_prepared_plan_result( + self, + request: Request, + *, + session: str, + cache_key: str, + plan_result: Dict[str, Any], + message_prefix: str = "", + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not bool(plan_result.get("success")): + return plan_result + plan_data = dict(plan_result.get("data") or {}) + plan_id = self._clean_text(plan_data.get("plan_id")) + if not plan_id: + return { + "success": False, + "message": "当前没有可执行的待确认计划,请先生成计划后再执行。", + "data": self._assistant_response_data(session=session, data={ + "action": "execute_plan", + "ok": False, + "error_code": "plan_not_found", + }), + } + execute_result = await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": plan_id, + "prefer_unexecuted": True, + "stop_on_error": True, + "include_raw_results": False, + "compact": False, + "apikey": self._extract_apikey(request, {}), + }) + ) + execute_data = dict(execute_result.get("data") or {}) + for key in [ + "source_type", + "best_candidate", + "decision_summary", + "sources_checked", + "score_summary", + "choice", + "smart_plan_auto_selected", + ]: + if key in plan_data: + execute_data[key] = plan_data.get(key) + if extra_data: + execute_data.update(dict(extra_data)) + execute_result["data"] = execute_data + if message_prefix: + execute_result["message"] = "\n".join( + line for line in [message_prefix, self._clean_text(execute_result.get("message"))] + if line + ).strip() + return execute_result + + async def _assistant_smart_best_execute_response( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + plan_result = self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + ) + plan_data = dict(plan_result.get("data") or {}) + best_candidate = plan_data.get("best_candidate") if isinstance(plan_data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + source_label = {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get(source_type, source_type or "当前源") + transfer_source_text = { + "pansou": "盘搜搜索转存", + "hdhive": "影巢搜索转存", + "mp_pt": "MP/PT 下载", + }.get(source_type, source_label) + prefix = "已根据智能搜索结果自动生成并执行当前首选计划" + prefix += f"\n执行方式:{transfer_source_text}" + if choice: + prefix += f"\n首选:{source_label} #{choice} | {score_value}分" + (f" | {title}" if title else "") + return await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=prefix, + extra_data={ + "smart_execute_auto_selected": True, + "source_type": source_type, + }, + ) + + async def _assistant_cloud_transfer_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + effective_target_path = self._clean_text(target_path) + prefer_provider = self._clean_text((session_preference_overrides or {}).get("prefer_cloud_provider")).lower() + if not effective_target_path: + if prefer_provider == "quark": + effective_target_path = self._quark_default_path + elif prefer_provider == "115": + effective_target_path = self._p115_default_path + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + search_result=search_result, + origin=origin, + ) + best_candidate = dict((smart_state.get("best_candidate") or {})) + if not search_result.get("success") and not best_candidate.get("source_type"): + return search_result + + preferences = self._assistant_smart_merge_session_preferences( + self._assistant_preferences_for_session(session=session), + session_overrides=session_preference_overrides, + ) + threshold = self._safe_int( + preferences.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + if best_candidate.get("source_type") and score_value >= threshold and not hard_risks: + execute_result = await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=effective_target_path, + ) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if source_type == "hdhive": + raw_item = dict(best_candidate.get("raw_item") or {}) + points_text = self._resource_points_text(raw_item) + if points_text and points_text not in {"免费", "未标积分"}: + execute_result["message"] = "\n".join( + line + for line in [ + f"影巢积分:{points_text},已直接解锁并转存。", + self._clean_text(execute_result.get("message")), + ] + if line + ).strip() + return execute_result + + source_states = smart_state.get("source_states") if isinstance(smart_state.get("source_states"), dict) else {} + pansou_state = dict(source_states.get("pansou") or {}) + hdhive_state = dict(source_states.get("hdhive") or {}) + cloud_result = self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=pansou_state.get("items") or [], + pansou_total=len(pansou_state.get("items") or []), + hdhive_resources=hdhive_state.get("resources") or [], + hdhive_candidate=hdhive_state.get("selected_candidate") or {}, + hdhive_candidates=hdhive_state.get("candidates") or [], + target_path=effective_target_path or self._hdhive_default_path, + lead_note="当前云盘资源质量或风险未达到直接转存阈值,已列出盘搜 + 影巢结果供你手动选择。", + ) + top_choices: List[str] = [] + if best_candidate.get("choice") and best_candidate.get("source_type"): + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get( + self._clean_text(best_candidate.get("source_type")).lower(), + self._clean_text(best_candidate.get("source_type")), + ) + top_choices.append(f"{source_label} #{self._safe_int(best_candidate.get('choice'), 0)}") + for item in (smart_state.get("alternatives") or []): + if not isinstance(item, dict): + continue + source_type = self._clean_text(item.get("source_type")).lower() + if source_type not in {"pansou", "hdhive"}: + continue + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get(source_type, source_type) + label = f"{source_label} #{self._safe_int(item.get('choice'), 0)}" + if label not in top_choices and self._safe_int(item.get("choice"), 0) > 0: + top_choices.append(label) + if len(top_choices) >= 3: + break + if top_choices: + cloud_result["message"] = "\n".join( + [ + self._clean_text(cloud_result.get("message")), + "推荐优先看:" + " / ".join(top_choices[:3]), + ] + ).strip() + if isinstance(cloud_result.get("data"), dict): + cloud_result["data"]["action"] = "cloud_transfer_execute" + cloud_result["data"]["best_candidate"] = best_candidate + return cloud_result + + async def _assistant_smart_resource_search( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + action_name: str = "smart_resource_search", + origin: str = "", + ) -> Dict[str, Any]: + base_preferences = self._assistant_preferences_for_session(session=session) + preferences = self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + decision_profile=decision_profile, + ) + search_order = self._assistant_smart_search_source_order(preferences, source_order=source_order) + available_sources, blocked_sources = self._assistant_smart_source_availability( + preferences, + source_order=source_order, + ) + if not search_order: + return { + "success": False, + "message": "当前偏好把盘搜、影巢、MP/PT 都关闭了,请先保存偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": False, + "error_code": "no_enabled_sources", + "preference_status": self._assistant_preferences_status_brief(session=session), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + }), + } + + checked: List[Dict[str, Any]] = [] + alternatives: List[Dict[str, Any]] = [] + active_state: Dict[str, Any] = {} + best_candidate: Dict[str, Any] = {} + best_source_score = -1 + source_states: Dict[str, Any] = {} + threshold = self._safe_int(preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold) + + for current_source in search_order: + if current_source == "pansou": + search_ok, payload, search_message = self._call_pansou_search(keyword) + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + for idx, item in enumerate(items, start=1): + item["index"] = idx + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=6) + source_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + } + source_states[current_source] = copy.deepcopy(source_state) + summary = self._score_summary(items, limit=5) + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="pansou") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(search_ok), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": search_message or ("盘搜无结果" if not items else ""), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="pansou") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + if current_source == "hdhive": + hdhive_result = await self._assistant_smart_search_hdhive_resources( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + preferences=preferences, + ) + source_state = dict(hdhive_result.get("state") or {}) + items = hdhive_result.get("items") or [] + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + source_state["items"] = items + source_state["resources"] = items + source_states[current_source] = copy.deepcopy(source_state) + summary = hdhive_result.get("score_summary") if isinstance(hdhive_result.get("score_summary"), dict) else {} + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="hdhive") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(hdhive_result.get("ok")), + "result_count": len(items), + "candidate_count": len(hdhive_result.get("candidates") or []), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._clean_text(hdhive_result.get("reason")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="hdhive") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + items = ((mp_result.get("data") or {}).get("items") or []) if isinstance(mp_result.get("data"), dict) else [] + summary = ((mp_result.get("data") or {}).get("score_summary") or {}) if isinstance(mp_result.get("data"), dict) else {} + source_state = copy.deepcopy(self._load_session(cache_key) or {}) + source_states[current_source] = source_state + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_pt_item(best_item) if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(mp_result.get("success")), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._assistant_result_message_head(mp_result.get("message")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_pt_item(item) + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + + alternatives = [item for item in alternatives if isinstance(item, dict) and item.get("choice")] + alternatives.sort( + key=lambda item: ( + self._safe_int(item.get("score"), 0), + 0 if item.get("hard_risk_reasons") else 1, + ), + reverse=True, + ) + unique_alternatives: List[Dict[str, Any]] = [] + seen_keys: set = set() + for item in alternatives: + dedupe_key = ( + self._clean_text(item.get("source_type")).lower(), + self._safe_int(item.get("choice"), 0), + ) + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + unique_alternatives.append(item) + + decision_summary = self._assistant_smart_search_decision_summary( + keyword=keyword, + preferences=preferences, + checked=checked, + best_candidate=best_candidate, + available_sources=available_sources, + blocked_sources=blocked_sources, + decision_profile=decision_profile, + ) + if active_state: + smart_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or active_state.get("target_path") or "", + "active_source": self._clean_text(best_candidate.get("source_type")), + "active_state": copy.deepcopy(active_state), + "source_states": source_states, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "source_order": search_order, + "decision_profile": self._clean_text(decision_profile), + "decision_entry": action_name, + "origin": self._clean_text(origin), + "session_preference_overrides": dict(session_preference_overrides or {}), + } + self._save_session(cache_key, smart_state) + + if action_name == "smart_resource_decision": + message_lines = [ + self._assistant_smart_decision_entry_message( + keyword=keyword, + decision_summary=decision_summary, + available_sources=available_sources, + blocked_sources=blocked_sources, + ) + ] + else: + message_lines = [ + f"智能搜索:{keyword}", + "已检查:" + " -> ".join( + {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get( + self._clean_text(item.get("source_type")).lower(), + self._clean_text(item.get("source_type")), + ) + for item in checked + if self._clean_text(item.get("source_type")) + ), + ] + if best_candidate: + message_lines.append(decision_summary.get("decision_hint") or "") + if best_candidate.get("hard_risk_reasons"): + message_lines.append("降级原因:" + ";".join(best_candidate.get("hard_risk_reasons")[:2])) + elif best_candidate.get("risk_reasons") and self._safe_int(best_candidate.get("score"), 0) < threshold: + message_lines.append("继续原因:" + ";".join(best_candidate.get("risk_reasons")[:2])) + else: + message_lines.append("当前按偏好筛过可用源后,没有找到可推荐结果。") + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + plan_command = self._clean_text(decision_summary.get("plan_command")) + execute_command = self._clean_text(decision_summary.get("execute_command")) + detail_short_command = self._clean_text(decision_summary.get("detail_short_command")) + plan_short_command = self._clean_text(decision_summary.get("plan_short_command")) + confirm_short_command = self._clean_text(decision_summary.get("confirm_short_command")) + if action_name != "smart_resource_decision" and preferred_command: + message_lines.append(f"建议先发:{preferred_command}") + if action_name != "smart_resource_decision" and fallback_command and fallback_command != preferred_command: + message_lines.append(f"备选:{fallback_command}") + if action_name != "smart_resource_decision" and plan_command and plan_command not in {preferred_command, fallback_command}: + message_lines.append(f"如需直接生成待确认计划:{plan_command}") + if action_name != "smart_resource_decision" and execute_command and execute_command not in {preferred_command, fallback_command, plan_command}: + message_lines.append(f"如需直接执行:{execute_command}") + override_summary = self._assistant_smart_session_overrides_summary(session_preference_overrides) + if override_summary: + message_lines.append("会话偏好:" + override_summary) + + score_summary = {} + active_source_type = self._clean_text(best_candidate.get("source_type")).lower() + if active_source_type == "pansou": + active_items = (source_states.get("pansou") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "hdhive": + active_items = (source_states.get("hdhive") or {}).get("resources") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "mp_pt": + active_items = (source_states.get("mp_pt") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + + return { + "success": True, + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": True, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "score_summary": score_summary, + "preference_status": self._assistant_preferences_status_brief(session=session), + "effective_preferences": preferences, + "session_preference_overrides": dict(session_preference_overrides or {}), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "decision_mode": self._clean_text(decision_summary.get("decision_mode")), + "decision_reason": self._clean_text(decision_summary.get("decision_reason")), + "next_actions": [ + command + for command in [ + preferred_command, + fallback_command, + detail_short_command, + plan_command, + plan_short_command, + execute_command, + confirm_short_command, + "继续决策", + "换影巢", + "换盘搜", + "换PT", + "保守一点", + "激进一点", + "只用夸克", + "只用115", + "只走PT", + "不用影巢", + "按保存偏好", + "跟进", + ] + if self._clean_text(command) + ], + }), + } + + async def _assistant_smart_resource_decision( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + return await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + action_name="smart_resource_decision", + origin=origin, + ) + + async def _assistant_smart_resource_decision_adjust( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]], + adjust_action: str, + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的资源决策会话,请先发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_session_not_found", + }), + } + keyword = self._clean_text(current_state.get("keyword")) + if not keyword: + return { + "success": False, + "message": "当前资源决策会话缺少片名,请重新发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_missing_keyword", + }), + } + media_type = self._clean_text(current_state.get("media_type") or "auto") or "auto" + year = self._clean_text(current_state.get("year")) + target_path = self._clean_text(current_state.get("target_path")) + source_order = list(current_state.get("source_order") or []) + session_preference_overrides = dict(current_state.get("session_preference_overrides") or {}) + if not source_order: + base_preferences = self._assistant_preferences_for_session(session=session) + source_order = self._assistant_smart_search_source_order( + self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + ) + ) + decision_profile = self._clean_text(current_state.get("decision_profile")) + if adjust_action == "decision_hdhive": + source_order = ["hdhive", "pansou", "mp_pt"] + elif adjust_action == "decision_pansou": + source_order = ["pansou", "hdhive", "mp_pt"] + elif adjust_action == "decision_mp_pt": + source_order = ["mp_pt", "pansou", "hdhive"] + elif adjust_action in { + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + session_preference_overrides, source_order = self._assistant_smart_apply_session_override( + session_preference_overrides, + adjust_action=adjust_action, + source_order=source_order, + ) + elif adjust_action == "decision_conservative": + decision_profile = "conservative" + elif adjust_action == "decision_aggressive": + decision_profile = "aggressive" + elif adjust_action == "decision_continue": + pass + return await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + ) + + async def _assistant_smart_resource_plan( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + async def _assistant_smart_resource_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + def _assistant_smart_state_after_search( + self, + *, + cache_key: str, + keyword: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + search_result: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + current_state = dict(self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) == "assistant_smart_search": + return current_state + data = (search_result or {}).get("data") if isinstance((search_result or {}).get("data"), dict) else {} + best_candidate = data.get("best_candidate") if isinstance(data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if not source_type: + return current_state + source_states = copy.deepcopy(current_state.get("source_states") or {}) if isinstance(current_state.get("source_states"), dict) else {} + raw_item = dict(best_candidate.get("raw_item") or {}) if isinstance(best_candidate.get("raw_item"), dict) else {} + choice = self._safe_int(best_candidate.get("choice") or raw_item.get("index"), 0) + final_path = self._clean_text(target_path or current_state.get("target_path") or data.get("target_path")) + + if source_type == "pansou" and raw_item: + item = dict(raw_item) + if choice and not item.get("index"): + item["index"] = choice + if not final_path: + share_url = self._clean_text(item.get("url")) + final_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + source_states[source_type] = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": final_path, + "items": [item], + } + elif source_type == "hdhive" and raw_item: + resource = dict(raw_item) + source_states[source_type] = { + "kind": "assistant_hdhive", + "stage": "resource_list", + "keyword": keyword, + "target_path": final_path or self._hdhive_default_path, + "resources": [resource], + "items": [resource], + } + + active_state = copy.deepcopy(source_states.get(source_type) or current_state.get("active_state") or {}) + fallback_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": final_path, + "active_source": source_type, + "active_state": active_state, + "source_states": source_states, + "sources_checked": data.get("sources_checked") if isinstance(data.get("sources_checked"), list) else [], + "best_candidate": best_candidate, + "alternatives": data.get("alternatives") if isinstance(data.get("alternatives"), list) else [], + "decision_summary": data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}, + "available_sources": data.get("available_sources") if isinstance(data.get("available_sources"), list) else [], + "blocked_sources": data.get("blocked_sources") if isinstance(data.get("blocked_sources"), list) else [], + "source_order": source_order or current_state.get("source_order") or [], + "decision_profile": self._clean_text(current_state.get("decision_profile") or ((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_profile")), + "decision_entry": self._clean_text(data.get("action") or current_state.get("decision_entry") or "smart_resource_search"), + "origin": self._clean_text(origin or current_state.get("origin")), + "session_preference_overrides": data.get("session_preference_overrides") if isinstance(data.get("session_preference_overrides"), dict) else dict(current_state.get("session_preference_overrides") or {}), + } + self._save_session(cache_key, fallback_state) + return fallback_state + + @staticmethod + def _assistant_score_warning_text(score: Dict[str, Any], *, limit: int = 3) -> str: + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + if not risks: + return "" + return "风险提示:" + ";".join(risks[:max(1, limit)]) + + def _assistant_mp_download_plan_response( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + workflow: str = "mp_download", + message: str = "PT 下载计划已生成", + ) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会生成下载计划。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”后再选择编号。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + result = self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": "pick_mp_download", + "session": session, + "session_id": cache_key, + "choice": choice, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "choice": choice, + "dry_run": False, + }, + message=message, + score_items=[selected], + extra_data={ + "choice": choice, + "selected": selected, + }, + ) + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + warning = self._assistant_score_warning_text(score) + if warning: + result["message"] = f"{result.get('message')}\n{warning}" + return result + + def _assistant_mp_subscribe_plan_response( + self, + *, + keyword: str, + session: str, + cache_key: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + action_name = "start_mp_subscribe_search" if immediate_search else "start_mp_subscribe" + workflow = "mp_subscribe_and_search" if immediate_search else "mp_subscribe" + label = "订阅并搜索计划已生成" if immediate_search else "订阅计划已生成" + return self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": action_name, + "session": session, + "session_id": cache_key, + "keyword": keyword, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "keyword": keyword, + "dry_run": False, + }, + message=label, + extra_data={ + "keyword": keyword, + "immediate_search": bool(immediate_search), + }, + ) + + def _assistant_mp_download_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "下载任务操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + } + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return { + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_target_not_found", + "target": target, + }), + } + downloader = self._clean_text(downloader) + return self._save_assistant_pick_plan_response( + workflow="mp_download_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + }], + execute_body={ + "workflow": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + "dry_run": False, + }, + message="下载任务操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + def _assistant_mp_subscribe_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "订阅操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + } + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id): + return { + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + } + return self._save_assistant_pick_plan_response( + workflow="mp_subscribe_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + }], + execute_body={ + "workflow": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "dry_run": False, + }, + message="订阅操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + async def _assistant_mp_download(self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any]) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会执行下载。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + "write_effect": "state", + }), + } + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + cache_choice = self._safe_int(selected.get("cache_index"), choice) + message_text = self._ensure_feishu_channel()._execute_media_download(cache_choice, cache_key) + ok = not message_text.startswith(( + "下载资源失败", + "下载提交失败", + "没有可用", + "序号超出范围", + )) and "无法连接" not in message_text and "添加下载任务失败" not in message_text + warning = self._assistant_score_warning_text(score) + if warning: + message_text = f"{message_text}\n{warning}".strip() + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": ok, + "choice": choice, + "cache_choice": cache_choice, + "selected": selected, + "score": score, + "write_effect": "write", + }), + } + + async def _assistant_mp_subscribe( + self, + *, + keyword: str, + session: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + message_text = self._ensure_feishu_channel()._execute_media_subscribe(keyword, immediate_search) + ok = not message_text.startswith("订阅失败") + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": ok, + "keyword": keyword, + "immediate_search": bool(immediate_search), + "write_effect": "write", + }), + } + + def _resolve_mp_download_task_target(self, *, target: str, cache_key: str) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index: + return dict(item) + hash_match = re.search(r"\b[0-9a-fA-F]{40}\b", target_text) + if hash_match: + task_hash = hash_match.group(0) + return {"hash": task_hash, "hash_short": task_hash[:8]} + short_hash_match = re.search(r"\b[0-9a-fA-F]{6,12}\b", target_text) + if short_hash_match: + short_hash = short_hash_match.group(0).lower() + for item in items: + task_hash = self._clean_text(item.get("hash")).lower() + if task_hash.startswith(short_hash): + return dict(item) + return {} + + async def _assistant_mp_download_tasks( + self, + *, + session: str, + cache_key: str, + status: str = "downloading", + title: str = "", + hash_value: str = "", + downloader: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_tasks( + downloader=downloader, + status=status or "downloading", + title=title, + hash_value=hash_value, + limit=max(1, min(30, self._safe_int(limit, 10))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_tasks", + "stage": "download_tasks", + "keyword": title or hash_value or status or "downloading", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_tasks", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": result.get("total", len(items)), + }), + } + + async def _assistant_mp_download_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_download_task_target(target=target, cache_key=cache_key) + task_hash = self._clean_text(selected.get("hash")) + if not task_hash: + return { + "success": False, + "message": "操作下载任务失败:请先发送“下载任务”获取列表,再按编号操作,例如“暂停下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "missing_download_task_hash", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_download_task( + action=control, + hash_value=task_hash, + downloader=downloader or self._clean_text(selected.get("downloader")), + delete_files=delete_files, + ) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_download_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_history( + title=title, + hash_value=hash_value, + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_history", + "stage": "download_history", + "keyword": title or hash_value or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_download_history", + "title": title, + "hash": hash_value, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + async def _assistant_mp_downloaders(self, *, session: str, cache_key: str) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_downloaders() + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_downloaders", + "stage": "downloaders", + "keyword": "downloaders", + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载器查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_downloaders", + "ok": bool(result.get("success")), + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + }), + } + + async def _assistant_mp_sites( + self, + *, + session: str, + cache_key: str, + status: str = "active", + name: str = "", + limit: int = 30, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_sites( + status=status or "active", + name=name, + limit=max(1, min(100, self._safe_int(limit, 30))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_sites", + "stage": "sites", + "keyword": name or status or "active", + "items": items, + "status": result.get("status") or status, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "站点查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_sites", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), 0), + }), + } + + def _resolve_mp_subscribe_target(self, *, target: str, cache_key: str, allow_raw_id: bool = False) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index or self._safe_int(item.get("id"), 0) == index: + return dict(item) + if allow_raw_id: + return {"id": index} + return {} + + async def _assistant_mp_subscribes( + self, + *, + session: str, + cache_key: str, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_subscribes( + status=status or "all", + media_type=media_type or "all", + name=name, + limit=max(1, min(100, self._safe_int(limit, 20))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_subscribes", + "stage": "subscribe_list", + "keyword": name or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribes", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + }), + } + + async def _assistant_mp_subscribe_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id) + subscribe_id = self._safe_int(selected.get("id") or target, 0) + if subscribe_id <= 0: + return { + "success": False, + "message": "操作订阅失败:请先发送“订阅列表”获取列表,再按编号操作,例如“搜索订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "missing_subscribe_id", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_subscribe(action=control, subscribe_id=subscribe_id) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_transfer_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status=status or "all", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_transfer_history", + "stage": "transfer_history", + "keyword": title or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "整理历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_transfer_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_history", + "status": result.get("status") or status, + "title": title, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + def _assistant_mp_detect_stage( + self, + *, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> str: + def has_term(value: Any, terms: List[str]) -> bool: + text = self._clean_text(value).lower() + return bool(text) and any(term in text for term in terms) + + failed_terms = ["失败", "错误", "error", "fail", "未识别", "识别失败", "入库失败", "整理失败"] + success_terms = ["成功", "完成", "已入库", "已整理", "已转移", "imported", "success", "completed"] + running_terms = ["处理中", "进行中", "转移中", "整理中", "入库中", "等待", "队列", "pending", "running"] + + for item in transfer_items: + if has_term(item.get("status_text") or item.get("status"), failed_terms): + return "failed" + for item in download_items: + if has_term(item.get("transfer_status_text") or item.get("transfer_status"), failed_terms): + return "failed" + + if task_items: + return "downloading" + + for item in transfer_items: + status_value = item.get("status_text") or item.get("status") + if has_term(status_value, success_terms): + return "imported" + if has_term(status_value, running_terms): + return "transferring" + + for item in download_items: + transfer_value = item.get("transfer_status_text") or item.get("transfer_status") + if has_term(transfer_value, success_terms): + return "imported" + if has_term(transfer_value, running_terms): + return "transferring" + + if transfer_items: + return "transferring" + if download_items: + return "downloaded" + return "not_found" + + def _assistant_mp_diagnosis_summary( + self, + *, + keyword: str, + hash_value: str, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + force_failed: bool = False, + ) -> Dict[str, Any]: + stage = "failed" if force_failed else self._assistant_mp_detect_stage( + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + matched_count = len(task_items) + len(download_items) + len(transfer_items) + matched = matched_count > 0 + evidence: List[str] = [] + risk_reasons: List[str] = [] + recommended_action = "" + follow_up_hint = "" + confidence = 0.0 + + if task_items: + first_task = task_items[0] + evidence.append( + f"下载任务 {self._clean_text(first_task.get('title')) or '-'} | " + f"{self._clean_text(first_task.get('progress')) or '-'} | " + f"{self._clean_text(first_task.get('state')) or '-'}" + ) + if download_items: + first_download = download_items[0] + evidence.append( + f"下载历史 {self._clean_text(first_download.get('title')) or '-'} | " + f"{self._clean_text(first_download.get('date')) or '-'} | " + f"{self._clean_text(first_download.get('transfer_status_text')) or '-'}" + ) + transfer_status_text = self._clean_text(first_download.get("transfer_status_text")) + if transfer_status_text and any(word in transfer_status_text for word in ["失败", "错误"]): + risk_reasons.append(f"下载历史显示整理状态异常:{transfer_status_text}") + if transfer_items: + first_transfer = transfer_items[0] + evidence.append( + f"整理历史 {self._clean_text(first_transfer.get('title')) or '-'} | " + f"{self._clean_text(first_transfer.get('status_text')) or '-'} | " + f"{self._clean_text(first_transfer.get('date')) or '-'}" + ) + status_text = self._clean_text(first_transfer.get("status_text")) + if status_text and any(word in status_text for word in ["失败", "错误"]): + risk_reasons.append(f"整理历史存在失败记录:{status_text}") + + if stage == "downloading": + confidence = 0.95 + recommended_action = "query_mp_download_tasks" + follow_up_hint = "资源仍在下载阶段,优先查看下载任务进度。" + elif stage == "downloaded": + confidence = 0.85 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已找到下载历史,但还没有明确的入库记录,下一步查看整理/入库历史。" + elif stage == "transferring": + confidence = 0.9 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "资源已经进入整理/入库链路,优先查看最近整理历史。" + elif stage == "imported": + confidence = 0.98 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已经找到成功入库线索,如需确认最终落地可查看最近整理历史。" + elif stage == "failed": + confidence = 0.92 if matched else 0.65 + recommended_action = "query_mp_local_diagnose" + follow_up_hint = "已发现失败线索,建议进入本地诊断汇总失败原因和下一步处理建议。" + elif stage == "not_found": + confidence = 0.95 + recommended_action = "query_mp_download_history" + follow_up_hint = "当前没有找到相关下载任务、下载历史或整理记录。先检查是否真的提交过下载/订阅。" + else: + confidence = 0.5 + recommended_action = "query_mp_lifecycle_status" + follow_up_hint = "状态不足以判断,建议继续查看生命周期聚合结果。" + + if hash_value: + evidence.append(f"Hash 检索:{hash_value}") + elif keyword: + evidence.append(f"关键词检索:{keyword}") + + return { + "status": "ok" if matched else "not_found", + "matched": matched, + "stage": stage, + "confidence": confidence, + "evidence": evidence[:6], + "risk_reasons": risk_reasons[:6], + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + } + + def _assistant_mp_diagnosis_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + hash_value: str, + preferred: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + } + keyword_body = dict(base_body) + if keyword: + keyword_body["keyword"] = keyword + if hash_value: + keyword_body["hash"] = hash_value + action_order = [preferred] + for name in ["query_mp_lifecycle_status", "query_mp_download_history", "query_mp_transfer_history", "query_mp_local_diagnose"]: + if name not in action_order: + action_order.append(name) + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_mp_lifecycle_status": "聚合查看下载任务、下载历史和整理/入库历史", + "query_mp_download_history": "查看下载历史,并确认是否已经提交过下载", + "query_mp_transfer_history": "查看整理/入库历史,确认是否已经落库或失败", + "query_mp_local_diagnose": "汇总本地/PT 链路的失败线索与处理建议", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**keyword_body, "name": name, "compact": True}, + ) + for name in action_order[:4] + ] + return action_order[:4], templates + + def _assistant_followup_command(self, action_name: str, *, keyword: str = "", hash_value: str = "") -> str: + target = self._clean_text(keyword or hash_value) + mapping = { + "query_execution_followup": "后续", + "query_mp_download_tasks": "下载任务", + "query_mp_subscribes": "订阅列表", + } + if action_name in mapping: + return mapping[action_name] + if action_name == "query_mp_ingest_status": + return f"入库 {target}".strip() + if action_name == "query_mp_lifecycle_status": + return f"状态 {target}".strip() + if action_name == "query_mp_download_history": + return f"记录 {target}".strip() + if action_name == "query_mp_transfer_history": + return f"入库记录 {target}".strip() + if action_name == "query_mp_local_diagnose": + return f"诊断 {target}".strip() + if action_name == "query_ai_failed_samples": + return f"失败样本 {target}".strip() if target else "失败样本" + if action_name == "query_ai_sample_worklist": + return f"工作清单 {target}".strip() if target else "工作清单" + if action_name == "query_ai_sample_insights": + return f"样本洞察 {target}".strip() if target else "样本洞察" + if action_name == "start_mp_media_search": + return f"MP搜索 {target}".strip() + return "" + + def _assistant_template_command( + self, + template: Dict[str, Any], + *, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> str: + if not isinstance(template, dict): + return "" + body = dict(template.get("action_body") or template.get("body") or {}) + action_name = self._clean_text(body.get("name")) or self._clean_text(template.get("name")) + target_text = self._clean_text(target or body.get("keyword") or body.get("hash") or body.get("target")) + command = self._assistant_followup_command( + action_name, + keyword=self._clean_text(keyword or body.get("keyword") or target_text), + hash_value=self._clean_text(hash_value or body.get("hash")), + ) + if command: + return command + if action_name == "query_mp_recent_activity": + return "最近" + if action_name == "execute_session_latest_plan": + return "执行计划" + if action_name == "mp_download_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停下载", + "resume": "恢复下载", + "delete": "删除下载", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "下载任务" + if action_name == "mp_subscribe_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停订阅", + "resume": "恢复订阅", + "delete": "删除订阅", + "search": "搜索订阅", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "订阅列表" + return "" + + def _assistant_error_summary( + self, + *, + error_code: str, + recommended_action: str = "", + message_head: str = "", + next_actions: Optional[List[Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> Dict[str, Any]: + code = self._clean_text(error_code) + if not code: + return {} + label_map = { + "latest_plan_not_executed": "当前会话还有待执行计划", + "plan_not_executed": "指定计划还没有执行", + "executed_plan_not_found": "当前没有可追踪的已执行计划", + "followup_not_available": "当前计划没有自动追踪动作", + "invalid_download_control_args": "下载任务参数不完整", + "download_target_not_found": "没有匹配到可操作的下载任务", + "invalid_subscribe_control_args": "订阅操作参数不完整", + "subscribe_target_not_found": "没有匹配到可操作的订阅", + } + hint_map = { + "latest_plan_not_executed": "先执行计划,再继续查看后续状态。", + "plan_not_executed": "先执行这条计划,再继续查看执行后追踪。", + "executed_plan_not_found": "先执行下载、订阅或转存计划,再查看后续。", + "followup_not_available": "当前计划只能改查状态、记录或最近活动。", + "invalid_download_control_args": "先发“下载任务”获取列表,再按编号暂停、恢复或删除。", + "download_target_not_found": "先发“下载任务”刷新列表,再按编号操作。", + "invalid_subscribe_control_args": "先发“订阅列表”获取列表,再按编号暂停、恢复或删除。", + "subscribe_target_not_found": "先发“订阅列表”刷新列表,再按编号操作。", + } + command_candidates: List[str] = [] + fallback_map = { + "latest_plan_not_executed": ["执行计划", "后续"], + "plan_not_executed": ["执行计划", "后续"], + "executed_plan_not_found": ["最近"], + "followup_not_available": [f"状态 {target}".strip(), f"记录 {target}".strip(), "最近"], + "invalid_download_control_args": ["下载任务"], + "download_target_not_found": ["下载任务"], + "invalid_subscribe_control_args": ["订阅列表"], + "subscribe_target_not_found": ["订阅列表"], + } + for command in fallback_map.get(code, []): + command_text = self._clean_text(command) + if command_text and command_text not in command_candidates: + command_candidates.append(command_text) + synthetic_templates: List[Dict[str, Any]] = [] + action_name = self._clean_text(recommended_action) + if action_name: + synthetic_templates.append({ + "name": action_name, + "action_body": { + "name": action_name, + "keyword": keyword, + "hash": hash_value, + "target": target, + }, + }) + synthetic_templates.extend( + item for item in (action_templates or []) if isinstance(item, dict) + ) + for item in synthetic_templates: + command = self._assistant_template_command( + item, + keyword=keyword, + hash_value=hash_value, + target=target, + ) + if command and command not in command_candidates: + command_candidates.append(command) + for name in [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)]: + command = self._assistant_followup_command(name, keyword=keyword or target, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + preferred_requires_confirmation = code in {"latest_plan_not_executed", "plan_not_executed"} + return { + "error_code": code, + "label": label_map.get(code, message_head or code), + "decision_hint": hint_map.get(code, message_head or "当前请求未完成,请先按建议补充上下文。"), + "command_policy": "confirm_then_resume" if preferred_requires_confirmation else "safe_read_recovery", + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": not preferred_requires_confirmation and bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + } + + def _format_quark_transfer_failure(self, detail: str = "", target_path: str = "") -> str: + text = self._clean_text(detail) + target = self._clean_text(target_path) or self._quark_default_path or "/飞书" + if "41031" in text or "分享者用户封禁链接查看受限" in text: + return ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + "建议改选同一列表里的其他夸克资源。" + ) + if "未获取到 stoken" in text: + try: + cookie_ok, _cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok = False + if cookie_ok: + return ( + "夸克转存失败:当前夸克登录态正常,但这条分享无法获取 stoken," + "更可能是分享链接失效、提取码错误或分享已受限。建议改选同一列表里的其他夸克资源。" + ) + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if "分享链接为空" in text or "无权查看内容" in text: + return ( + "夸克转存失败:当前分享内容为空或账号无权查看," + "更可能是分享链接失效、内容被取消分享或分享受限。建议改选其他资源。" + ) + if "require login [guest]" in text or "未配置夸克 Cookie" in text: + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text or text == f"无法转存到 {target}": + try: + cookie_ok, cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok, cookie_message = True, "" + cookie_text = self._clean_text(cookie_message) + if (not cookie_ok) and ( + "require login [guest]" in cookie_text + or "未配置夸克 Cookie" in cookie_text + or "cookie" in cookie_text.lower() + or "login" in cookie_text.lower() + ): + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text: + return ( + f"夸克转存失败:无法转存到 {target}。" + "当前原因未明,请先不要自行推断为路径问题,可稍后重试或先检查夸克登录态。" + ) + return f"夸克转存失败:{text}" + + @staticmethod + def _is_quark_share_restricted_message(detail: str) -> bool: + text = str(detail or "").strip() + if not text: + return False + return "41031" in text or "分享者用户封禁链接查看受限" in text + + async def _assistant_retry_pansou_quark_candidates( + self, + request: Request, + *, + items: List[Dict[str, Any]], + selected_index: int, + selected_url: str, + session: str, + target_path: str, + apikey: str, + ) -> Optional[Dict[str, Any]]: + candidates: List[Tuple[int, Dict[str, Any]]] = [] + for offset, raw_item in enumerate(items[selected_index:], start=selected_index + 1): + item = dict(raw_item or {}) + share_url = self._clean_text(item.get("url")) + if not share_url or not self._is_quark_url(share_url): + continue + if share_url == selected_url: + continue + candidates.append((offset, item)) + if len(candidates) >= 3: + break + + if not candidates: + return None + + for alt_index, alt_item in candidates: + alt_url = self._clean_text(alt_item.get("url")) + alt_code = self._clean_text(alt_item.get("password")) + alt_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": alt_url, + "access_code": alt_code, + "path": target_path, + "trigger": "Agent影视助手 盘搜夸克候选自动切换", + "apikey": apikey, + }) + ) + if alt_result.get("success"): + message = ( + f"原夸克链接已受限,已自动切换到同列表夸克候选 #{alt_index}。\n" + f"{str(alt_result.get('message') or '夸克转存成功')}" + ) + data = dict(alt_result.get("data") or {}) + data.update( + { + "provider": "quark", + "selected_choice": alt_index, + "auto_switched_from_choice": selected_index, + "auto_switched": True, + "selected_item": alt_item, + } + ) + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data=data), + } + + hint_choices = "、".join(f"#{idx}" for idx, _ in candidates) + return { + "success": False, + "message": ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + f"可改试同列表夸克候选 {hint_choices}。" + ), + "data": self._assistant_response_data( + session=session, + data={ + "provider": "quark", + "auto_switched": False, + "fallback_choices": [idx for idx, _ in candidates], + }, + ), + } + + def _assistant_followup_summary( + self, + *, + category: str, + stage: str, + recommended_action: str, + follow_up_hint: str, + next_actions: Optional[List[str]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + ) -> Dict[str, Any]: + action_names = [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)] + command_candidates: List[str] = [] + for name in action_names: + command = self._assistant_followup_command(name, keyword=keyword, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + label_map = { + "mp_download": "下载后追踪", + "mp_subscribe": "订阅后追踪", + "cloud_write": "云盘落库追踪", + "mp_diagnosis": "本地/PT 状态追踪", + "ai_reingest": "AI 二次识别重放追踪", + } + summary = { + "category": category, + "stage": self._clean_text(stage), + "label": label_map.get(category, category or "后续追踪"), + "preferred_action": self._clean_text(recommended_action), + "decision_hint": self._clean_text(follow_up_hint), + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + "next_actions": action_names[:4], + "action_templates_count": len([item for item in (action_templates or []) if isinstance(item, dict)]), + } + if category == "ai_reingest" and keyword: + summary.update({ + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "recommended_commands": ["诊断", "入库状态", "工作清单"], + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + }) + return summary + + @staticmethod + def _format_followup_summary_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + if not data: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(data.get("label")) + stage = AgentResourceOfficer._clean_text(data.get("stage")) + hint = AgentResourceOfficer._clean_text(data.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (data.get("compact_commands") or data.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"后续追踪:{label}{f' | {stage}' if stage else ''}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("可直接继续:" + " / ".join(commands)) + return lines + + @staticmethod + def _assistant_local_api_base_urls() -> List[str]: + return [ + "http://127.0.0.1:3001", + "http://127.0.0.1:3000", + "http://host.docker.internal:3000", + ] + + def _assistant_plugin_api_request( + self, + *, + plugin_name: str, + path: str, + query: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + method: str = "GET", + timeout: int = 15, + ) -> Dict[str, Any]: + token = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not token: + return {"success": False, "message": "服务端未配置 API Token"} + safe_path = "/" + self._clean_text(path).lstrip("/") + query_payload = dict(query or {}) + query_payload["apikey"] = token + last_error = "" + payload_bytes: Optional[bytes] = None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + payload_bytes = json.dumps(dict(body or {}), ensure_ascii=False).encode("utf-8") + for base_url in self._assistant_local_api_base_urls(): + try: + url = f"{base_url}/api/v1/plugin/{plugin_name}{safe_path}?{urlencode(query_payload)}" + request = UrlRequest( + url=url, + data=payload_bytes, + headers=headers, + method=(method or "GET").upper(), + ) + with urlopen(request, timeout=max(5, self._safe_int(timeout, 15))) as response: + raw = response.read().decode("utf-8", errors="ignore") + payload = json.loads(raw or "{}") + if isinstance(payload, dict): + return payload + last_error = f"插件 {plugin_name} 返回了非字典 JSON" + except Exception as exc: + last_error = str(exc) + return { + "success": False, + "message": f"调用 {plugin_name}{safe_path} 失败:{last_error or '未知错误'}", + } + + @staticmethod + def _assistant_ai_recognizer_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + return running_plugins.get("AIRecognizerEnhancer") + + def _assistant_ai_failed_samples_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/failed_samples", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_worklist_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_summarize_sample"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + worklist = [plugin._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 工作清单失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_worklist", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_insights_payload(self, *, limit: int, top: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_build_sample_insights"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 200)))) + insights = plugin._build_sample_insights(samples, top=max(1, min(top, 20))) + return { + "success": True, + "data": insights, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 样本洞察失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_insights", + query={"limit": max(1, min(limit, 200)), "top": max(1, min(top, 20))}, + method="GET", + ) + + def _assistant_ai_replay_failed_sample_payload( + self, + *, + sample_index: int, + remove_if_resolved: bool, + ) -> Dict[str, Any]: + body = { + "sample_index": max(0, self._safe_int(sample_index, 0)), + "remove_if_resolved": bool(remove_if_resolved), + } + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_replay_failed_sample"): + try: + result = plugin._replay_failed_sample(body) + if isinstance(result, dict): + return result + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 重放 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/replay_failed_sample", + method="POST", + body=body, + ) + + def _assistant_ai_sample_by_index(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + for item in items: + current = item if isinstance(item, dict) else {} + if self._safe_int(current.get("sample_index"), 0) == desired: + return dict(current) + if self._safe_int(current.get("index"), 0) == desired: + return dict(current) + return {} + + @staticmethod + def _assistant_quarkdisk_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + plugin = running_plugins.get("QuarkDisk") or running_plugins.get("quarkdisk") + if plugin is not None: + return plugin + for candidate in running_plugins.values(): + if candidate is None: + continue + if candidate.__class__.__name__ == "QuarkDisk": + return candidate + if getattr(candidate, "plugin_name", "") == "夸克网盘存储": + return candidate + return None + + def _assistant_quarkdisk_clear_directory(self, path: str) -> Optional[Tuple[bool, Dict[str, Any], str]]: + plugin = self._assistant_quarkdisk_plugin() + if plugin is None or app_schemas is None: + return None + try: + storage_name = self._clean_text(getattr(plugin, "_disk_name", "")) or "夸克网盘" + clear_cache = getattr(plugin, "clear_cache", None) + + def _safe_clear_cache() -> None: + if callable(clear_cache): + try: + clear_cache() + except Exception: + pass + + def _resolve_folder() -> Any: + resolved = None + if hasattr(plugin, "get_folder"): + resolved = plugin.get_folder(storage=storage_name, path=Path(path)) + if resolved is None and hasattr(plugin, "get_file_item"): + resolved = plugin.get_file_item(storage=storage_name, path=Path(path)) + return resolved + + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 未定位到目录,改走直连夸克接口确认" + + items = [] + if hasattr(plugin, "list_files"): + items = plugin.list_files(folder, recursion=False) or [] + if not items: + _safe_clear_cache() + folder = _resolve_folder() or folder + items = plugin.list_files(folder, recursion=False) or [] + files = [item for item in items if getattr(item, "type", "") != "dir"] + folders = [item for item in items if getattr(item, "type", "") == "dir"] + if not items: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 返回空列表,改走直连夸克接口确认" + + removed_count = 0 + failed_items: List[str] = [] + for item in items: + ok = plugin.delete_file(item) if hasattr(plugin, "delete_file") else None + if ok: + removed_count += 1 + continue + item_path = self._clean_text(getattr(item, "path", "")) or self._clean_text(getattr(item, "name", "")) + if item_path: + failed_items.append(item_path) + result = { + "target_path": path, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self._clean_text(getattr(item, "name", "")) for item in items[:20] if self._clean_text(getattr(item, "name", ""))], + "failed_items": failed_items[:20], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + } + if failed_items: + return False, result, f"夸克网盘存储删除失败:{len(failed_items)} 项未删除" + return True, result, "success" + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 调用 quarkdisk 清空目录失败,改走直连夸克接口:{exc}") + return None + + def _assistant_ai_sample_reference(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + cached = self._assistant_ai_sample_by_index(cache_key=cache_key, sample_index=desired) + if cached: + return cached + for payload in ( + self._assistant_ai_failed_samples_payload(limit=1000), + self._assistant_ai_sample_worklist_payload(limit=1000), + ): + rows = [] + if isinstance((payload or {}).get("data"), dict): + rows = (payload.get("data") or {}).get("samples") or [] + if not isinstance(rows, list): + continue + for item in rows: + current = dict(item or {}) + if self._safe_int(current.get("sample_index") or current.get("index"), 0) == desired: + return current + return {} + + def _assistant_ai_replay_target_title(self, *, sample: Dict[str, Any], target: Dict[str, Any]) -> str: + current_sample = dict(sample or {}) + current_target = dict(target or {}) + return ( + self._clean_text(current_target.get("name")) + or self._clean_text(current_sample.get("verified_title")) + or self._clean_text(current_sample.get("guess_name")) + or self._clean_text(current_sample.get("title")) + or self._clean_text(current_sample.get("path")) + ) + + def _assistant_ai_sample_matches(self, sample: Dict[str, Any], keyword: str) -> bool: + needle = self._clean_text(keyword).lower() + if not needle: + return True + target = sample.get("inferred_target") if isinstance(sample.get("inferred_target"), dict) else {} + fields = [ + sample.get("title"), + sample.get("path"), + sample.get("reason"), + sample.get("guess_name"), + sample.get("verified_title"), + target.get("name"), + ] + for value in fields: + text = self._clean_text(value).lower() + if text and needle in text: + return True + return False + + def _assistant_ai_filtered_rows(self, rows: List[Dict[str, Any]], keyword: str) -> List[Dict[str, Any]]: + if not keyword: + return [dict(item or {}) for item in rows] + return [ + dict(item or {}) + for item in rows + if self._assistant_ai_sample_matches(dict(item or {}), keyword) + ] + + def _assistant_ai_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + "keyword": keyword, + "compact": True, + } + action_order = [ + "query_ai_sample_worklist", + "query_ai_sample_insights", + "query_ai_failed_samples", + "query_mp_local_diagnose", + ] + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_ai_sample_worklist": "查看 AI 识别失败样本工作清单,适合先挑目标", + "query_ai_sample_insights": "查看 AI 失败样本洞察,适合看重复样本和优先处理项", + "query_ai_failed_samples": "查看 AI 原始失败样本列表,适合核对标题、路径和失败原因", + "query_mp_local_diagnose": "回到本地/PT 诊断链,继续看入库失败阶段和证据", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": name}, + ) + for name in action_order + ] + return action_order, templates + + def _assistant_ai_sample_brief_lines( + self, + rows: List[Dict[str, Any]], + *, + limit: int = 5, + ) -> List[str]: + lines: List[str] = [] + for item in rows[: max(1, min(limit, 10))]: + label = ( + self._clean_text( + ((item.get("inferred_target") or {}).get("name")) + if isinstance(item.get("inferred_target"), dict) + else "" + ) + or self._clean_text(item.get("verified_title")) + or self._clean_text(item.get("guess_name")) + or self._clean_text(item.get("title")) + or "未命名样本" + ) + confidence = round(self._safe_float(item.get("guess_confidence"), 0.0), 2) + reason = self._clean_text(item.get("reason")) or "-" + lines.append( + f"{self._safe_int(item.get('sample_index'), 0)}. {label} | 置信度 {confidence} | {reason}" + ) + return lines + + def _assistant_mp_recent_activity_summary( + self, + *, + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> Dict[str, Any]: + evidence: List[str] = [] + for item in download_items[:3]: + evidence.append( + f"下载 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('transfer_status_text')) or '-'}" + ) + for item in transfer_items[:3]: + evidence.append( + f"入库 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('status_text')) or '-'}" + ) + return { + "status": "ok" if (download_items or transfer_items) else "not_found", + "matched": bool(download_items or transfer_items), + "stage": "unknown", + "confidence": 0.8 if (download_items or transfer_items) else 0.95, + "evidence": evidence[:6], + "risk_reasons": [], + "recommended_action": "query_mp_lifecycle_status", + "follow_up_hint": "先从最近活动里挑目标,再继续看单个资源的生命周期或本地诊断。", + } + + async def _assistant_mp_lifecycle_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(10, self._safe_int(limit, 5))) + channel = self._ensure_feishu_channel() + task_result = channel._query_download_tasks( + status="all", + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + download_result = channel._query_download_history( + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + transfer_result = channel._query_transfer_history( + title=title, + status="all", + limit=safe_limit, + ) + task_items = task_result.get("items") if isinstance(task_result.get("items"), list) else [] + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + keyword = title or hash_value or "全部" + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value=hash_value, + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value=hash_value, + preferred=self._clean_text(diagnosis_summary.get("recommended_action")) or "query_mp_download_history", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword=title, + hash_value=hash_value, + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = [f"MP 生命周期追踪:{keyword}"] + lines.append( + f"结论:{diagnosis_summary.get('stage') or 'unknown'} | " + f"置信度 {int(float(diagnosis_summary.get('confidence') or 0) * 100)}%" + ) + lines.append(f"活动下载任务:{len(task_items)} 条;下载历史:{download_result.get('total', len(download_items))} 条;整理历史:{transfer_result.get('total', len(transfer_items))} 条") + if task_items: + lines.append("下载任务:") + for item in task_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} | {item.get('progress') or '-'} | {item.get('state') or '-'} | Hash:{item.get('hash_short') or '-'}") + if download_items: + lines.append("下载历史:") + for item in download_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('date') or '-'} | {item.get('transfer_status_text') or '-'} | Hash:{item.get('download_hash_short') or '-'}") + if transfer_items: + lines.append("整理/入库历史:") + for item in transfer_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('status_text') or '-'} | {item.get('date') or '-'}") + if not task_items and not download_items and not transfer_items: + lines.append("未找到相关任务、下载历史或整理历史。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + lines.append("说明:这是只读聚合查询,用于判断资源处于搜索后、下载中、已下载还是已落库阶段。") + self._save_session(cache_key, { + "kind": "assistant_mp_lifecycle_status", + "stage": "lifecycle_status", + "keyword": keyword, + "items": { + "download_tasks": task_items, + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + ok = bool(task_result.get("success")) and bool(download_result.get("success")) and bool(transfer_result.get("success")) + return { + "success": ok, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_lifecycle_status", + "ok": ok, + "source_type": "moviepilot_lifecycle_status", + "title": title, + "hash": hash_value, + "download_tasks": { + "ok": bool(task_result.get("success")), + "total": self._safe_int(task_result.get("total"), len(task_items)), + "items": task_items, + }, + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_ingest_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + lines = [ + f"本地/PT 入库状态:{self._clean_text(title or hash_value) or '全部'}", + f"当前阶段:{stage}", + ] + if diagnosis_summary.get("evidence"): + lines.append("证据:") + for item in (diagnosis_summary.get("evidence") or [])[:5]: + lines.append(f"- {item}") + if diagnosis_summary.get("risk_reasons"): + lines.append("风险:") + for item in (diagnosis_summary.get("risk_reasons") or [])[:5]: + lines.append(f"- {item}") + lines.extend(self._format_followup_summary_lines(diagnosis_summary.get("followup_summary"))) + lifecycle_data["action"] = "mp_ingest_status" + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(lines), + "data": lifecycle_data, + } + + async def _assistant_mp_ingest_failures( + self, + *, + session: str, + cache_key: str, + title: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status="failed", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value="", + task_items=[], + download_items=[], + transfer_items=items, + force_failed=bool(items), + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value="", + preferred="query_mp_local_diagnose", + ) + lines = [f"本地/PT 失败线索:{self._clean_text(title) or '最近失败'}"] + if items: + for item in items[: min(len(items), 6)]: + lines.append( + f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | " + f"{item.get('status_text') or '-'} | {item.get('date') or '-'}" + ) + else: + lines.append("未找到匹配的整理/入库失败记录。") + self._save_session(cache_key, { + "kind": "assistant_mp_ingest_failures", + "stage": "ingest_failures", + "keyword": title or "failed", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_ingest_failures", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_failures", + "title": title, + "status": "failed", + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + "diagnosis_summary": diagnosis_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_ai_failed_samples( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_failed_samples_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="工作清单", + ) + lines = [f"AI 失败样本:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 失败样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_failed_samples", + "stage": "ai_failed_samples", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_failed_samples", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先看工作清单或样本洞察,再决定是否进入二次识别重放。", + }), + } + + async def _assistant_ai_sample_worklist( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_sample_worklist_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="样本洞察", + ) + lines = [f"AI 工作清单:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 工作清单样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_worklist", + "stage": "ai_sample_worklist", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_worklist", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_insights", + "follow_up_hint": "先看样本洞察确认哪些失败样本最值得重放或生成识别词。", + }), + } + + async def _assistant_ai_sample_insights( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 20, + top: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(100, self._safe_int(limit, 20))) + safe_top = max(1, min(10, self._safe_int(top, 5))) + result = self._assistant_ai_sample_insights_payload(limit=safe_limit, top=safe_top) + insights = dict((result.get("data") or {})) if isinstance(result.get("data"), dict) else {} + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + priority_samples = insights.get("priority_samples") if isinstance(insights.get("priority_samples"), list) else [] + decision_summary = self._assistant_ai_reingest_decision_summary( + items=priority_samples, + fallback_command="工作清单", + ) + lines = [f"AI 样本洞察:{self._clean_text(keyword) or '全局'}"] + total_count = self._safe_int(insights.get("total_count"), 0) + if total_count > 0: + lines.append(f"样本总数:{total_count}") + reason_counts = insights.get("reason_counts") if isinstance(insights.get("reason_counts"), list) else [] + if reason_counts: + lines.append("主要失败原因:") + for item in reason_counts[:safe_top]: + lines.append(f"- {self._clean_text(item.get('reason')) or '-'}:{self._safe_int(item.get('count'), 0)}") + repeated_groups = insights.get("repeated_groups") if isinstance(insights.get("repeated_groups"), list) else [] + if repeated_groups: + lines.append("重复出现的样本组:") + for item in repeated_groups[:safe_top]: + lines.append(f"- {self._clean_text(item.get('title')) or '-'}:{self._safe_int(item.get('count'), 0)}") + if priority_samples: + lines.append("优先处理样本:") + lines.extend(self._assistant_ai_sample_brief_lines(priority_samples, limit=safe_top)) + else: + lines.append("当前没有可分析的 AI 失败样本洞察。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_insights", + "stage": "ai_sample_insights", + "keyword": keyword or "all", + "items": insights, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_insights", + "ok": bool(result.get("success")), + "keyword": keyword, + "insights": insights, + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先挑优先样本,再确认是否进入 AI 二次识别重放。", + }), + } + + def _assistant_ai_replay_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + next_actions: List[str] = [ + "query_ai_sample_worklist", + "query_ai_failed_samples", + "query_ai_sample_insights", + ] + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + base_body = { + "session": session, + "session_id": session_id, + "compact": True, + } + templates: List[Dict[str, Any]] = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="继续查看这次二次识别相关的本地/PT 入库诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_local_diagnose", "keyword": keyword, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_ingest_status", "keyword": keyword, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续挑需要重放的失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,核对标题、路径和失败原因", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,判断失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + return next_actions, templates + + def _assistant_ai_reingest_decision_summary( + self, + *, + items: List[Dict[str, Any]], + fallback_command: str, + ) -> Dict[str, Any]: + fallback = self._clean_text(fallback_command) + first_index = 0 + for item in items or []: + first_index = self._safe_int((item or {}).get("index"), 0) + if first_index > 0: + break + if first_index <= 0: + return { + "decision_mode": "show_detail", + "decision_reason": "当前没有可直接重放的 AI 失败样本。", + "preferred_command": fallback, + "fallback_command": "", + "compact_commands": [fallback] if fallback else [], + "recommended_agent_behavior": "show_only" if fallback else "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": fallback, + } + replay_command = f"重放 {first_index}" + return { + "decision_mode": "make_plan", + "decision_reason": f"先为样本 #{first_index} 生成二次识别重放计划,再确认是否实际重放。", + "preferred_command": replay_command, + "fallback_command": fallback, + "compact_commands": [item for item in [replay_command, fallback] if item], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": replay_command, + "confirm_command": "确认", + "display_command": replay_command, + } + + def _assistant_ai_replay_sample_plan_response( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + reference = self._assistant_ai_sample_reference(cache_key=cache_key, sample_index=desired) + if not reference: + return { + "success": False, + "message": "未找到对应的 AI 失败样本。请先发送“工作清单”或“失败样本”查看当前样本编号。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "sample_not_found", + "sample_index": desired, + }), + } + title = self._assistant_ai_replay_target_title( + sample=reference, + target=(reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}), + ) + return self._save_assistant_pick_plan_response( + workflow="ai_replay_failed_sample", + session=session, + session_id=cache_key, + actions=[{ + "name": "replay_ai_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + }], + execute_body={ + "workflow": "ai_replay_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "dry_run": False, + }, + message="AI 二次识别重放计划已生成", + extra_data={ + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "source_sample": reference, + "target": reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}, + "keyword": title, + "decision_summary": { + "decision_mode": "execute_now", + "decision_reason": ( + "AI 二次识别重放计划已生成,确认后才会实际重放并尝试重新识别。" + if bool(remove_if_resolved) + else "AI 二次识别重放计划已生成,确认后会实际重放,但保留原失败样本。" + ), + "preferred_command": "确认", + "fallback_command": "工作清单", + "compact_commands": ["确认", "工作清单"], + "command_policy": "confirm_then_resume", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": "确认", + "display_command": "确认", + }, + }, + ) + + def _assistant_ai_replay_execution_decision_summary( + self, + *, + ok: bool, + resolved: bool, + has_title: bool, + ) -> Dict[str, Any]: + if ok and resolved and has_title: + return { + "decision_mode": "show_detail", + "decision_reason": "这次二次识别已命中目标,下一步先看本地诊断确认是否已消除失败,再决定是否继续整理。", + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + } + if ok: + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放已完成,但暂未命中目标。先回看工作清单和样本洞察,再决定是否继续重放或补识别词。", + "preferred_command": "工作清单", + "fallback_command": "样本洞察", + "compact_commands": ["工作清单", "样本洞察"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "工作清单", + "confirm_command": "", + "display_command": "工作清单", + } + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放没有成功执行。先回看工作清单或失败样本,确认当前还能处理哪些样本。", + "preferred_command": "工作清单", + "fallback_command": "失败样本", + "compact_commands": ["工作清单", "失败样本"], + "command_policy": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": "工作清单", + } + + async def _assistant_ai_replay_failed_sample( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + result = self._assistant_ai_replay_failed_sample_payload( + sample_index=desired, + remove_if_resolved=bool(remove_if_resolved), + ) + ok = bool(result.get("success")) + payload = dict(result.get("data") or {}) if isinstance(result.get("data"), dict) else {} + source_sample = dict(payload.get("source_sample") or {}) if isinstance(payload.get("source_sample"), dict) else {} + target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} + title = self._assistant_ai_replay_target_title(sample=source_sample, target=target) + next_actions, action_templates = self._assistant_ai_replay_followups( + session=session, + session_id=cache_key, + keyword=title, + ) + if ok: + self._save_session(cache_key, { + "kind": "assistant_ai_replay", + "stage": "ai_replay_failed_sample", + "keyword": title or f"sample-{desired}", + "items": [source_sample] if source_sample else [], + "target_path": "", + "sample_index": desired, + "replay_result": { + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + }, + }) + lines = [f"AI 二次识别重放:样本 #{desired}"] + if title: + lines.append(f"目标:{title}") + if ok: + lines.append("结果:" + ("已命中目标并完成重放" if bool(payload.get("resolved")) else "已完成重放,但暂未命中目标")) + if payload.get("resolved_by_identifiers"): + lines.append("来源:当前识别词已直接命中目标。") + elif payload.get("resolved_by_recognizer"): + lines.append("来源:识别器重跑后命中了目标。") + if "sample_removed" in payload: + lines.append("样本移除:" + ("已移除" if payload.get("sample_removed") else "未移除")) + else: + lines.append(self._clean_text(result.get("message")) or "重放失败") + resolved = bool(payload.get("resolved")) + recommended_action = "query_mp_local_diagnose" if ok and resolved and title else "query_ai_sample_worklist" + follow_up_hint = ( + "先回到本地诊断或入库状态,确认这次重放是否已经消除失败。" + if ok and resolved and title + else "先看工作清单或失败样本,确认是否还有可重放样本。" + ) + decision_summary = self._assistant_ai_replay_execution_decision_summary( + ok=ok, + resolved=resolved, + has_title=bool(title), + ) + return { + "success": ok, + "message": "\n".join(line for line in lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": ok, + "sample_index": desired, + "source_sample": source_sample, + "target": target, + "identifier_preview": payload.get("identifier_preview") if isinstance(payload.get("identifier_preview"), dict) else {}, + "recognize_result": payload.get("recognize_result") if isinstance(payload.get("recognize_result"), dict) else {}, + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + "sample_removal_result": payload.get("sample_removal_result") if isinstance(payload.get("sample_removal_result"), dict) else {}, + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + "decision_summary": decision_summary, + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_recent_activity( + self, + *, + session: str, + cache_key: str, + limit: int = 10, + download_only: bool = False, + transfer_only: bool = False, + ) -> Dict[str, Any]: + safe_limit = max(1, min(20, self._safe_int(limit, 10))) + channel = self._ensure_feishu_channel() + download_result = {"success": True, "items": [], "total": 0} + transfer_result = {"success": True, "items": [], "total": 0} + if not transfer_only: + download_result = channel._query_download_history(title="", hash_value="", limit=safe_limit, page=1) + if not download_only: + transfer_result = channel._query_transfer_history(title="", status="all", limit=safe_limit, page=1) + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_recent_activity_summary( + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword="", + hash_value="", + preferred="query_mp_lifecycle_status", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword="", + hash_value="", + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = ["最近本地/PT 活动:"] + if download_items: + lines.append(f"最近下载:{len(download_items)} 条") + if transfer_items: + lines.append(f"最近入库:{len(transfer_items)} 条") + if not download_items and not transfer_items: + lines.append("当前没有可展示的最近下载或入库活动。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + self._save_session(cache_key, { + "kind": "assistant_mp_recent_activity", + "stage": "recent_activity", + "keyword": "recent_activity", + "items": { + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + return { + "success": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recent_activity", + "ok": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_local_diagnose( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + risk_reasons = diagnosis_summary.get("risk_reasons") or [] + evidence = diagnosis_summary.get("evidence") or [] + ai_worklist = await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=title, + limit=max(5, limit), + ) + ai_data = dict((ai_worklist or {}).get("data") or {}) + ai_items = ai_data.get("items") if isinstance(ai_data.get("items"), list) else [] + if ai_items: + evidence.append(f"AI 失败样本命中 {len(ai_items)} 条") + risk_reasons.append("存在可用于二次识别重放的 AI 失败样本") + message_lines = [ + f"本地诊断:{self._clean_text(title or hash_value) or '全部'}", + f"判断阶段:{stage}", + f"是否命中记录:{'是' if diagnosis_summary.get('matched') else '否'}", + ] + if evidence: + message_lines.append("诊断证据:") + for item in evidence[:6]: + message_lines.append(f"- {item}") + if risk_reasons: + message_lines.append("风险与失败线索:") + for item in risk_reasons[:6]: + message_lines.append(f"- {item}") + if ai_items: + message_lines.append("AI 失败样本:") + for item in self._assistant_ai_sample_brief_lines(ai_items, limit=3): + message_lines.append(f"- {item}") + if diagnosis_summary.get("follow_up_hint"): + message_lines.append(f"建议动作:{diagnosis_summary.get('follow_up_hint')}") + next_actions = [] + for name in lifecycle_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + for name in ai_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + action_templates = [] + for item in (lifecycle_data.get("action_templates") or []) + (ai_data.get("action_templates") or []): + if isinstance(item, dict): + action_templates.append(item) + diagnosis_summary["risk_reasons"] = risk_reasons[:6] + diagnosis_summary["evidence"] = evidence[:6] + lifecycle_data["diagnosis_summary"] = diagnosis_summary + lifecycle_data["action"] = "mp_local_diagnose" + lifecycle_data["ai_sample_worklist"] = { + "ok": bool(ai_data.get("ok")), + "count": self._safe_int(ai_data.get("count"), len(ai_items)), + "items": ai_items[:10], + } + lifecycle_data["next_actions"] = next_actions[:6] + lifecycle_data["action_templates"] = action_templates[:6] + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(message_lines), + "data": lifecycle_data, + } + + async def _assistant_mp_recommendations( + self, + *, + source: str = "tmdb_trending", + media_type: str = "all", + limit: int = 20, + session: str = "default", + cache_key: str = "", + ) -> Dict[str, Any]: + try: + from app.chain.recommend import RecommendChain + from app.schemas.types import MediaType, media_type_to_agent + except Exception as exc: + return { + "success": False, + "message": f"MP 推荐失败:当前环境缺少推荐依赖 {exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + max_limit = max(1, min(50, self._safe_int(limit, 20))) + source_name = self._clean_text(source) or "tmdb_trending" + media_type_name = self._clean_text(media_type) or "all" + chain = RecommendChain() + try: + def collect_items(raw_results: List[Dict[str, Any]], media_type_filter: str = "") -> List[Dict[str, Any]]: + current_media_type = media_type_filter or media_type_name + collected = [] + for raw_item in (raw_results or [])[:max_limit]: + if not isinstance(raw_item, dict): + continue + item_type = raw_item.get("type") + if current_media_type != "all": + enum_type = MediaType.from_agent(current_media_type) + agent_type = media_type_to_agent(item_type) + expected_types = {current_media_type} + if current_media_type == "movie": + expected_types.add("电影") + elif current_media_type == "tv": + expected_types.update({"电视剧", "剧集"}) + if enum_type and item_type != enum_type and agent_type not in expected_types: + continue + collected.append({ + "index": len(collected) + 1, + "title": raw_item.get("title"), + "year": raw_item.get("year"), + "type": media_type_to_agent(item_type), + "tmdb_id": raw_item.get("tmdb_id"), + "douban_id": raw_item.get("douban_id"), + "vote_average": raw_item.get("vote_average"), + "poster_path": raw_item.get("poster_path"), + "detail_link": raw_item.get("detail_link"), + }) + return collected + + results: List[Dict[str, Any]] = [] + if source_name == "tmdb_trending": + results = await chain.async_tmdb_trending(page=1) + elif source_name == "tmdb_movies": + results = await chain.async_tmdb_movies(page=1) + elif source_name == "tmdb_tvs": + results = await chain.async_tmdb_tvs(page=1) + elif source_name in {"douban_hot", "douban_movie_hot"}: + results = await chain.async_douban_movie_hot(page=1, count=max_limit) + if source_name == "douban_hot" and media_type_name in {"all", "tv"}: + results.extend(await chain.async_douban_tv_hot(page=1, count=max_limit)) + elif source_name == "douban_tv_hot": + results = await chain.async_douban_tv_hot(page=1, count=max_limit) + elif source_name == "douban_movie_showing": + results = await chain.async_douban_movie_showing(page=1, count=max_limit) + elif source_name == "douban_movie_top250": + results = await chain.async_douban_movie_top250(page=1, count=max_limit) + elif source_name == "douban_tv_animation": + results = await chain.async_douban_tv_animation(page=1, count=max_limit) + elif source_name == "bangumi_calendar": + results = await chain.async_bangumi_calendar(page=1, count=max_limit) + else: + return { + "success": False, + "message": f"不支持的推荐来源:{source_name}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + items = collect_items(results) + fallback_source = "" + if not items and source_name != "tmdb_trending": + fallback_source = "tmdb_trending" + fallback_media_type = media_type_name if media_type_name in {"movie", "tv"} else "all" + items = collect_items(await chain.async_tmdb_trending(page=1), fallback_media_type) + display_source = fallback_source or source_name + lines = [f"MP 热门推荐:{display_source},共 {len(items)} 条"] + if fallback_source: + lines.append(f"注:{source_name} 当前暂无结果,已自动回退 {fallback_source}。") + for item in items[:10]: + lines.append(f"{item.get('index')}. {item.get('title') or '-'} ({item.get('year') or '-'}) | {item.get('type') or '-'} | 评分 {item.get('vote_average') or '-'}") + lines.append("下一步:回复“选择 1 决策”进入统一资源决策。") + lines.append("如果已经明确意图,也可以直接发“选择 1 计划”或“选择 1 确认”;也支持直接回复“详情”“计划”“确认”,默认作用于当前榜单首项。") + lines.append("如果想走单源,也可以回复“选择 1”进入 MP 原生搜索,或“选择 1 影巢”“选择 1 盘搜”。") + decision_summary = self._assistant_mp_recommendation_decision_summary() + if cache_key: + self._save_session(cache_key, { + "kind": "assistant_mp_recommend", + "stage": "result", + "source": fallback_source or source_name, + "requested_source": source_name, + "media_type": media_type_name, + "keyword": "", + "items": items, + "target_path": "", + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": True, + "source_type": "moviepilot_recommendation", + "source": fallback_source or source_name, + "requested_source": source_name, + "fallback_source": fallback_source, + "media_type": media_type_name, + "items": items, + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }), + } + except Exception as exc: + logger.error(f"MP 推荐失败:{source_name} {exc}", exc_info=True) + return { + "success": False, + "message": f"MP 推荐失败:{exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + + def _format_mp_recommend_item_detail_text(self, item: Dict[str, Any]) -> str: + title = self._clean_text(item.get("title")) or "-" + year = self._clean_text(item.get("year")) or "-" + media_type = self._clean_text(item.get("type")) or "-" + tmdb_id = self._clean_text(item.get("tmdb_id")) or "-" + douban_id = self._clean_text(item.get("douban_id")) or "-" + vote = self._clean_text(item.get("vote_average")) or "-" + lines = [ + "MP 推荐条目详情", + f"标题:{title}", + f"年份:{year}", + f"类型:{media_type}", + f"评分:{vote}", + f"TMDB:{tmdb_id}", + f"豆瓣:{douban_id}", + "下一步:回复“决策”“计划”“确认”,或“盘搜”“影巢”“原生”。", + ] + return "\n".join(lines) + + def _assistant_mp_recommendation_decision_summary(self) -> Dict[str, Any]: + return { + "decision_mode": "show_detail", + "decision_reason": "推荐列表默认先看当前榜单首项详情,再决定是否生成计划或直接确认执行。", + "decision_hint": "当前推荐会话支持直接对首项继续:详情 / 决策 / 计划 / 确认,也支持切到盘搜 / 影巢 / 原生。", + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "show_only", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + } + + def _assistant_recommend_handoff_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + current_state = dict(state or {}) + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + selected_index = self._safe_int(handoff.get("selected_index"), 0) + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + return { + "source": self._clean_text(handoff.get("source")), + "requested_source": self._clean_text(handoff.get("requested_source")), + "media_type": self._clean_text(handoff.get("media_type")), + "selected_index": selected_index if selected_index > 0 else None, + "selected_title": self._clean_text(selected_item.get("title")), + "return_short_command": "回推荐", + "source_short_commands": { + "pansou": "盘搜", + "hdhive": "影巢", + "mp": "原生", + }, + } + + def _assistant_recommend_handoff_state( + self, + *, + source: str, + requested_source: str, + media_type: str, + selected_index: int, + selected_item: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "source": self._clean_text(source), + "requested_source": self._clean_text(requested_source or source), + "media_type": self._clean_text(media_type or "all"), + "selected_index": max(0, self._safe_int(selected_index, 0)), + "return_short_command": "回推荐", + } + if isinstance(selected_item, dict) and selected_item: + payload["selected_item"] = dict(selected_item) + return payload + + async def _assistant_restore_mp_recommendation_handoff( + self, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + limit: int = 20, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + result = await self._assistant_mp_recommendations( + source=self._clean_text(handoff.get("requested_source") or handoff.get("source") or "tmdb_trending"), + media_type=self._clean_text(handoff.get("media_type") or "all"), + limit=limit, + session=session, + cache_key=cache_key, + ) + if not result.get("success"): + return result + selected_index = max(0, self._safe_int(handoff.get("selected_index"), 0)) + if selected_index > 0: + refreshed_state = self._load_session(cache_key) or {} + items = refreshed_state.get("items") if isinstance(refreshed_state.get("items"), list) else [] + if selected_index <= len(items): + self._save_session(cache_key, { + **refreshed_state, + "selected_index": selected_index, + "selected_item": dict(items[selected_index - 1] or {}), + }) + message = str(result.get("message") or "").strip() + if selected_index > 0: + message = f"{message}\n已返回推荐列表,默认仍指向第 {selected_index} 项。".strip() + result["message"] = message + payload = dict(result.get("data") or {}) + if selected_index > 0: + payload["selected_index"] = selected_index + payload["return_short_command"] = "" + result["data"] = self._assistant_response_data(session=session, data=payload) + return result + + async def _assistant_switch_recommend_handoff_source( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可切换的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法切换源。请先发送:回推荐。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": self._clean_text(mode), + "keyword": keyword, + "media_type": media_type, + "recommend_handoff": dict(handoff), + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_run_recommend_source_compound( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + followup_action: str, + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + mode = self._clean_text(mode).lower() + followup_action = self._clean_text(followup_action) + kind = self._clean_text(state.get("kind")) + if mode not in {"pansou", "mp"}: + return { + "success": False, + "message": "当前只支持 盘搜/原生 的单条详情、计划、确认命令。", + "data": self._assistant_response_data(session=session, data={ + "action": "recommend_source_compound", + "ok": False, + "error_code": "unsupported_recommend_source_compound_mode", + }), + } + + if kind == "assistant_mp_recommend": + selected_index = max(1, self._safe_int(state.get("selected_index"), 0) or 1) + source_result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": selected_index, + "mode": mode, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if not source_result.get("success"): + return source_result + else: + kind_mode = { + "assistant_pansou": "pansou", + "assistant_mp": "mp", + "assistant_hdhive": "hdhive", + }.get(kind, "") + if kind_mode != mode: + source_result = await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=mode, + ) + if not source_result.get("success"): + return source_result + + return await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": 0, + "action": followup_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_route_recommend_handoff_to_smart_decision( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法回到统一资源决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": "smart_decision", + "keyword": keyword, + "media_type": media_type, + "origin": "mp_recommend", + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_confirm_recommend_handoff( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + pending_plan = self._find_workflow_plan( + session=session, + session_id=cache_key, + executed=False, + ) + handoff_state = dict(state or {}) + if pending_plan and not isinstance(handoff_state.get("recommend_handoff"), dict): + pending_handoff = pending_plan.get("recommend_handoff") if isinstance(pending_plan.get("recommend_handoff"), dict) else {} + if pending_handoff: + handoff_state["recommend_handoff"] = dict(pending_handoff) + if pending_plan: + result = await self.api_assistant_plan_execute(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "prefer_unexecuted": True, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + kind = self._clean_text(state.get("kind")) + if kind in {"assistant_pansou", "assistant_mp"}: + result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best_execute", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + return { + "success": False, + "message": "当前会话没有待确认计划。影巢候选阶段请先选择编号;如果想回统一搜索决策,请回复:决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "confirm_recommend_handoff", + "ok": False, + "error_code": "recommend_handoff_pending_plan_missing", + "preferred_command": "决策", + "fallback_command": "回推荐", + }), + } + + def _assistant_recommend_handoff_short_metadata( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + handoff = (state or {}).get("recommend_handoff") if isinstance((state or {}).get("recommend_handoff"), dict) else {} + source_short_commands = handoff.get("source_short_commands") if isinstance(handoff.get("source_short_commands"), dict) else {} + return { + "recommend_handoff": dict(handoff) if handoff else {}, + "return_short_command": self._clean_text(handoff.get("return_short_command") or "回推荐") if handoff else "", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": self._clean_text(source_short_commands.get("pansou") or "盘搜") if handoff else "", + "hdhive_short_command": self._clean_text(source_short_commands.get("hdhive") or "影巢") if handoff else "", + "mp_short_command": self._clean_text(source_short_commands.get("mp") or "原生") if handoff else "", + } + + def _assistant_recommend_handoff_plan_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "confirm", + "label": "待确认计划", + "decision_hint": "当前推荐条目已生成计划;先看详情,再决定是否确认执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "确认", + "fallback_command": "详情", + "compact_commands": ["确认", "详情"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_entry_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "source_entry", + "label": "已切入单源", + "decision_hint": "当前推荐条目已切入单源结果;先看详情,再决定是否生成计划。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_detail_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "detail", + "label": "已查看详情", + "decision_hint": "当前推荐条目详情已展开;可以先生成计划,确认无误后再执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "计划", + "fallback_command": "确认", + "compact_commands": ["计划", "确认"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "计划", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_execute_failure_followup( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "category": "recommend_handoff", + "stage": "write_failed", + "label": "执行失败,建议换路", + "preferred_action": "return_to_smart_decision", + "decision_hint": "当前推荐条目的执行失败;优先回到统一资源决策,或回推荐后切换其它源。", + "command_policy": "safe_read_recovery", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "决策", + "fallback_command": meta.get("return_short_command") or "回推荐", + "compact_commands": ["决策", meta.get("return_short_command") or "回推荐"], + "recommended_commands": [ + "决策", + meta.get("return_short_command") or "回推荐", + meta.get("hdhive_short_command") or "影巢", + meta.get("mp_short_command") or "原生", + ], + "recommended_agent_behavior": "show_only", + **meta, + } + + def _persist_workflow_plans(self) -> None: + try: + items = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + )[:self._workflow_plan_limit] + self._workflow_plans = { + self._clean_text(item.get("plan_id")): item + for item in items + if self._clean_text(item.get("plan_id")) + } + self.save_data(key=self._workflow_plan_store_key, value=self._workflow_plans) + except Exception: + pass + + def _restore_workflow_plans(self) -> None: + try: + restored = self.get_data(self._workflow_plan_store_key) or {} + if isinstance(restored, dict): + self._workflow_plans = { + self._clean_text(plan_id): dict(payload) + for plan_id, payload in restored.items() + if self._clean_text(plan_id) and isinstance(payload, dict) + } + except Exception: + self._workflow_plans = {} + + def _save_workflow_plan( + self, + *, + workflow: str, + session: str, + session_id: str = "", + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + ) -> Dict[str, Any]: + plan_id = self._new_session_id("plan") + created_at = int(time.time()) + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plan = { + "plan_id": plan_id, + "workflow": self._clean_text(workflow), + "session": session_name, + "session_id": normalized_session_id, + "actions": [dict(item or {}) for item in (actions or [])], + "execute_body": dict(execute_body or {}), + "created_at": created_at, + "created_at_text": self._format_unix_time(created_at), + "executed_at": 0, + "executed_at_text": "", + "executed": False, + } + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + return dict(plan) + + def _save_assistant_pick_plan_response( + self, + *, + workflow: str, + session: str, + session_id: str, + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + message: str, + score_items: Optional[List[Dict[str, Any]]] = None, + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + plan = self._save_workflow_plan( + workflow=workflow, + session=session, + session_id=session_id, + actions=actions, + execute_body=execute_body, + ) + plan_id = self._clean_text(plan.get("plan_id")) + recommend_handoff = extra_data.get("recommend_handoff") if isinstance((extra_data or {}).get("recommend_handoff"), dict) else {} + if plan_id and recommend_handoff and isinstance(self._workflow_plans.get(plan_id), dict): + self._workflow_plans[plan_id]["recommend_handoff"] = dict(recommend_handoff) + self._persist_workflow_plans() + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": plan_id, + "session": session, + "session_id": session_id, + "prefer_unexecuted": True, + }, + ) + data = { + "action": "workflow_plan", + "ok": True, + "plan_id": plan_id, + "workflow": workflow, + "dry_run": True, + "workflow_actions": [dict(item or {}) for item in actions], + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan_id}, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "next_actions": ["execute_plan"], + "action_templates": [template], + "write_effect": "state", + } + if score_items: + data["score_summary"] = self._score_summary(score_items, limit=1) + if extra_data: + data.update(extra_data) + decision_summary = data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {} + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in decision_summary and key not in data: + data[key] = decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + choice = self._safe_int(data.get("choice"), 0) + confirm_hint = f"回复“执行计划”或“{choice}”确认执行。" if choice > 0 else "回复“执行计划”确认执行。" + return { + "success": True, + "message": f"{message}:{plan_id}\n未实际执行。{confirm_hint}", + "data": self._assistant_response_data(session=session, data=data), + } + + def _load_workflow_plan(self, plan_id: str) -> Optional[Dict[str, Any]]: + plan = (self._workflow_plans or {}).get(self._clean_text(plan_id)) + return dict(plan) if isinstance(plan, dict) else None + + def _find_workflow_plan( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + clean_plan_id = self._clean_text(plan_id) + if clean_plan_id: + return self._load_workflow_plan(clean_plan_id) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + for plan in plans: + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + return dict(plan) + return None + + def _find_pending_multi_plan( + self, + *, + session: str, + session_id: str, + rank: int = 0, + choice: int = 0, + group_id: str = "", + ) -> Optional[Dict[str, Any]]: + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + candidates: List[Dict[str, Any]] = [] + for plan in (self._workflow_plans or {}).values(): + if not isinstance(plan, dict) or bool(plan.get("executed")): + continue + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + plan_group = self._clean_text(plan.get("multi_plan_group") or execute_body.get("multi_plan_group")) + if group_id and plan_group != group_id: + continue + plan_rank = self._safe_int(plan.get("plan_rank") or execute_body.get("plan_rank"), 0) + plan_choice = self._safe_int(plan.get("plan_choice") or execute_body.get("choice") or execute_body.get("index"), 0) + if rank > 0 and plan_rank != rank: + continue + if choice > 0 and plan_choice != choice and plan_rank != choice: + continue + candidates.append(dict(plan)) + candidates.sort( + key=lambda item: ( + -self._safe_int(item.get("created_at"), 0), + self._safe_int(item.get("plan_rank") or (item.get("execute_body") or {}).get("plan_rank"), 999), + ) + ) + return candidates[0] if candidates else None + + def _workflow_plan_public_item(self, plan: Dict[str, Any], *, include_actions: bool = False) -> Dict[str, Any]: + current = dict(plan or {}) + actions = current.get("actions") or [] + item = { + "plan_id": self._clean_text(current.get("plan_id")), + "workflow": self._clean_text(current.get("workflow")), + "session": self._clean_text(current.get("session")), + "session_id": self._clean_text(current.get("session_id")), + "created_at": self._safe_int(current.get("created_at"), 0), + "created_at_text": self._clean_text(current.get("created_at_text")), + "executed": bool(current.get("executed")), + "executed_at": self._safe_int(current.get("executed_at"), 0), + "executed_at_text": self._clean_text(current.get("executed_at_text")), + "last_success": current.get("last_success"), + "last_message": self._clean_text(current.get("last_message")), + "action_count": len(actions) if isinstance(actions, list) else 0, + } + if include_actions: + item["actions"] = [dict(action or {}) for action in actions] if isinstance(actions, list) else [] + item["execute_body"] = dict(current.get("execute_body") or {}) + return item + + def _session_workflow_plan_public_data(self, *, session: str = "", session_id: str = "") -> Dict[str, Any]: + pending = self._find_workflow_plan(session=session, session_id=session_id, executed=False) + latest = pending or self._find_workflow_plan(session=session, session_id=session_id, executed=None) + if not latest: + return { + "has_plan": False, + "has_pending": False, + "latest": None, + } + return { + "has_plan": True, + "has_pending": bool(pending), + "latest": self._workflow_plan_public_item(latest, include_actions=False), + } + + def _assistant_plans_public_data( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + items: List[Dict[str, Any]] = [] + matching_total = 0 + for plan in plans: + if session_id_filter and self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + matching_total += 1 + if len(items) < max_limit: + items.append(self._workflow_plan_public_item(plan, include_actions=include_actions)) + return { + "total": matching_total, + "total_matching": matching_total, + "total_all": len(self._workflow_plans or {}), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "include_actions": bool(include_actions), + "items": items, + } + + def _format_assistant_plans_text( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> str: + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 保存计划。" + lines = [f"已保存计划:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "已执行" if item.get("executed") else "待执行" + line = ( + f"{index}. {item.get('plan_id') or '-'} | {status} | " + f"{item.get('workflow') or '-'} | {item.get('session') or '-'} | " + f"{item.get('action_count') or 0}步 | {item.get('created_at_text') or '-'}" + ) + if item.get("last_message"): + line = f"{line} | {item.get('last_message')}" + lines.append(line) + lines.append("下一步:可用 agent_resource_officer_execute_plan 执行 plan_id,或用 agent_resource_officer_plans_clear 清理。") + return "\n".join(lines) + + def _clear_workflow_plans( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + clean_plan_id = self._clean_text(plan_id) + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not any([clean_plan_id, session_id_filter, executed is not None, all_plans]): + return { + "ok": False, + "message": "请指定 plan_id、session/session_id、executed 过滤条件,或显式 all_plans=true", + "removed": 0, + "removed_ids": [], + } + removed_ids: List[str] = [] + for current_id, plan in list((self._workflow_plans or {}).items()): + if len(removed_ids) >= max_limit: + break + current = dict(plan or {}) + if clean_plan_id and self._clean_text(current_id) != clean_plan_id: + continue + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(current.get("executed")) != bool(executed): + continue + removed_ids.append(self._clean_text(current_id)) + for current_id in removed_ids: + self._workflow_plans.pop(current_id, None) + if removed_ids: + self._persist_workflow_plans() + return { + "ok": True, + "message": f"已清理 {len(removed_ids)} 条计划", + "removed": len(removed_ids), + "removed_ids": removed_ids, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "all_plans": bool(all_plans), + } + + def _record_assistant_execution( + self, + *, + action: str, + session: str = "default", + session_id: str = "", + success: bool = False, + message: str = "", + summary: Optional[Dict[str, Any]] = None, + ) -> None: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + entry = { + "id": self._new_session_id("exec"), + "time": int(time.time()), + "time_text": self._format_unix_time(int(time.time())), + "action": self._clean_text(action), + "session": session_name, + "session_id": normalized_session_id, + "success": bool(success), + "message_head": self._assistant_result_message_head(message), + "summary": dict(summary or {}), + } + self._execution_history.append(entry) + self._execution_history = self._execution_history[-self._execution_history_limit:] + self._persist_execution_history() + + def _assistant_history_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + items: List[Dict[str, Any]] = [] + for entry in reversed(self._execution_history or []): + current = dict(entry or {}) + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + items.append(current) + if len(items) >= max_limit: + break + return { + "total": len(self._execution_history or []), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "items": items, + } + + def _format_assistant_history_text( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> str: + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 执行历史。" + lines = [f"最近执行历史:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "成功" if item.get("success") else "失败" + line = f"{index}. {item.get('time_text') or '-'} | {status} | {item.get('action') or '-'} | {item.get('session') or '-'}" + if item.get("message_head"): + line = f"{line} | {item.get('message_head')}" + lines.append(line) + return "\n".join(lines) + + def _is_session_expired(self, payload: Optional[Dict[str, Any]]) -> bool: + session = dict(payload or {}) + updated_at = self._safe_int(session.get("updated_at"), 0) + if updated_at <= 0: + return False + return (int(time.time()) - updated_at) > self._session_retention_seconds + + @staticmethod + def _format_unix_time(value: Any) -> str: + try: + timestamp = int(value) + except Exception: + return "" + if timestamp <= 0: + return "" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + except Exception: + return "" + + @staticmethod + def _group_resource_preview(items: List[Dict[str, Any]], per_group: Optional[int] = 6) -> List[Dict[str, Any]]: + groups: Dict[str, List[Dict[str, Any]]] = {"115": [], "quark": [], "other": []} + for item in items: + pan = str(item.get("pan_type") or "").lower() + if pan == "115": + key = "115" + elif pan == "quark": + key = "quark" + else: + key = "other" + if per_group is None or len(groups[key]) < per_group: + groups[key].append(item) + ordered = groups["115"] + groups["quark"] + if not ordered: + ordered = groups["other"] + preview: List[Dict[str, Any]] = [] + for index, item in enumerate(ordered, 1): + row = dict(item) + row["pick_index"] = index + preview.append(row) + return preview + + def _assistant_session_id(self, session: str) -> str: + session = self._clean_text(session) or "default" + return f"assistant::{session}" + + def _assistant_session_name_from_id(self, session_id: str) -> str: + clean_session_id = self._clean_text(session_id) + if clean_session_id.startswith("assistant::"): + return clean_session_id.split("assistant::", 1)[1] or "default" + return clean_session_id or "default" + + def _normalize_assistant_session_ref( + self, + *, + session: Any = None, + session_id: Any = None, + fallback: str = "default", + ) -> Tuple[str, str]: + clean_session_id = self._clean_text(session_id) + if clean_session_id: + session_name = self._assistant_session_name_from_id(clean_session_id) + return session_name, self._assistant_session_id(session_name) + session_name = self._clean_text(session) or fallback + return session_name, self._assistant_session_id(session_name) + + def _p115_status_snapshot(self) -> Dict[str, Any]: + health_ok, result, health_message = self._ensure_p115_service().health() + return { + "ready": health_ok, + "message": health_message or result.get("message") or "", + "direct_source": self._clean_text(result.get("direct_source")), + "helper_ready": bool(result.get("helper_ready")), + "client_type": self._p115_client_type, + "default_target_path": self._p115_default_path, + "cookie_mode": self._clean_text((result.get("cookie_state") or {}).get("mode")), + } + + def _format_p115_next_actions(self, status: Optional[Dict[str, Any]] = None) -> str: + current = dict(status or self._p115_status_snapshot()) + final_path = current.get("default_target_path") or self._p115_default_path + if current.get("ready"): + lines = [ + "下一步建议:", + f"1. 直接发:链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 也可以直接贴 115 链接,不写前缀也能识别", + "3. 搜资源可发:影巢搜索 片名", + "4. 外部搜资源可发:盘搜搜索 片名", + "5. 想复查登录状态可发:115状态", + ] + else: + lines = [ + "下一步建议:", + "1. 回复:115登录", + "2. 扫码确认后回复:检查115登录", + "3. 登录完成后可回复:115状态", + f"4. 然后可直接发 115 链接转存到 {final_path}", + "5. 也可以继续发:影巢搜索 片名", + ] + return "\n".join(lines) + + def _format_p115_transfer_failure( + self, + *, + detail: str = "", + target_path: str = "", + title: str = "115 转存失败", + ) -> str: + status = self._p115_status_snapshot() + final_path = target_path or status.get("default_target_path") or self._p115_default_path + clean_detail = self._clean_text(detail) + status_message = self._clean_text(status.get("message")) + lines = [title] + if clean_detail: + lines.append(f"原因:{clean_detail}") + if final_path: + lines.append(f"目标目录:{final_path}") + lines.append(f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}") + if status_message and status_message.lower() not in {"success", "ok"} and status_message != clean_detail: + lines.append(f"状态详情:{status_message}") + if status.get("ready"): + lines.append("建议:先回复 115状态 检查当前会话;如果还是失败,再回复 115登录 重新扫码。") + else: + lines.append("建议:先回复 115登录,扫码成功后再重试当前操作。") + return "\n".join(lines) + + @staticmethod + def _format_p115_resume_hint(title: str = "") -> str: + clean_title = str(title or "").strip() + prefix = f"已记住这次 115 任务({clean_title})。" if clean_title else "已记住这次 115 任务。" + return f"{prefix}\n登录成功后回复:检查115登录,我会自动继续处理。" + + def _save_pending_p115_share( + self, + session_id: str, + *, + share_url: str, + access_code: str = "", + target_path: str = "", + source: str = "", + title: str = "", + last_error: str = "", + ) -> None: + clean_url = self._clean_text(share_url) + if not clean_url: + return + state = self._load_session(session_id) or {} + previous = dict(state.get("pending_p115") or {}) + now = int(time.time()) + state["pending_p115"] = { + "kind": "share_route", + "share_url": clean_url, + "access_code": self._clean_text(access_code), + "target_path": target_path or self._p115_default_path, + "source": self._clean_text(source), + "title": self._clean_text(title), + "created_at": self._safe_int(previous.get("created_at"), now) or now, + "last_attempt_at": now, + "retry_count": max(0, self._safe_int(previous.get("retry_count"), 0)), + "last_error": self._clean_text(last_error) or self._clean_text(previous.get("last_error")), + } + if not state.get("kind"): + state["kind"] = "assistant_p115_pending" + state["stage"] = "pending_login" + self._save_session(session_id, state) + + def _clear_pending_p115_share(self, session_id: str) -> None: + state = self._load_session(session_id) + if not state or "pending_p115" not in state: + return + state.pop("pending_p115", None) + self._save_session(session_id, state) + + def _pending_p115_summary(self, state: Optional[Dict[str, Any]]) -> str: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return "" + title = self._clean_text(pending.get("title")) or "未命名任务" + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + source = self._clean_text(pending.get("source")) or "unknown" + created_at = self._format_unix_time(pending.get("created_at")) + last_attempt_at = self._format_unix_time(pending.get("last_attempt_at")) + retry_count = max(0, self._safe_int(pending.get("retry_count"), 0)) + last_error = self._clean_text(pending.get("last_error")) + lines = [ + "待继续的 115 任务:", + f"资源:{title}", + f"目录:{target_path}", + f"来源:{source}", + ] + if created_at: + lines.append(f"首次记录:{created_at}") + if last_attempt_at: + lines.append(f"最近尝试:{last_attempt_at}") + if retry_count: + lines.append(f"重试次数:{retry_count}") + if last_error: + lines.append(f"最近错误:{last_error}") + lines.append("可用命令:继续115任务 / 取消115任务") + return "\n".join(lines) + + def _pending_p115_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + pending = dict((state or {}).get("pending_p115") or {}) + if not self._clean_text(pending.get("share_url")): + return {"has_pending": False} + return { + "has_pending": True, + "title": self._clean_text(pending.get("title")) or "未命名任务", + "target_path": self._clean_text(pending.get("target_path")) or self._p115_default_path, + "source": self._clean_text(pending.get("source")) or "unknown", + "created_at": self._safe_int(pending.get("created_at"), 0), + "created_at_text": self._format_unix_time(pending.get("created_at")), + "last_attempt_at": self._safe_int(pending.get("last_attempt_at"), 0), + "last_attempt_at_text": self._format_unix_time(pending.get("last_attempt_at")), + "retry_count": max(0, self._safe_int(pending.get("retry_count"), 0)), + "last_error": self._clean_text(pending.get("last_error")), + } + + @staticmethod + def _assistant_find_action_template( + templates: Optional[List[Dict[str, Any]]], + names: List[str], + ) -> Optional[Dict[str, Any]]: + rows = [dict(item or {}) for item in (templates or []) if isinstance(item, dict)] + for current_name in names: + for item in rows: + if str(item.get("name") or "").strip() == current_name: + return item + return None + + def _assistant_recovery_public_data( + self, + *, + session_state: Optional[Dict[str, Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + state = dict(session_state or {}) + templates = [dict(item or {}) for item in (action_templates or state.get("action_templates") or []) if isinstance(item, dict)] + saved_plan = dict(state.get("saved_plan") or {}) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id") or saved_plan.get("plan_id")) + latest_plan_executed = bool(latest_plan.get("executed")) + pending_p115 = dict(state.get("pending_p115") or {}) + has_session = bool(state.get("has_session")) + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + session_name = self._clean_text(state.get("session")) or self._assistant_session_name_from_id(self._clean_text(state.get("session_id"))) + session_id = self._clean_text(state.get("session_id")) + mode = "" + reason = "" + template: Optional[Dict[str, Any]] = None + + if saved_plan.get("has_pending"): + mode = "resume_saved_plan" + reason = "当前会话存在待执行计划" + template = self._assistant_find_action_template(templates, [ + "execute_latest_plan", + "execute_plan", + "execute_session_latest_plan", + ]) + elif latest_plan_executed and latest_plan_id: + mode = "followup_executed_plan" + reason = "当前会话最近一条计划已执行,建议先做统一后续追踪" + template = self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动追踪下载、订阅或入库后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **({"session": session_name} if session_name else {}), + **({"session_id": session_id} if session_id else {}), + "plan_id": latest_plan_id, + }, + ) + elif pending_p115.get("has_pending"): + mode = "resume_pending_115" + reason = "当前会话存在待继续的 115 任务" + template = self._assistant_find_action_template(templates, [ + "resume_pending_115", + "check_115_login", + ]) + elif has_session and kind == "assistant_pansou": + mode = "continue_pansou" + reason = "当前会话停留在盘搜结果列表" + template = self._assistant_find_action_template(templates, ["pick_pansou_result"]) + elif has_session and kind == "assistant_mp_candidate": + mode = "continue_mp_candidate" + reason = "当前会话停留在 MP 媒体候选列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp": + mode = "continue_mp_search" + reason = "当前会话停留在 MP 原生搜索结果列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_best_result_detail", + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp_recommend": + mode = "continue_mp_recommend" + reason = "当前会话停留在 MP 热门推荐列表" + template = self._assistant_find_action_template(templates, [ + "pick_recommend_smart_decision", + "pick_recommend_smart_plan", + "pick_recommend_mp_search", + ]) + elif has_session and kind == "assistant_mp_download_tasks": + mode = "continue_mp_download_tasks" + reason = "当前会话停留在 MP 下载任务列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "pause_mp_download", + "resume_mp_download", + "delete_mp_download", + ]) + elif has_session and kind == "assistant_mp_download_history": + mode = "continue_mp_download_history" + reason = "当前会话停留在 MP 下载历史" + template = self._assistant_find_action_template(templates, [ + "query_mp_lifecycle_status", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_downloaders": + mode = "continue_mp_downloaders" + reason = "当前会话停留在 MP 下载器状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_sites", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_sites": + mode = "continue_mp_sites" + reason = "当前会话停留在 MP 站点状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_downloaders", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_subscribes": + mode = "continue_mp_subscribes" + reason = "当前会话停留在 MP 订阅列表" + template = self._assistant_find_action_template(templates, [ + "start_mp_subscribe", + "search_mp_subscribe", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_lifecycle_status": + mode = "continue_mp_lifecycle_status" + reason = "当前会话停留在 MP 生命周期追踪" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_hdhive" and stage == "candidate": + mode = "continue_hdhive_candidate" + reason = "当前会话停留在影巢候选列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_candidate"]) + elif has_session and kind == "assistant_hdhive" and stage == "resource": + mode = "continue_hdhive_resource" + reason = "当前会话停留在影巢资源列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_resource"]) + elif has_session and kind == "assistant_p115_login": + mode = "continue_115_login" + reason = "当前会话停留在 115 登录检查阶段" + template = self._assistant_find_action_template(templates, ["check_115_login", "show_115_status"]) + elif self._assistant_find_action_template(templates, ["preferences_save"]): + mode = "onboard_preferences" + reason = "智能体片源偏好未初始化,建议先询问并保存用户偏好" + template = self._assistant_find_action_template(templates, ["preferences_save"]) + else: + mode = "start_new" + reason = "当前没有待恢复的执行状态,可直接开始新任务" + template = self._assistant_find_action_template(templates, [ + "start_pansou_search", + "start_hdhive_search", + "start_115_login", + ]) + + can_resume = mode != "start_new" and bool(template) + return { + "mode": mode, + "reason": reason, + "can_resume": can_resume, + "recommended_action": self._clean_text((template or {}).get("name")), + "recommended_tool": self._clean_text((template or {}).get("tool")), + "action_template": template or None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in templates[:6] + if self._clean_text(item.get("name")) + ], + } + + def _assistant_session_public_data(self, session: str = "default") -> Dict[str, Any]: + session_name = self._clean_text(session) or "default" + session_id = self._assistant_session_id(session_name) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + state = self._load_session(session_id) or {} + if not state: + payload = { + "has_session": False, + "session": session_name, + "session_id": session_id, + "saved_plan": saved_plan, + "suggested_actions": ["execute_plan.session", "smart_entry"] if saved_plan.get("has_pending") else ["smart_entry"], + } + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + target_path = self._clean_text(state.get("target_path")) + payload: Dict[str, Any] = { + "has_session": True, + "session": session_name, + "session_id": session_id, + "kind": kind, + "stage": stage, + "updated_at": self._safe_int(state.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(state.get("updated_at")), + "target_path": target_path, + "keyword": self._clean_text(state.get("keyword")), + "media_type": self._clean_text(state.get("media_type")), + "year": self._clean_text(state.get("year")), + "pending_p115": self._pending_p115_public_data(state), + "saved_plan": saved_plan, + "suggested_actions": [], + } + + if kind == "assistant_pansou": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "channel": self._clean_text(item.get("channel")), + "title": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + } + for idx, item in enumerate(items[:6]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["smart_pick.choice", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=影巢", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(((item.get("torrent_info") or {}).get("title")) or item.get("title")), + "site": self._clean_text((item.get("torrent_info") or {}).get("site_name")), + "seeders": (item.get("torrent_info") or {}).get("seeders"), + "volume_factor": self._clean_text((item.get("torrent_info") or {}).get("volume_factor")), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["mp_download.choice", "mp_subscribe.keyword", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=盘搜", + "smart_entry.text=影巢", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + payload.update({ + "stage": "candidate", + "candidate_count": len(candidates), + "page": self._safe_int(state.get("page"), 1), + "page_size": self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size), + "candidates_preview": [ + { + "index": idx + 1, + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "tmdb_id": item.get("tmdb_id"), + } + for idx, item in enumerate(candidates[:10]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + elif kind == "assistant_mp_download_tasks": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "hash_short": self._clean_text(item.get("hash_short")), + "downloader": self._clean_text(item.get("downloader")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_download_control.pause", "mp_download_control.resume", "mp_download_control.delete", "session_clear"] + if items else + ["mp_media_search", "mp_download_history", "session_clear"] + ), + }) + elif kind == "assistant_mp_download_history": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + "download_hash_short": self._clean_text(item.get("download_hash_short")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_lifecycle_status", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_downloaders": + items = state.get("items") or [] + payload.update({ + "enabled_count": self._safe_int(state.get("enabled_count"), 0), + "result_count": len(items), + "items_preview": [ + { + "name": self._clean_text(item.get("name")), + "type": self._clean_text(item.get("type")), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + } + for item in items[:8] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_sites", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_sites": + items = state.get("items") or [] + payload.update({ + "status": self._clean_text(state.get("status")), + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "name": self._clean_text(item.get("name")), + "domain": self._clean_text(item.get("domain")), + "enabled": bool(item.get("enabled")), + "has_cookie": bool(item.get("has_cookie")), + "priority": item.get("priority"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_downloaders", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_subscribes": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "id": self._safe_int(item.get("id"), 0), + "title": self._clean_text(item.get("name")), + "year": self._clean_text(item.get("year")), + "state": self._clean_text(item.get("state")), + "lack_episode": item.get("lack_episode"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_subscribe_control.search", "mp_subscribe_control.pause", "mp_subscribe_control.resume", "mp_subscribe_control.delete", "session_clear"] + if items else + ["mp_subscribe.keyword", "mp_media_search", "session_clear"] + ), + }) + elif kind == "assistant_mp_lifecycle_status": + result_groups = state.get("items") if isinstance(state.get("items"), dict) else {} + task_items = result_groups.get("download_tasks") if isinstance(result_groups.get("download_tasks"), list) else [] + download_items = result_groups.get("download_history") if isinstance(result_groups.get("download_history"), list) else [] + transfer_items = result_groups.get("transfer_history") if isinstance(result_groups.get("transfer_history"), list) else [] + payload.update({ + "download_task_count": len(task_items), + "download_history_count": len(download_items), + "transfer_history_count": len(transfer_items), + "items_preview": [ + { + "kind": "download_task", + "title": self._clean_text(item.get("title")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for item in task_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "download_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + } + for item in download_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "transfer_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "status_text": self._clean_text(item.get("status_text")), + } + for item in transfer_items[:3] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_media_search", "mp_download_history", "session_clear"], + }) + elif kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + payload.update({ + "source": self._clean_text(state.get("source")), + "result_count": len(items), + "selected_index": selected_index if selected_index > 0 else None, + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "type": self._clean_text(item.get("type")), + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "douban_id": self._clean_text(item.get("douban_id")), + "vote_average": item.get("vote_average"), + } + for idx, item in enumerate(items[:10]) + if isinstance(item, dict) + ], + "suggested_actions": [ + "smart_pick.choice", + "smart_entry.text=电影", + "smart_entry.text=电视剧", + "smart_entry.text=豆瓣", + "smart_entry.text=热映", + "smart_entry.text=番剧", + "smart_pick.choice mode=hdhive", + "smart_pick.choice mode=pansou", + "session_clear", + ], + }) + if selected_index > 0: + payload["selected_item"] = dict(state.get("selected_item") or {}) + payload["suggested_actions"] = [ + *list(payload.get("suggested_actions") or []), + "smart_entry.text=详情", + "smart_entry.text=决策", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=原生", + "smart_entry.text=影巢", + "smart_entry.text=盘搜", + ] + elif kind == "assistant_update_check": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("_index") or item.get("index"), idx + 1), + "source_type": self._clean_text(item.get("_update_source")), + "title": self._clean_text(item.get("note") or item.get("title") or item.get("remark")), + "provider": self._clean_text(item.get("channel") or item.get("pan_type")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=计划", "session_clear"], + }) + elif kind == "assistant_hdhive": + payload["recommend_handoff"] = self._assistant_recommend_handoff_public_data(state) + if stage == "candidate": + candidates = state.get("candidates") or [] + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "page": current_page, + "page_size": page_size, + "total_candidates": len(candidates), + "total_pages": total_pages, + "candidates_preview": [ + { + "index": start + idx + 1, + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type")), + "actors": item.get("actors") or [], + } + for idx, item in enumerate(candidates[start:end]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif stage == "resource": + resources = state.get("resources") or [] + selected_candidate = dict(state.get("selected_candidate") or {}) + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "selected_candidate": { + "tmdb_id": self._clean_text(selected_candidate.get("tmdb_id")), + "title": self._clean_text(selected_candidate.get("title")), + "year": self._clean_text(selected_candidate.get("year")), + "media_type": self._clean_text(selected_candidate.get("media_type")), + "actors": selected_candidate.get("actors") or [], + }, + "page": current_page, + "page_size": page_size, + "total_resources": len(resources), + "total_pages": total_pages, + "resource_count_115": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "115"]), + "resource_count_quark": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "quark"]), + "resources_preview": [ + { + "index": self._safe_int(item.get("pick_index"), idx + 1), + "provider": self._clean_text(item.get("pan_type")), + "title": self._clean_text(item.get("title") or item.get("matched_title")), + "points": item.get("unlock_points"), + "points_text": self._resource_points_text(item), + "quality": self._clean_text(self._list_text(item.get("video_resolution")) or item.get("quality")), + "source": self._clean_text(self._list_text(item.get("source"))), + "size": self._clean_text(item.get("share_size") or item.get("size")), + "episodes": self._resource_episode_text(item), + "subtitle": self._resource_subtitle_text(item), + "remark": self._resource_remark_text(item), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(resources[start:end], start=start + 1) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(resources, limit=5), + "suggested_actions": ["smart_pick.choice", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_p115_login": + payload.update({ + "client_type": self._clean_text(state.get("client_type")) or self._p115_client_type, + "has_qrcode_session": bool(self._clean_text(state.get("uid")) and self._clean_text(state.get("time")) and self._clean_text(state.get("sign"))), + "suggested_actions": ["smart_entry.text=检查115登录", "p115_status", "session_clear"], + }) + else: + payload["suggested_actions"] = ["smart_entry", "session_clear"] + if saved_plan.get("has_pending"): + payload["suggested_actions"] = ["execute_plan.session", *list(payload.get("suggested_actions") or [])] + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + def _assistant_session_brief_public_data(self, session_id: str, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(state or {}) + name = str(session_id or "") + session_name = name.split("assistant::", 1)[1] if name.startswith("assistant::") else name or "default" + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=name) + result: Dict[str, Any] = { + "session": session_name, + "session_id": name or self._assistant_session_id(session_name), + "kind": self._clean_text(payload.get("kind")), + "stage": self._clean_text(payload.get("stage")), + "updated_at": self._safe_int(payload.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(payload.get("updated_at")), + "keyword": self._clean_text(payload.get("keyword")), + "target_path": self._clean_text(payload.get("target_path")), + "has_pending_p115": bool(self._clean_text(((payload.get("pending_p115") or {}).get("share_url")))), + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": saved_plan.get("latest"), + } + if result["kind"] == "assistant_pansou": + result["result_count"] = len(payload.get("items") or []) + elif result["kind"] == "assistant_mp_recommend": + result["result_count"] = len(payload.get("items") or []) + result["source"] = self._clean_text(payload.get("source")) + elif result["kind"] == "assistant_hdhive": + if result["stage"] == "candidate": + candidates = payload.get("candidates") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + result["total_candidates"] = len(candidates) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["stage"] == "resource": + selected = dict(payload.get("selected_candidate") or {}) + resources = payload.get("resources") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), 10)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + result["selected_title"] = self._clean_text(selected.get("title")) + result["selected_year"] = self._clean_text(selected.get("year")) + result["total_resources"] = len(resources) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["kind"] == "assistant_p115_login": + result["client_type"] = self._clean_text(payload.get("client_type")) or self._p115_client_type + result["recovery"] = self._assistant_recovery_public_data( + session_state={ + **result, + "pending_p115": self._pending_p115_public_data(payload), + "saved_plan": saved_plan, + }, + action_templates=[ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": result.get("session_id"), "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": result.get("session_id")}, + ), + ], + ) + return result + + def _assistant_plan_only_session_brief_public_data(self, session_id: str) -> Dict[str, Any]: + session_name = self._assistant_session_name_from_id(session_id) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + latest = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest.get("plan_id")) + latest_executed = bool(latest.get("executed")) + return { + "session": session_name, + "session_id": self._assistant_session_id(session_name), + "kind": "assistant_workflow_plan", + "stage": "planned", + "updated_at": self._safe_int(latest.get("created_at"), 0), + "updated_at_text": self._clean_text(latest.get("created_at_text")), + "keyword": "", + "target_path": "", + "has_pending_p115": False, + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": latest or None, + "recovery": { + "mode": "resume_saved_plan" if saved_plan.get("has_pending") else "followup_executed_plan" if latest_executed and latest_plan_id else "inspect_plan_only_session", + "reason": "当前会话只有 dry_run 计划,尚未生成交互会话缓存", + "can_resume": bool(saved_plan.get("has_pending") or (latest_executed and latest_plan_id)), + "recommended_action": "execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + "recommended_tool": "agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + "action_template": self._assistant_action_template( + name="execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + description="按 session_id 执行该会话最近一条待执行计划" if saved_plan.get("has_pending") else "按最近已执行计划自动追踪下载、订阅或入库后续状态" if latest_executed and latest_plan_id else "查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute" if saved_plan.get("has_pending") else "/api/v1/plugin/AgentResourceOfficer/assistant/action" if latest_executed and latest_plan_id else "/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True} if saved_plan.get("has_pending") else {"session_id": self._assistant_session_id(session_name), "name": "query_execution_followup", "plan_id": latest_plan_id} if latest_executed and latest_plan_id else {"session_id": self._assistant_session_id(session_name)}, + ), + "alternatives": ["execute_session_latest_plan", "inspect_session"] if saved_plan.get("has_pending") else ["query_execution_followup", "inspect_session"] if latest_executed and latest_plan_id else ["inspect_session"], + }, + } + + @staticmethod + def _assistant_action_template( + *, + name: str, + description: str, + endpoint: str, + body: Dict[str, Any], + method: str = "POST", + tool: str = "", + ) -> Dict[str, Any]: + body_payload = dict(body or {}) + compact_paths = [ + "/assistant/action", + "/assistant/actions", + "/assistant/workflow", + "/assistant/plan/execute", + "/assistant/recover", + "/assistant/route", + "/assistant/pick", + "/assistant/session", + "/assistant/sessions", + "/assistant/history", + "/assistant/plans", + "/assistant/readiness", + "/assistant/capabilities", + ] + if "compact" not in body_payload and any(path in endpoint for path in compact_paths): + body_payload["compact"] = True + action_body: Dict[str, Any] = {"name": name} + for key in [ + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "status", + "hash", + "target", + "control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "top", + "page", + "plan_id", + "prefer_unexecuted", + "preferences", + "compact", + "mode", + "source", + ]: + if key in body_payload: + action_body[key] = body_payload.get(key) + return { + "name": name, + "description": description, + "endpoint": endpoint, + "method": method, + "tool": tool, + "body": body_payload, + "action_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "action_tool": "agent_resource_officer_execute_action", + "action_body": action_body, + } + + @staticmethod + def _assistant_compact_action_templates( + primary: Optional[Dict[str, Any]] = None, + templates: Optional[List[Dict[str, Any]]] = None, + *, + limit: int = 6, + ) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + seen: set[str] = set() + for item in [primary, *(templates or [])]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(dict(item)) + if len(result) >= max(1, limit): + break + return result + + @staticmethod + def _assistant_compact_next_actions( + primary: Optional[List[Any]] = None, + secondary: Optional[List[Any]] = None, + *, + limit: int = 6, + ) -> List[str]: + result: List[str] = [] + seen: set[str] = set() + for item in [*(primary or []), *(secondary or [])]: + name = str(item or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(name) + if len(result) >= max(1, limit): + break + return result + + def _assistant_action_templates(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: + session_name = self._clean_text(data.get("session")) or "default" + session_id = self._clean_text(data.get("session_id")) or self._assistant_session_id(session_name) + base_route = { + "session": session_name, + "session_id": session_id, + } + base_pick = { + "session": session_name, + "session_id": session_id, + } + base_state = { + "session": session_name, + "session_id": session_id, + } + templates: List[Dict[str, Any]] = [] + preference_status = self._assistant_preferences_status_brief(session=session_name) + preference_template = self._assistant_action_template( + name="preferences_save", + description="保存智能体片源偏好;首次接入建议先询问用户后再保存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + tool="agent_resource_officer_preferences", + body={**base_state, "preferences": self._assistant_default_preferences_template()}, + ) + + if not data.get("has_session"): + templates = [] + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + saved_plan = dict(data.get("saved_plan") or {}) + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + templates.extend([ + self._assistant_action_template( + name="start_pansou_search", + description="发起新的盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "pansou", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="start_hdhive_search", + description="发起新的影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "hdhive", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="query_mp_media_detail", + description="使用 MoviePilot 原生识别确认媒体信息和 TMDB/Douban/IMDB ID", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_media_detail", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_recommendations", + description="查看 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "start_mp_recommendations", "source": "tmdb_trending", "media_type": "all"}, + ), + self._assistant_action_template( + name="query_mp_download_tasks", + description="查看 MP 下载任务状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_tasks", "status": "downloading"}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看 MP 下载历史,并关联整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="聚合查看 MP 下载任务、下载历史和整理/入库历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_lifecycle_status", "keyword": "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="query_mp_sites", + description="查看 MP 站点启用状态和 Cookie 是否存在,不返回 Cookie 明文", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看 MP 订阅列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_subscribes", "status": "all", "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看 MP 最近整理/入库历史,用于判断下载后是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_transfer_history", "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="start_115_login", + description="发起新的 115 扫码登录", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_start"}, + ), + ]) + return templates + + kind = self._clean_text(data.get("kind")) + stage = self._clean_text(data.get("stage")) + pending = dict(data.get("pending_p115") or {}) + saved_plan = dict(data.get("saved_plan") or {}) + target_path = self._clean_text(data.get("target_path")) + + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + + templates.append( + self._assistant_action_template( + name="inspect_session_state", + description="重新获取当前会话详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body=base_state, + ) + ) + + if kind == "assistant_pansou": + templates.extend([ + self._assistant_action_template( + name="pick_pansou_result", + description="按编号选择盘搜结果继续转存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._p115_default_path}, + ), + self._assistant_action_template( + name="plan_pansou_result", + description="按编号生成盘搜转存计划,不立即写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._p115_default_path}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前盘搜结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_pansou_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + self._assistant_action_template( + name="handoff_pansou_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_detail", + description="查看当前盘搜首选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_plan", + description="为当前盘搜首选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_pansou_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前盘搜首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_pansou_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_mp": + templates.extend([ + self._assistant_action_template( + name="query_mp_best_result_detail", + description="查看当前 MP 搜索结果里评分最高的 PT 候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_best_result_detail"}, + ), + self._assistant_action_template( + name="pick_mp_best_download", + description="按当前评分最高的 MP 搜索结果生成下载计划;不会静默下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_best_download"}, + ), + self._assistant_action_template( + name="query_mp_search_result_detail", + description="按编号查看 MP 原生搜索结果详情和 PT 评分理由", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_search_result_detail", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="pick_mp_download", + description="按编号为 MP 原生搜索结果生成下载计划;不会立即下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_download", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe", + description="按当前关键词生成 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe_search", + description="按当前关键词生成“订阅并搜索”计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe_search", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前原生搜索结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_mp_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_mp_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_mp_best_detail", + description="查看当前 MP 搜索里评分最高的候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_mp_best_plan", + description="为当前 MP 搜索里评分最高的候选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_mp_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前 MP 首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_mp_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + ]) + elif kind == "assistant_mp_download_tasks": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="pause_mp_download", + description="按编号暂停下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_download", + description="按编号恢复下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_download", + description="按编号删除下载任务;默认不删除文件", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "delete", "target": "<1-N>", "delete_files": False}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="当前没有下载中任务,改查下载历史和整理状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_download_history": + templates.extend([ + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": data.get("keyword") or "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_downloaders": + templates.extend([ + self._assistant_action_template( + name="query_mp_sites", + description="查看 PT 站点启用状态和 Cookie 是否存在", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_sites": + templates.extend([ + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_subscribes": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="search_mp_subscribe", + description="按编号触发订阅搜索;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "search", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="pause_mp_subscribe", + description="按编号暂停订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_subscribe", + description="按编号恢复订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_subscribe", + description="按编号删除订阅", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "delete", "target": "<1-N>"}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="start_mp_subscribe", + description="按关键词生成新的 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_lifecycle_status": + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="继续查看 MP 下载历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "title": data.get("keyword") or "", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_recommend": + templates.extend([ + self._assistant_action_template( + name="pick_recommend_smart_decision", + description="按编号选择推荐条目并进入统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_decision"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_plan", + description="按编号选择推荐条目并直接生成统一资源计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_plan"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_execute", + description="按编号选择推荐条目并直接进入统一资源执行链", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_execute"}, + ), + self._assistant_action_template( + name="pick_recommend_mp_search", + description="按编号选择推荐条目并进入 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "mp"}, + ), + self._assistant_action_template( + name="pick_recommend_hdhive_search", + description="按编号选择推荐条目并进入影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "hdhive"}, + ), + self._assistant_action_template( + name="pick_recommend_pansou_search", + description="按编号选择推荐条目并进入盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "pansou"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "candidate": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_candidate", + description="按编号选择影巢候选影片进入资源列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="candidate_detail", + description="补充当前候选页详情,例如主演", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "detail"}, + ), + self._assistant_action_template( + name="candidate_next_page", + description="翻到候选下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢候选对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "resource": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_resource", + description="按编号选择影巢资源,解锁并路由到对应网盘", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="plan_hdhive_resource", + description="按编号生成影巢解锁/转存计划,不立即扣分或写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="resource_next_page", + description="翻到影巢资源下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢资源列表对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_p115_login": + templates.extend([ + self._assistant_action_template( + name="check_115_login", + description="检查 115 扫码是否已确认,并在成功后自动继续待任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_check"}, + ), + self._assistant_action_template( + name="show_115_status", + description="查看当前 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_status"}, + ), + ]) + + if pending.get("has_pending"): + templates.extend([ + self._assistant_action_template( + name="resume_pending_115", + description="继续当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_resume"}, + ), + self._assistant_action_template( + name="cancel_pending_115", + description="取消当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_cancel"}, + ), + ]) + + templates.append( + self._assistant_action_template( + name="clear_current_session", + description="清理当前会话缓存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", + tool="agent_resource_officer_session_clear", + body=base_state, + ) + ) + return templates + + def _assistant_sessions_public_data( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> Dict[str, Any]: + kind_filter = self._clean_text(kind) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + items_by_session: Dict[str, Dict[str, Any]] = {} + for session_id, payload in (self._session_cache or {}).items(): + if not str(session_id).startswith("assistant::"): + continue + session = dict(payload or {}) + if self._is_session_expired(session): + continue + brief = self._assistant_session_brief_public_data(str(session_id), session) + items_by_session[brief.get("session_id") or str(session_id)] = brief + for plan in (self._workflow_plans or {}).values(): + current = dict(plan or {}) + plan_session_id = self._clean_text(current.get("session_id")) + if not plan_session_id: + continue + brief = items_by_session.get(plan_session_id) + if brief: + if not brief.get("has_saved_plan"): + refreshed = self._assistant_session_brief_public_data(plan_session_id, self._load_session(plan_session_id) or {}) + items_by_session[plan_session_id] = refreshed + continue + items_by_session[plan_session_id] = self._assistant_plan_only_session_brief_public_data(plan_session_id) + items: List[Dict[str, Any]] = [] + for brief in items_by_session.values(): + if kind_filter and brief.get("kind") != kind_filter: + continue + if has_pending_p115 is not None and bool(brief.get("has_pending_p115")) != bool(has_pending_p115): + continue + items.append(brief) + items.sort(key=lambda item: self._safe_int(item.get("updated_at"), 0), reverse=True) + return { + "total": len(items), + "limit": max_limit, + "items": items[:max_limit], + "filters": { + "kind": kind_filter, + "has_pending_p115": has_pending_p115, + }, + "action_templates": [ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": "", "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": ""}, + ), + self._assistant_action_template( + name="clear_session_by_id", + description="按 session_id 清理单个会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"session_id": ""}, + ), + ], + "recovery": ( + dict((items[:max_limit][0] or {}).get("recovery") or {}) + if (items[:max_limit] and isinstance(items[:max_limit][0], dict)) + else { + "mode": "start_new", + "reason": "当前没有活跃会话,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + } + + def _assistant_recover_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + requested_session = self._clean_text(session) + requested_session_id = self._clean_text(session_id) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + if requested_session or requested_session_id: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=requested_session or "default", + session_id=requested_session_id, + ) + state = self._assistant_session_public_data(session=session_name) + return { + "scope": "session", + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "selected_session": { + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "kind": state.get("kind"), + "stage": state.get("stage"), + "keyword": state.get("keyword"), + "has_pending_plan": bool((state.get("saved_plan") or {}).get("has_pending")), + "has_pending_p115": bool((state.get("pending_p115") or {}).get("has_pending")), + }, + "session_state": state, + "sessions": None, + "recovery": dict(state.get("recovery") or self._assistant_recovery_public_data(session_state=state)), + } + + sessions = self._assistant_sessions_public_data(limit=max_limit) + items = [dict(item or {}) for item in (sessions.get("items") or []) if isinstance(item, dict)] + selected: Optional[Dict[str, Any]] = None + + current_recovery = dict(sessions.get("recovery") or {}) + template_body = dict(((current_recovery.get("action_template") or {}).get("body") or {})) + preferred_session_id = self._clean_text(template_body.get("session_id")) + if preferred_session_id: + selected = next((item for item in items if self._clean_text(item.get("session_id")) == preferred_session_id), None) + if not selected: + selected = next((item for item in items if bool((item.get("recovery") or {}).get("can_resume"))), None) + if not selected: + selected = next((item for item in items if item.get("has_pending_plan") or item.get("has_pending_p115")), None) + if not selected and items: + selected = items[0] + + if selected: + session_name = self._clean_text(selected.get("session")) or "default" + selected_session_id = self._clean_text(selected.get("session_id")) or self._assistant_session_id(session_name) + state = self._assistant_session_public_data(session=session_name) + recovery = dict(state.get("recovery") or selected.get("recovery") or current_recovery) + selected = {**selected, "recovery": recovery} + return { + "scope": "global", + "session": session_name, + "session_id": selected_session_id, + "selected_session": selected, + "session_state": state, + "sessions": sessions, + "recovery": recovery, + } + + state = self._assistant_session_public_data(session="default") + return { + "scope": "global", + "session": "default", + "session_id": state.get("session_id") or self._assistant_session_id("default"), + "selected_session": None, + "session_state": state, + "sessions": sessions, + "recovery": dict(state.get("recovery") or current_recovery), + } + + def _format_assistant_recover_text(self, data: Dict[str, Any]) -> str: + recovery = dict((data or {}).get("recovery") or {}) + selected = dict((data or {}).get("selected_session") or {}) + lines = [ + "Agent影视助手 恢复入口", + f"范围:{(data or {}).get('scope') or 'session'}", + f"会话:{(data or {}).get('session') or 'default'}", + f"模式:{recovery.get('mode') or 'unknown'}", + f"原因:{recovery.get('reason') or '-'}", + f"可恢复:{'是' if recovery.get('can_resume') else '否'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + if recovery.get("recommended_tool"): + lines.append(f"推荐 Tool:{recovery.get('recommended_tool')}") + if selected.get("kind") or selected.get("keyword"): + detail = " / ".join( + str(item) + for item in [ + selected.get("kind"), + selected.get("stage"), + selected.get("keyword"), + ] + if item + ) + lines.append(f"当前状态:{detail}") + if recovery.get("can_resume"): + lines.append("如需直接恢复,可调用 assistant/recover 并传 execute=true。") + return "\n".join(lines) + + def _assistant_recover_response_data(self, data: Dict[str, Any], compact: bool = False) -> Dict[str, Any]: + if not compact: + return self._assistant_response_data(session=(data or {}).get("session") or "default", data=data) + + payload = dict(data or {}) + session_state = dict(payload.pop("session_state", {}) or {}) + payload.pop("sessions", None) + recovery = dict(payload.get("recovery") or {}) + selected = payload.get("selected_session") + if isinstance(selected, dict) and selected.get("recovery"): + selected = dict(selected) + selected.pop("recovery", None) + payload["selected_session"] = selected + + session_name = self._clean_text(payload.get("session") or session_state.get("session")) or "default" + session_id = self._clean_text(payload.get("session_id") or session_state.get("session_id")) or self._assistant_session_id(session_name) + action_template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + session_templates = session_state.get("action_templates") if isinstance(session_state.get("action_templates"), list) else [] + payload.update({ + "protocol_version": "assistant.v1", + "compact": True, + "session": session_name, + "session_id": session_id, + "next_actions": [ + item for item in [ + recovery.get("recommended_action"), + *(session_state.get("suggested_actions") or []), + ] + if item + ][:6], + "action_templates": self._assistant_compact_action_templates(action_template, session_templates), + }) + return payload + + def _assistant_session_compact_data(self, session_state: Dict[str, Any]) -> Dict[str, Any]: + state = dict(session_state or {}) + recovery = dict(state.get("recovery") or {}) + saved_plan = dict(state.get("saved_plan") or {}) + pending_p115 = dict(state.get("pending_p115") or {}) + payload: Dict[str, Any] = { + "protocol_version": "assistant.v1", + "action": "session_state", + "ok": True, + "compact": True, + "has_session": bool(state.get("has_session")), + "session": self._clean_text(state.get("session")) or "default", + "session_id": self._clean_text(state.get("session_id")), + "kind": self._clean_text(state.get("kind")), + "stage": self._clean_text(state.get("stage")), + "keyword": self._clean_text(state.get("keyword")), + "target_path": self._clean_text(state.get("target_path")), + "updated_at": state.get("updated_at"), + "updated_at_text": state.get("updated_at_text"), + "saved_plan": { + "has_pending": bool(saved_plan.get("has_pending")), + "plan_id": self._clean_text((saved_plan.get("latest") or {}).get("plan_id") or saved_plan.get("plan_id")), + }, + "pending_p115": { + "has_pending": bool(pending_p115.get("has_pending")), + "target_path": self._clean_text(pending_p115.get("target_path")), + "retry_count": pending_p115.get("retry_count"), + }, + "recovery": recovery, + "next_actions": state.get("suggested_actions") or [], + "action_templates": self._assistant_compact_action_templates( + recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None, + state.get("action_templates") if isinstance(state.get("action_templates"), list) else [], + ), + } + for key in [ + "result_count", + "total_candidates", + "page", + "total_pages", + "selected_candidate", + "total_resources", + "resource_count_115", + "resource_count_quark", + "score_summary", + "client_type", + ]: + if key in state: + payload[key] = state.get(key) + return payload + + def _assistant_sessions_compact_data(self, sessions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(sessions_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + recovery = dict(item.get("recovery") or {}) + items.append({ + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "keyword": self._clean_text(item.get("keyword")), + "target_path": self._clean_text(item.get("target_path")), + "updated_at": item.get("updated_at"), + "updated_at_text": item.get("updated_at_text"), + "has_pending_plan": bool(item.get("has_pending_plan")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "recovery_mode": self._clean_text(recovery.get("mode")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + }) + recovery = dict(data.get("recovery") or {}) + return { + "protocol_version": "assistant.v1", + "action": "sessions", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "filters": data.get("filters") or {}, + "items": items, + "recovery": recovery, + "next_actions": [recovery.get("recommended_action")] if recovery.get("recommended_action") else [], + "action_templates": [recovery.get("action_template")] if isinstance(recovery.get("action_template"), dict) else [], + } + + def _assistant_history_compact_data(self, history_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(history_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + summary = dict(item.get("summary") or {}) + steps = summary.get("steps") + items.append({ + "time_text": self._clean_text(item.get("time_text")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "workflow": self._clean_text(summary.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "message_head": self._clean_text(item.get("message_head")), + "steps": steps if isinstance(steps, int) else None, + }) + return { + "protocol_version": "assistant.v1", + "action": "history", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "items": items, + } + + def _assistant_plans_compact_data(self, plans_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plans_data or {}) + items: List[Dict[str, Any]] = [] + first_pending: Optional[Dict[str, Any]] = None + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + plan_id = self._clean_text(item.get("plan_id")) + executed = bool(item.get("executed")) + compact_item = { + "plan_id": plan_id, + "workflow": self._clean_text(item.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "executed": executed, + "action_count": self._safe_int(item.get("action_count"), 0), + "created_at_text": self._clean_text(item.get("created_at_text")), + "last_success": item.get("last_success"), + "last_message": self._clean_text(item.get("last_message")), + } + items.append(compact_item) + if not executed and first_pending is None and plan_id: + first_pending = compact_item + templates: List[Dict[str, Any]] = [] + if first_pending: + templates.append(self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": first_pending.get("plan_id"), + "session": first_pending.get("session"), + "session_id": first_pending.get("session_id"), + "prefer_unexecuted": True, + }, + )) + return { + "protocol_version": "assistant.v1", + "action": "plans", + "ok": True, + "compact": True, + "total": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_matching": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_all": data.get("total_all") if data.get("total_all") is not None else (data.get("total") or 0), + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "executed": data.get("executed"), + "include_actions": False, + "items": items, + "next_actions": ["execute_plan"] if first_pending else [], + "action_templates": templates, + } + + def _assistant_compact_action_results(self, rows: Any) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in rows or []: + if not isinstance(item, dict): + continue + results.append({ + "index": item.get("index"), + "name": self._clean_text(item.get("name")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "ok": bool(item.get("ok")) if "ok" in item else bool(item.get("success")), + "message_head": self._clean_text(item.get("message_head")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "next_actions": item.get("next_actions") or [], + }) + if isinstance(item.get("score_summary"), dict): + results[-1]["score_summary"] = item.get("score_summary") + if isinstance(item.get("diagnosis_summary"), dict): + results[-1]["diagnosis_summary"] = item.get("diagnosis_summary") + return results + + def _assistant_plan_execute_followup( + self, + *, + workflow: str, + session: str, + session_id: str, + session_state: Optional[Dict[str, Any]] = None, + ok: bool, + plan_id: str = "", + ) -> Dict[str, Any]: + workflow_name = self._clean_text(workflow) + if not ok and workflow_name != "smart_resource_plan": + return {"next_actions": [], "action_templates": [], "recommended_action": "", "follow_up_hint": ""} + + workflow_stage = workflow_name + state = dict(session_state or {}) + session_name = self._clean_text(session or state.get("session")) or "default" + session_cache = self._clean_text(session_id or state.get("session_id")) or self._assistant_session_id(session_name) + keyword = self._clean_text(state.get("keyword")) + plan_execute_body = dict(state.get("plan_execute_body") or {}) + if workflow_name == "smart_resource_plan": + inferred_source = self._clean_text( + state.get("source_type") + or ((state.get("best_candidate") or {}).get("source_type")) + or plan_execute_body.get("mode") + ).lower() + if inferred_source == "mp": + inferred_source = "mp_pt" + if inferred_source == "mp_pt": + workflow_name = "mp_best_download" + elif inferred_source in {"pansou", "hdhive"}: + workflow_name = inferred_source + base_route = { + "session": session_name, + "session_id": session_cache, + } + base_state = { + "session": session_name, + "session_id": session_cache, + } + keyword_value = keyword or "<关键词>" + next_actions: List[str] = [] + templates: List[Dict[str, Any]] = [] + follow_up_hint = "" + clean_plan_id = self._clean_text(plan_id) + + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动去查下载历史,再继续判断是否已整理、已入库或失败。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询下载后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看下载历史,并继续追踪整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "keyword": keyword_value, "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总失败线索并给出本地/PT 入库诊断建议", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + next_actions = ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查订阅列表,再决定是否继续搜索或进入入库追踪。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询订阅后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看订阅列表,确认当前订阅是否已生效", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_subscribes", "status": "all", "keyword": keyword, "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": keyword_value}, + ), + ] + elif workflow_name in {"share_transfer", "pansou_transfer_selected", "hdhive_unlock", "hdhive_unlock_selected", "pansou", "hdhive"}: + next_actions = ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查整理/入库历史,失败时再切到本地诊断。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询云盘转存后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看最近整理/入库历史,确认转存资源是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_transfer_history", "keyword": keyword, "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总下载后未入库或整理失败的诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + if workflow_stage == "smart_resource_plan" and keyword: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_local_diagnose"] + follow_up_hint = "推荐先跟进当前发现链的落库状态;它会先看统一后续,再判断是否已入库或需要本地诊断。" + templates = [ + templates[0], + self._assistant_action_template( + name="query_mp_ingest_status", + description="按推荐标题查看当前是否已下载、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + templates[-1], + ] + elif workflow_name == "ai_replay_failed_sample": + next_actions = ["query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + follow_up_hint = "先回看 AI 工作清单和失败样本,确认这次二次识别是否减少了失败样本。" + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + follow_up_hint = "先看本地诊断或入库状态,再回看 AI 工作清单确认失败样本是否已减少。" + templates = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="查看这次二次识别关联的本地/PT 入库诊断", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续处理剩余失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,确认当前仍有哪些标题或路径失败", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,确认失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + + category = "cloud_write" + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + category = "mp_download" + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + category = "mp_subscribe" + elif workflow_name == "ai_replay_failed_sample": + category = "ai_reingest" + followup_summary = self._assistant_followup_summary( + category=category, + stage=self._clean_text(workflow_stage), + recommended_action=next_actions[0] if next_actions else "", + follow_up_hint=follow_up_hint, + next_actions=next_actions, + action_templates=templates, + keyword=keyword, + ) + return { + "next_actions": next_actions, + "action_templates": templates, + "recommended_action": next_actions[0] if next_actions else "", + "follow_up_hint": follow_up_hint, + "followup_summary": followup_summary, + } + + async def _assistant_execution_followup( + self, + request: Request, + *, + session: str, + session_id: str, + plan_id: str = "", + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + target_plan_id = self._clean_text(plan_id) + plan = self._find_workflow_plan( + plan_id=target_plan_id, + session=session_name, + session_id=cache_key, + executed=True if not target_plan_id else None, + ) + if target_plan_id and plan and not bool(plan.get("executed")): + return { + "success": False, + "message": f"计划 {target_plan_id} 还没有执行,暂时无法做执行后追踪。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "plan_not_executed", + "plan_id": target_plan_id, + }), + } + if not plan: + latest_any = self._find_workflow_plan(session=session_name, session_id=cache_key, executed=None) + error_code = "executed_plan_not_found" + if latest_any and not bool(latest_any.get("executed")): + error_code = "latest_plan_not_executed" + return { + "success": False, + "message": "当前会话没有可追踪的已执行计划,请先执行下载、订阅或转存计划。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": error_code, + "plan_id": target_plan_id, + }), + } + + source_plan = self._workflow_plan_public_item(plan, include_actions=False) + state = self._load_session(self._clean_text(plan.get("session_id"))) or {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(plan.get("workflow")), + session=self._clean_text(plan.get("session")) or session_name, + session_id=self._clean_text(plan.get("session_id")) or cache_key, + session_state=state, + ok=bool(plan.get("last_success")) if plan.get("last_success") is not None else True, + plan_id=self._clean_text(plan.get("plan_id")), + ) + action_templates = [item for item in (followup.get("action_templates") or []) if isinstance(item, dict)] + resolved_template = next( + (item for item in action_templates if self._clean_text(item.get("name")) != "query_execution_followup"), + {}, + ) + resolved_name = self._clean_text(resolved_template.get("name")) + if not resolved_name: + return { + "success": False, + "message": "当前执行计划没有可自动继续的只读追踪动作。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "followup_not_available", + "plan_id": self._clean_text(plan.get("plan_id")), + "source_plan": source_plan, + }), + } + action_body = dict(resolved_template.get("action_body") or resolved_template.get("body") or {}) + action_body["compact"] = False + action_body["apikey"] = self._extract_apikey(request, {}) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "execution_followup", + "ok": bool(result.get("success")), + "plan_id": self._clean_text(plan.get("plan_id")), + "workflow": self._clean_text(plan.get("workflow")), + "source_plan": source_plan, + "recommended_action": self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(followup.get("follow_up_hint")), + "resolved_followup_action": resolved_name, + "followup_summary": followup.get("followup_summary") or {}, + }) + message_lines = [ + f"已执行后续追踪:{resolved_name}", + self._clean_text(result.get("message")), + ] + message_lines.extend(self._format_followup_summary_lines(followup.get("followup_summary"))) + return { + "success": bool(result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + async def _assistant_smart_followup( + self, + request: Request, + *, + session: str, + session_id: str, + keyword: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + title = self._clean_text(keyword) + hash_text = self._clean_text(hash_value) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=cache_key) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id")) + latest_executed = bool(latest_plan.get("executed")) + + if title or hash_text: + result = await self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=title, + hash_value=hash_text, + limit=limit, + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_lifecycle_status", + "smart_mode": "keyword_lifecycle", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按关键词查看本地/PT 状态", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if latest_executed and latest_plan_id: + result = await self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=latest_plan_id, + ) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": self._clean_text(payload.get("resolved_followup_action")) or "execution_followup", + "smart_mode": "executed_plan_followup", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按最近已执行计划继续追踪", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if saved_plan.get("has_pending"): + template = self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True}, + ) + return { + "success": False, + "message": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "smart_followup", + "ok": False, + "error_code": "latest_plan_not_executed", + "recommended_action": "execute_session_latest_plan", + "follow_up_hint": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "action_templates": [template], + "smart_mode": "pending_plan_blocked", + }), + } + + result = await self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=max(5, limit), + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_recent_activity", + "smart_mode": "recent_activity_fallback", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:当前没有指定片名,也没有已执行计划,先看最近活动", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + def _assistant_actions_compact_data(self, actions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(actions_data or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "execute_actions", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=self._clean_text(data.get("error_code")), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=self._assistant_result_message_head(data.get("message") or data.get("message_head")), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in ["decision_mode", "decision_reason"]: + if key in data: + payload[key] = data.get(key) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return payload + + @staticmethod + def _assistant_compact_command_summary(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = dict(payload or {}) + score_summary = data.get("score_summary") if isinstance(data.get("score_summary"), dict) else {} + candidates = [ + ("error_summary", data.get("error_summary") if isinstance(data.get("error_summary"), dict) else {}), + ("followup_summary", data.get("followup_summary") if isinstance(data.get("followup_summary"), dict) else {}), + ("decision_summary", data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}), + ("score_summary", score_summary.get("decision") if isinstance(score_summary.get("decision"), dict) else {}), + ] + for source, summary in candidates: + if not isinstance(summary, dict) or not summary: + continue + compact_commands = [ + AgentResourceOfficer._clean_text(item) + for item in (summary.get("compact_commands") or summary.get("recommended_commands") or []) + if AgentResourceOfficer._clean_text(item) + ] + preferred_command = AgentResourceOfficer._clean_text(summary.get("preferred_command")) + fallback_command = AgentResourceOfficer._clean_text(summary.get("fallback_command")) + if preferred_command or compact_commands: + return { + "command_source": source, + "command_policy": AgentResourceOfficer._clean_text(summary.get("command_policy")) or ("confirm_then_resume" if bool(summary.get("preferred_requires_confirmation")) else "safe_read_only"), + "preferred_requires_confirmation": bool(summary.get("preferred_requires_confirmation")), + "fallback_requires_confirmation": bool(summary.get("fallback_requires_confirmation")), + "can_auto_run_preferred": bool(summary.get("can_auto_run_preferred")) if "can_auto_run_preferred" in summary else not bool(summary.get("preferred_requires_confirmation")), + "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), + "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), + "compact_commands": compact_commands[:2], + "recommended_agent_behavior": AgentResourceOfficer._clean_text(summary.get("recommended_agent_behavior")), + "auto_run_command": AgentResourceOfficer._clean_text(summary.get("auto_run_command")), + "confirm_command": AgentResourceOfficer._clean_text(summary.get("confirm_command")), + "display_command": AgentResourceOfficer._clean_text(summary.get("display_command")), + "detail_short_command": AgentResourceOfficer._clean_text(summary.get("detail_short_command")), + "decision_short_command": AgentResourceOfficer._clean_text(summary.get("decision_short_command")), + "plan_short_command": AgentResourceOfficer._clean_text(summary.get("plan_short_command")), + "confirm_short_command": AgentResourceOfficer._clean_text(summary.get("confirm_short_command")), + "pansou_short_command": AgentResourceOfficer._clean_text(summary.get("pansou_short_command")), + "hdhive_short_command": AgentResourceOfficer._clean_text(summary.get("hdhive_short_command")), + "mp_short_command": AgentResourceOfficer._clean_text(summary.get("mp_short_command")), + } + return {} + + def _assistant_plan_execute_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + success_count = len([item for item in results if item.get("success")]) + last_result = results[-1] if results else {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(data.get("workflow")), + session=self._clean_text(data.get("session") or session_state.get("session")) or "default", + session_id=self._clean_text(data.get("session_id") or session_state.get("session_id")), + session_state=session_state, + ok=bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + plan_id=self._clean_text(data.get("plan_id")), + ) + payload = { + "protocol_version": "assistant.v1", + "action": "execute_plan", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or "write", + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "plan_id": self._clean_text(data.get("plan_id")), + "workflow": self._clean_text(data.get("workflow")), + "plan_auto_selected": bool(data.get("plan_auto_selected")), + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "plan_executed_at": data.get("plan_executed_at"), + "plan_executed_at_text": data.get("plan_executed_at_text"), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "result_summary": { + "success_count": success_count, + "failure_count": max(len(results) - success_count, 0), + "last_action": self._clean_text(last_result.get("action")), + "last_message_head": self._clean_text(last_result.get("message_head")), + }, + "recommended_action": self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")), + "next_actions": self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or session_state.get("suggested_actions") or [], + ), + "action_templates": self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ), + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=payload.get("recommended_action"), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("recovery"), dict): + payload["recovery"] = data.get("recovery") + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_single_action_compact_response(self, name: str, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or self._clean_text(name) or "execute_action", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action")) or self._clean_text(name)), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "name": self._clean_text(name), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + for key in ["plan_id", "workflow", "plan_auto_selected", "has_session", "has_pending"]: + if key in data: + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in [ + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "decision_mode", + "decision_reason", + "detail_short_command", + "decision_short_command", + "plan_short_command", + "confirm_short_command", + "pansou_short_command", + "hdhive_short_command", + "mp_short_command", + "auto_run_command", + "confirm_command", + "display_command", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + ]: + if key in data: + payload[key] = data.get(key) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_interaction_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "assistant_interaction", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action"))), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "keyword": self._clean_text(session_state.get("keyword")), + "target_path": self._clean_text(session_state.get("target_path")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target") or session_state.get("keyword")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("insights"), dict): + payload["insights"] = data.get("insights") + if isinstance(data.get("recommend_handoff"), dict): + payload["recommend_handoff"] = data.get("recommend_handoff") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in [ + "provider", + "source", + "requested_source", + "fallback_source", + "media_type", + "page", + "total_pages", + "selected_candidate", + "selected_resource", + "plan_id", + "workflow", + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "smart_plan_auto_selected", + "smart_execute_auto_selected", + "decision_mode", + "decision_reason", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + "selected_index", + "return_short_command", + ]: + if key in data: + payload[key] = data.get(key) + for key in ["best_candidate"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("items"), list): + payload["items"] = data.get("items") + for key in ["items", "candidates", "resources"]: + if isinstance(data.get(key), list): + payload[f"{key}_count"] = len(data.get(key) or []) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_workflow_plan_compact_data(self, plan_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plan_data or {}) + session_state = dict(data.get("session_state") or {}) + plan_id = self._clean_text(data.get("plan_id")) + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的 dry_run 计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"plan_id": plan_id}, + ) if plan_id else None + return { + "protocol_version": "assistant.v1", + "action": "workflow_plan", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "plan_id": plan_id, + "workflow": self._clean_text(data.get("workflow")), + "dry_run": True, + "estimated_steps": data.get("estimated_steps") or 0, + "ready_to_execute": bool(data.get("ready_to_execute")), + "execute_plan_endpoint": data.get("execute_plan_endpoint"), + "execute_plan_body": data.get("execute_plan_body") or {"plan_id": plan_id}, + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "preference_status": data.get("preference_status") or {}, + "needs_onboarding": bool((data.get("preference_status") or {}).get("needs_onboarding")), + "score_summary": data.get("score_summary") or {}, + "decision_summary": data.get("decision_summary") or {}, + "best_candidate": data.get("best_candidate") or {}, + "sources_checked": data.get("sources_checked") or [], + "available_sources": data.get("available_sources") or [], + "blocked_sources": data.get("blocked_sources") or [], + "effective_preferences": data.get("effective_preferences") or {}, + "session_preference_overrides": data.get("session_preference_overrides") or {}, + "smart_plan_auto_selected": bool(data.get("smart_plan_auto_selected")), + "detail_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("detail_short_command")), + "decision_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_short_command")), + "plan_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("plan_short_command")), + "confirm_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("confirm_short_command")), + "pansou_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("pansou_short_command")), + "hdhive_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("hdhive_short_command")), + "mp_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("mp_short_command")), + "next_actions": ["execute_plan"] if plan_id else [], + "action_templates": [template] if template else [], + } + + def _format_assistant_sessions_text( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> str: + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有活跃的 Agent影视助手 会话。" + lines = [ + f"当前活跃会话:{data.get('total') or 0} 个", + "可直接用 assistant/session 查看单个会话详情,也可按 session_id 直接恢复最近计划。", + ] + for idx, item in enumerate(items, 1): + line = f"{idx}. {item.get('session')} | {item.get('kind') or '-'} | {item.get('stage') or '-'}" + if item.get("keyword"): + line = f"{line} | {item.get('keyword')}" + lines.append(line) + detail_parts: List[str] = [] + if item.get("target_path"): + detail_parts.append(f"目录:{item.get('target_path')}") + if item.get("updated_at_text"): + detail_parts.append(f"更新:{item.get('updated_at_text')}") + if item.get("has_pending_p115"): + detail_parts.append("含待继续115任务") + if item.get("has_pending_plan"): + detail_parts.append("含待执行计划") + if item.get("selected_title"): + detail_parts.append(f"已选:{item.get('selected_title')}") + if item.get("result_count"): + detail_parts.append(f"结果:{item.get('result_count')}") + if item.get("total_candidates"): + detail_parts.append(f"候选:{item.get('total_candidates')}") + if item.get("total_resources"): + detail_parts.append(f"资源:{item.get('total_resources')}") + if detail_parts: + lines.append(" " + " | ".join(detail_parts)) + return "\n".join(lines) + + def _clear_assistant_sessions( + self, + *, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + cleared_ids: List[str] = [] + if self._clean_text(session_id) or self._clean_text(session): + _, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if cache_key in self._session_cache: + self._session_cache.pop(cache_key, None) + cleared_ids.append(cache_key) + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + kind_filter = self._clean_text(kind) + for current_session_id, payload in list((self._session_cache or {}).items()): + if len(cleared_ids) >= max_limit: + break + if not str(current_session_id).startswith("assistant::"): + continue + current = dict(payload or {}) + expired = self._is_session_expired(current) + if stale_only and not expired: + continue + if not stale_only and expired: + continue + if not all_sessions: + if kind_filter and self._clean_text(current.get("kind")) != kind_filter: + continue + if has_pending_p115 is not None: + has_pending = bool(self._clean_text(((current.get("pending_p115") or {}).get("share_url")))) + if has_pending != bool(has_pending_p115): + continue + if not kind_filter and has_pending_p115 is None and not stale_only: + continue + self._session_cache.pop(current_session_id, None) + cleared_ids.append(str(current_session_id)) + + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + def _format_assistant_session_summary(self, session: str = "default") -> str: + data = self._assistant_session_public_data(session=session) + if not data.get("has_session"): + return "\n".join([ + "当前没有活跃会话。", + "可直接调用 smart_entry 发起新操作,例如:", + "1. text=盘搜搜索 大君夫人", + "2. text=影巢搜索 蜘蛛侠", + "3. text=链接 https://115cdn.com/s/xxxx path=/待整理", + ]) + + lines = [ + "当前会话状态", + f"会话:{data.get('session')}", + f"类型:{data.get('kind') or '-'}", + f"阶段:{data.get('stage') or '-'}", + ] + if data.get("keyword"): + lines.append(f"关键词:{data.get('keyword')}") + if data.get("target_path"): + lines.append(f"目录:{data.get('target_path')}") + if data.get("updated_at_text"): + lines.append(f"最近更新:{data.get('updated_at_text')}") + if data.get("kind") == "assistant_pansou": + lines.append(f"结果数:{data.get('result_count') or 0}") + lines.append("下一步:调用 smart_pick,传入 choice=编号") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "candidate": + lines.append(f"候选数:{data.get('total_candidates') or 0}") + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=编号,或 action=详情 / 下一页") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "resource": + selected = data.get("selected_candidate") or {} + if selected.get("title"): + lines.append(f"已选影片:{selected.get('title')} ({selected.get('year') or '-'})") + lines.append(f"资源数:{data.get('total_resources') or 0}") + if data.get("page") and data.get("total_pages"): + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=资源编号,或 action=下一页") + elif data.get("kind") == "assistant_p115_login": + lines.append(f"扫码客户端:{data.get('client_type') or self._p115_client_type}") + lines.append("下一步:调用 smart_entry,传入 text=检查115登录") + + pending = data.get("pending_p115") or {} + if pending.get("has_pending"): + lines.append("存在待继续的 115 任务") + lines.append(f"任务:{pending.get('title')}") + lines.append(f"待转目录:{pending.get('target_path')}") + + actions = data.get("suggested_actions") or [] + if actions: + lines.append("建议动作:" + " / ".join(str(item) for item in actions if item)) + return "\n".join(lines) + + def _session_key_for_tool(self, session: str = "default") -> str: + clean_session = self._clean_text(session) or "default" + if clean_session.startswith("assistant::"): + return clean_session + return self._assistant_session_id(clean_session) + + def _execute_pending_p115_share( + self, + *, + session_id: str, + state: Dict[str, Any], + trigger: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return False, "", {} + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + transfer_ok, result, transfer_message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(pending.get("access_code")), + path=target_path, + trigger=trigger, + ) + if transfer_ok: + self._clear_pending_p115_share(session_id) + message = "\n".join( + [ + "115 转存已完成", + f"目录:{result.get('path') or target_path}", + f"结果:{transfer_message or result.get('message') or 'success'}", + ] + ) + return True, message, {"provider": "115", "result": result} + + failure_message = self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path, + ) + current_state = self._load_session(session_id) or dict(state or {}) + pending["retry_count"] = max(0, self._safe_int(pending.get("retry_count"), 0)) + 1 + pending["last_attempt_at"] = int(time.time()) + pending["last_error"] = failure_message + current_state["pending_p115"] = pending + if not current_state.get("kind"): + current_state["kind"] = "assistant_p115_pending" + current_state["stage"] = "pending_login" + self._save_session(session_id, current_state) + return False, failure_message, {"provider": "115", "result": result} + + async def _resume_pending_p115_share( + self, + request: Request, + body: Dict[str, Any], + *, + session_id: str, + state: Dict[str, Any], + ) -> Tuple[bool, str, Dict[str, Any]]: + return self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 115 登录后自动继续", + ) + + def _format_p115_status_summary(self, *, title: str = "115 当前状态") -> str: + status = self._p115_status_snapshot() + lines = [ + title, + f"可用状态:{'可用' if status.get('ready') else '待修复'}", + f"默认目录:{status.get('default_target_path') or self._p115_default_path}", + f"扫码客户端:{status.get('client_type') or self._p115_client_type}", + ] + if status.get("direct_source"): + lines.append(f"直转来源:{status.get('direct_source')}") + elif status.get("helper_ready"): + lines.append("直转来源:P115StrmHelper") + if status.get("cookie_mode") == "client_cookie": + lines.append("当前会话:已保存扫码会话") + elif status.get("cookie_mode") == "invalid_cookie": + lines.append("当前会话:已配置但看起来不是扫码会话") + else: + lines.append("当前会话:复用 115 助手客户端") + if status.get("message") and not status.get("ready"): + lines.append(f"详情:{status.get('message')}") + lines.append(self._format_p115_next_actions(status)) + return "\n".join(lines) + + def _format_p115_help_text(self) -> str: + status = self._p115_status_snapshot() + final_path = status.get("default_target_path") or self._p115_default_path + lines = [ + "115 使用帮助", + f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}", + f"默认目录:{final_path}", + "如果 115 转存因登录问题失败,我会记住这次任务;扫码成功后回复 检查115登录,会自动继续执行。", + "常用示例:", + f"1. 链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 影巢搜索 蜘蛛侠", + "3. 盘搜搜索 大君夫人", + "4. 115登录", + "5. 检查115登录", + "6. 115状态", + "7. 继续115任务 / 取消115任务", + self._format_p115_next_actions(status), + ] + return "\n".join(lines) + + def _format_assistant_help_text(self, session: str = "default") -> str: + session_name = self._clean_text(session) or "default" + lines = [ + "Agent影视助手 使用帮助", + f"当前会话:{session_name}", + "推荐优先使用原生 Tool:agent_resource_officer_smart_entry 与 agent_resource_officer_smart_pick。", + "smart_entry 常用示例:", + "1. text=盘搜搜索 大君夫人", + "2. text=搜索 大君夫人 默认走盘搜", + "3. text=云盘搜索 大君夫人 只走盘搜 + 影巢", + "4. text=影巢搜索 蜘蛛侠", + "5. text=MP搜索 蜘蛛侠 或 PT搜索 蜘蛛侠", + "6. text=115登录", + "7. text=检查115登录", + "8. text=链接 https://115cdn.com/s/xxxx path=/待整理", + "9. text=链接 https://pan.quark.cn/s/xxxx 位置=分享", + "10. text=转存 蜘蛛侠 默认等同 115转存;text=下载 蜘蛛侠 只走 MP/PT,先展示候选和 PT 资源,不自动提交下载", + "11. text=下载任务;暂停下载 1 / 恢复下载 1 / 删除下载 1 会先生成计划", + "12. text=站点状态;下载器状态 用于排查 PT 搜索/下载环境", + "13. text=记录 片名 用于判断资源是否提交过下载并进入整理流程", + "14. text=状态 片名 一次查看下载任务、下载历史和入库历史", + "15. text=识别 片名 使用 MoviePilot 原生识别确认 TMDB/Douban/IMDB 信息", + "16. text=订阅列表;搜索订阅 1 / 暂停订阅 1 / 恢复订阅 1 / 删除订阅 1 会先生成计划", + "17. text=入库记录;入库失败 片名 用于判断下载后是否已经整理落库", + "18. text=执行计划 执行当前会话最近待执行计划;text=执行 plan-xxxx 精确执行指定计划", + "19. text=偏好 / 保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库 / 重置偏好", + "20. text=后续 / 最近 / 入库 片名 / 诊断 片名 是更省 token 的本地/PT 跟踪短命令", + "21. text=跟进 / 跟进 片名 是统一跟进入口:有已执行计划时自动跟执行后状态,有片名时直接看生命周期", + "smart_pick 常用示例:", + "1. choice=1", + "2. action=详情", + "3. action=下一页", + "MP 搜索结果里,choice=1 会先展示 PT 详情和评分理由;确认下载再发 text=下载1。", + "MP 搜索结果里,action=最佳 会展示当前评分最高候选,适合智能体省 token 决策。", + "MP 搜索结果里,text=下载最佳 会按当前最高分候选生成下载计划,不会静默下载。", + "说明:同一个 session 会自动串起候选列表、资源列表、115 待任务与扫码续跑。", + self._format_p115_next_actions(self._p115_status_snapshot()), + ] + pending_summary = self._pending_p115_summary(self._load_session(self._assistant_session_id(session_name)) or {}) + if pending_summary: + lines.extend(["", pending_summary]) + return "\n".join(line for line in lines if line) + + def _assistant_capabilities_public_data(self) -> Dict[str, Any]: + return { + "version": self.plugin_version, + "defaults": { + "hdhive_path": self._hdhive_default_path, + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "p115_client_type": self._p115_client_type, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "pt_min_seeders": self._default_assistant_preferences().get("pt_min_seeders"), + "auto_ingest": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_enabled": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_score_threshold": self._default_assistant_preferences().get("auto_ingest_score_threshold"), + "confirm_score_threshold": self._default_assistant_preferences().get("confirm_score_threshold"), + }, + "smart_entry": { + "supports_text": True, + "supports_structured_fields": True, + "modes": ["mp", "pansou", "hdhive"], + "actions": [ + "assistant_help", + "p115_qrcode_start", + "p115_qrcode_check", + "p115_status", + "p115_help", + "p115_pending", + "p115_resume", + "p115_cancel", + "hdhive_checkin", + "hdhive_checkin_history", + "mp_media_detail", + "mp_download_tasks", + "mp_download_history", + "mp_lifecycle_status", + "mp_download_control", + "mp_downloaders", + "mp_sites", + "mp_subscribes", + "mp_subscribe_control", + "mp_transfer_history", + "mp_download", + "mp_download_best", + "mp_subscribe", + "mp_subscribe_search", + "mp_recommendations", + "execute_plan", + "plans_list", + "plans_clear", + "scoring_policy", + "preferences_get", + "preferences_save", + "preferences_reset", + ], + "structured_fields": [ + "session", + "session_id", + "path", + "mode", + "keyword", + "url", + "access_code", + "media_type", + "year", + "client_type", + "is_gambler", + "action", + "plan_id", + "status", + "hash", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "downloader", + "download_control", + "subscribe_control", + "delete_files", + "page", + "compact", + ], + }, + "assistant_preferences": { + "fields": ["session", "session_id", "user_key", "preferences", "reset", "compact"], + "description": "智能体片源偏好画像:云盘与 PT 分源评分都会读取这里;无偏好时建议先完成一次偏好询问。", + }, + "scoring_policy": self._assistant_scoring_policy_public_data(), + "smart_pick": { + "fields": ["session", "session_id", "choice", "action", "path", "compact"], + "actions": ["detail", "next_page"], + }, + "assistant_session": { + "fields": ["session", "session_id", "compact"], + "description": "compact=true 时返回低 token 会话快照,不嵌套完整 session_state。", + }, + "assistant_capabilities": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 能力清单,不嵌套完整 session_state。", + }, + "assistant_readiness": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 就绪状态,不嵌套完整 session_state。", + }, + "assistant_sessions": { + "fields": ["kind", "has_pending_p115", "compact", "limit"], + "description": "compact=true 时返回低 token 会话列表,不嵌套 default session_state。", + }, + "assistant_history": { + "fields": ["session", "session_id", "compact", "limit"], + "description": "compact=true 时返回低 token 执行历史,不嵌套 default session_state。", + }, + "assistant_action": { + "fields": [ + "name", + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "source", + "status", + "hash", + "target", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "control", + "subscribe_control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "page", + "plan_id", + "prefer_unexecuted", + "compact", + ], + "description": "compact=true 时返回低 token 单动作摘要,不嵌套完整 session_state。", + }, + "assistant_actions": { + "fields": [ + "actions", + "session", + "session_id", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 批量执行摘要,不嵌套完整 session_state。", + }, + "assistant_workflow": self._assistant_workflow_catalog(), + "assistant_plan_execute": { + "fields": [ + "plan_id", + "session", + "session_id", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 计划执行摘要,不嵌套完整 session_state。", + }, + "assistant_plans": { + "fields": [ + "session", + "session_id", + "executed", + "include_actions", + "compact", + "limit", + ], + "description": "compact=true 时返回低 token 计划列表,不嵌套 default session_state。", + }, + "assistant_plans_clear": { + "fields": [ + "plan_id", + "session", + "session_id", + "executed", + "all_plans", + "limit", + ], + }, + "assistant_recover": { + "fields": [ + "session", + "session_id", + "execute", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + "limit", + ], + "description": "单入口恢复协议:不传 session 时自动挑选最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可返回低 token 回执。", + }, + "assistant_maintain": { + "fields": [ + "execute", + "limit", + ], + "description": "低风险维护入口:execute=false 只返回建议;execute=true 清理过期会话和已执行计划,不清理待执行计划。", + }, + "assistant_request_templates": { + "fields": [ + "limit", + ], + "description": "轻量请求模板入口:返回外部智能体常用 assistant 请求模板,适合缓存为调用说明。", + }, + "session_tools": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/toolbox", + "assistant/request_templates", + "assistant/selfcheck", + "assistant/readiness", + "assistant/history", + "assistant/action", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + "assistant/plans/clear", + "assistant/recover", + "assistant/sessions", + "assistant/sessions/clear", + "assistant/session", + "assistant/session/clear", + ], + "response_envelope": { + "fields": [ + "protocol_version", + "action", + "ok", + "session", + "session_id", + "session_state", + "next_actions", + "action_templates", + ], + "description": "assistant/route 与 assistant/pick 返回的 data 中会统一附带当前会话状态、建议下一步动作与可直接调用的动作模板,上层智能体可直接按结构化字段继续编排。", + }, + "agent_tools": [ + "agent_resource_officer_capabilities", + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_toolbox", + "agent_resource_officer_request_templates", + "agent_resource_officer_selfcheck", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_history", + "agent_resource_officer_execute_action", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_plans", + "agent_resource_officer_plans_clear", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_preferences", + "agent_resource_officer_help", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + "agent_resource_officer_sessions", + "agent_resource_officer_sessions_clear", + "agent_resource_officer_session_state", + "agent_resource_officer_session_clear", + ], + } + + def _assistant_capabilities_compact_data(self, capabilities_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(capabilities_data or {}) + workflow_catalog = dict(data.get("assistant_workflow") or {}) + workflows = [ + self._clean_text(item.get("name")) + for item in workflow_catalog.get("workflows") or [] + if isinstance(item, dict) and self._clean_text(item.get("name")) + ] + compact_endpoints = [ + "assistant/capabilities", + "assistant/startup", + "assistant/maintain", + "assistant/request_templates", + "assistant/readiness", + "assistant/recover", + "assistant/session", + "assistant/sessions", + "assistant/history", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + ] + return { + "protocol_version": "assistant.v1", + "action": "capabilities", + "ok": True, + "compact": True, + "version": data.get("version"), + "defaults": data.get("defaults") or {}, + "smart_entry_modes": (data.get("smart_entry") or {}).get("modes") or [], + "smart_entry_actions": (data.get("smart_entry") or {}).get("actions") or [], + "smart_pick_actions": (data.get("smart_pick") or {}).get("actions") or [], + "workflows": workflows, + "request_templates": bool(data.get("assistant_request_templates")), + "scoring_policy": data.get("scoring_policy") or {}, + "recommended_start": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/selfcheck", + "assistant/toolbox", + "assistant/request_templates", + "assistant/readiness?compact=true", + ], + "compact_endpoints": compact_endpoints, + "agent_tools": data.get("agent_tools") or [], + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_readiness", "smart_entry", "assistant_workflow"], + } + + def _format_assistant_capabilities_text(self) -> str: + data = self._assistant_capabilities_public_data() + defaults = data.get("defaults") or {} + lines = [ + "Agent影视助手 能力说明", + f"版本:{data.get('version')}", + "推荐上层调用顺序:", + "1. 先看 capabilities 或 assistant/startup", + "2. 如需恢复会话,可先看 assistant/sessions", + "3. 再调用 smart_entry", + "4. 之后用 assistant/session 或 session_state 判断下一步", + "5. 最后再调用 smart_pick 或 session_clear", + "默认目录:", + f"- 影巢:{defaults.get('hdhive_path')}", + f"- 115:{defaults.get('p115_path')}", + f"- 夸克:{defaults.get('quark_path')}", + f"- 115 客户端:{defaults.get('p115_client_type')}", + f"影巢资源入口:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'};单资源积分上限:{defaults.get('hdhive_max_unlock_points')} 分(0 表示不限制)", + "默认评分策略:", + f"- PT 最低做种数:{defaults.get('pt_min_seeders')}", + f"- 建议确认分数线:{defaults.get('confirm_score_threshold')}", + f"- 自动入库:{'开启' if defaults.get('auto_ingest_enabled') else '关闭'};自动入库分数线:{defaults.get('auto_ingest_score_threshold')}", + "启动聚合包:assistant/startup,一次返回 pulse、自检、核心工具、端点和恢复建议,适合外部智能体开场调用", + "轻量启动探针:assistant/pulse,返回版本、关键服务状态与最佳恢复建议,适合外部智能体每次开场调用", + "轻量工具清单:assistant/toolbox,返回推荐工具、端点、工作流和命令示例,适合外部智能体初始化系统提示", + "轻量协议自检:assistant/selfcheck,返回 compact 模板、布尔解析和低 token 入口健康状态", + "启动探针:assistant/readiness,可直接判断外部智能体是否可以开始调用;compact=true 可减少嵌套回执", + "执行历史:assistant/history,可查看最近 action/workflow 的成功状态和摘要;compact=true 可减少嵌套回执", + "smart_entry 结构化字段:session / session_id / path / mode / keyword / url / access_code / media_type / year / client_type / action / plan_id", + "smart_entry 结构化模式:mp / pansou / hdhive", + "smart_entry 动作:assistant_help / p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel", + "smart_entry 与 smart_pick 支持 compact=true,可减少搜索与选择链路嵌套回执", + "smart_pick 字段:session / session_id / choice / action / path / compact", + "smart_pick 动作:detail / next_page", + "动作执行入口:assistant/action,可直接执行 action_templates 里的 name + body;compact=true 可减少嵌套回执", + "批量动作入口:assistant/actions,可一次执行多步 action_body;compact=true 可减少嵌套回执", + "预设工作流入口:assistant/workflow,可用 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / share_transfer / p115_status 等短参数场景;compact=true 可减少嵌套回执", + "计划执行入口:assistant/plan/execute,可执行 dry_run 返回的 plan_id;compact=true 可减少嵌套回执", + "自然语言计划确认:smart_entry 支持“执行计划”和“执行 plan-xxxx”,用于确认已生成的下载、订阅或控制计划", + "计划管理入口:assistant/plans 与 assistant/plans/clear,可查询或清理 dry_run 保存的计划;compact=true 可减少嵌套回执", + "单入口恢复:assistant/recover,可自动选择最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可减少回执字段", + "统一回执字段:protocol_version / action / ok / session / session_id / session_state / next_actions / action_templates", + ] + return "\n".join(lines) + + def _assistant_readiness_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + sessions = self._assistant_sessions_public_data(limit=10) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_resource_enabled: + warnings.append("影巢资源搜索/解锁已关闭,外部智能体应改用 MP 搜索或盘搜") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置,影巢相关工作流不可用") + if not p115_status.get("ready"): + warnings.append("115 当前不可用,需要先扫码或修复执行层") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置,夸克转存可能需要先刷新") + + ready_for_external_agent = bool(self._enabled) + pending_plans = self._assistant_plans_public_data(executed=False, limit=5) + pending_plan_templates = [ + self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": item.get("plan_id"), + "session": item.get("session"), + "session_id": item.get("session_id"), + "prefer_unexecuted": True, + }, + ) + for item in (pending_plans.get("items") or []) + if isinstance(item, dict) and self._clean_text(item.get("plan_id")) + ] + return { + "version": self.plugin_version, + "enabled": self._enabled, + "ready_for_external_agent": ready_for_external_agent, + "can_start": ready_for_external_agent, + "services": { + "p115": p115_status, + "hdhive": { + "configured": bool(self._hdhive_api_key), + "base_url": self._hdhive_base_url, + "default_path": self._hdhive_default_path, + }, + "quark": { + "configured": bool(self._quark_cookie), + "default_path": self._quark_default_path, + "auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + }, + }, + "active_sessions": { + "total": sessions.get("total") or 0, + "preview": sessions.get("items") or [], + }, + "saved_plans": { + "total": len(self._workflow_plans or {}), + "pending": len(pending_plans.get("items") or []), + "pending_preview": pending_plans.get("items") or [], + "action_templates": pending_plan_templates, + }, + "recovery": ( + { + "mode": "resume_saved_plan", + "reason": "当前存在待执行计划,可直接恢复", + "can_resume": True, + "recommended_action": self._clean_text((pending_plan_templates[0] or {}).get("name")) if pending_plan_templates else "", + "recommended_tool": self._clean_text((pending_plan_templates[0] or {}).get("tool")) if pending_plan_templates else "", + "action_template": pending_plan_templates[0] if pending_plan_templates else None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in pending_plan_templates[:5] + if self._clean_text(item.get("name")) + ], + } + if pending_plan_templates + else { + "mode": "start_new", + "reason": "当前没有待恢复计划,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + "recommended_entrypoints": [ + "GET /api/v1/plugin/AgentResourceOfficer/assistant/startup", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/readiness", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/actions", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/route", + ], + "recommended_tools": [ + "agent_resource_officer_startup", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_run_workflow", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_smart_entry", + ], + "warnings": warnings, + "suggested_first_call": { + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "body": { + "name": "pansou_search", + "session": "external-agent-demo", + "keyword": "片名", + }, + }, + } + + def _assistant_readiness_compact_data(self, readiness_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(readiness_data or {}) + services = dict(data.get("services") or {}) + p115 = dict(services.get("p115") or {}) + hdhive = dict(services.get("hdhive") or {}) + quark = dict(services.get("quark") or {}) + recovery = dict(data.get("recovery") or {}) + saved_plans = dict(data.get("saved_plans") or {}) + template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + return { + "protocol_version": "assistant.v1", + "action": "readiness", + "ok": bool(data.get("can_start")), + "compact": True, + "version": data.get("version"), + "enabled": bool(data.get("enabled")), + "can_start": bool(data.get("can_start")), + "services": { + "p115_ready": bool(p115.get("ready")), + "hdhive_configured": bool(hdhive.get("configured")), + "quark_configured": bool(quark.get("configured")), + }, + "active_sessions_total": (data.get("active_sessions") or {}).get("total") or 0, + "saved_plans_total": saved_plans.get("total") or 0, + "saved_plans_pending": saved_plans.get("pending") or 0, + "recovery": { + "mode": self._clean_text(recovery.get("mode")), + "can_resume": bool(recovery.get("can_resume")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + "recommended_tool": self._clean_text(recovery.get("recommended_tool")), + "reason": self._clean_text(recovery.get("reason")), + }, + "warnings": data.get("warnings") or [], + "next_actions": [ + item for item in [ + recovery.get("recommended_action") if recovery.get("can_resume") else "", + "assistant_workflow", + "smart_entry", + ] + if item + ], + "action_templates": [template] if template else [], + } + + def _format_assistant_readiness_text(self) -> str: + data = self._assistant_readiness_public_data() + services = data.get("services") or {} + p115 = services.get("p115") or {} + hdhive = services.get("hdhive") or {} + quark = services.get("quark") or {} + lines = [ + "Agent影视助手 启动就绪", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"外部智能体:{'可以启动' if data.get('can_start') else '暂不可启动'}", + f"115:{'可用' if p115.get('ready') else '不可用'}", + f"影巢:{'已配置' if hdhive.get('configured') else '未配置'}", + f"夸克:{'已配置' if quark.get('configured') else '未配置'}", + f"活跃会话:{(data.get('active_sessions') or {}).get('total') or 0}", + f"待执行计划:{(data.get('saved_plans') or {}).get('pending') or 0}", + "推荐入口:assistant/workflow 或 assistant/actions", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_pulse_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + recovery_data = self._assistant_recover_public_data(limit=10) + recovery_data.update({ + "action": "recover", + "ok": True, + "execute_requested": False, + "executed": False, + }) + recovery_compact = self._assistant_recover_response_data(recovery_data, compact=True) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置") + if not p115_status.get("ready"): + warnings.append("115 当前不可用") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置") + return { + "protocol_version": "assistant.v1", + "action": "pulse", + "ok": bool(self._enabled), + "version": self.plugin_version, + "enabled": self._enabled, + "can_start": bool(self._enabled), + "services": { + "p115_ready": bool(p115_status.get("ready")), + "p115_direct_ready": bool(p115_status.get("direct_ready")), + "hdhive_configured": bool(self._hdhive_api_key), + "quark_configured": bool(self._quark_cookie), + }, + "warnings": warnings, + "session": recovery_compact.get("session"), + "session_id": recovery_compact.get("session_id"), + "recovery": recovery_compact.get("recovery") or {}, + "selected_session": recovery_compact.get("selected_session"), + "next_actions": recovery_compact.get("next_actions") or [], + "action_templates": recovery_compact.get("action_templates") or [], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + }, + } + + def _format_assistant_pulse_text(self) -> str: + data = self._assistant_pulse_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + lines = [ + "Agent影视助手 轻量启动状态", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'}", + f"影巢:{'已配置' if services.get('hdhive_configured') else '未配置'}", + f"夸克:{'已配置' if services.get('quark_configured') else '未配置'}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_maintenance_snapshot(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + pending_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and not bool(item.get("executed")) + ) + executed_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and bool(item.get("executed")) + ) + stale_session_count = sum( + 1 + for item in (self._session_cache or {}).values() + if isinstance(item, dict) and self._is_session_expired(item) + ) + action_templates = [ + self._assistant_action_template( + name="clear_stale_sessions", + description="清理过期 assistant 会话,不影响仍有效的当前会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"stale_only": True, "limit": max_limit}, + ), + self._assistant_action_template( + name="clear_executed_plans", + description="清理已执行的保存计划,不影响待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", + tool="agent_resource_officer_plans_clear", + body={"executed": True, "limit": max_limit}, + ), + ] + recommended_actions = [ + item.get("name") + for item in action_templates + if ( + (item.get("name") == "clear_stale_sessions" and stale_session_count > 0) + or (item.get("name") == "clear_executed_plans" and executed_plan_count > 0) + ) + ] + return { + "active_sessions": len(self._session_cache or {}), + "stale_sessions": stale_session_count, + "saved_plans_total": len(self._workflow_plans or {}), + "saved_plans_pending": pending_plan_count, + "saved_plans_executed": executed_plan_count, + "recommended_actions": recommended_actions, + "action_templates": action_templates, + "safe_to_execute": bool(recommended_actions), + "dry_run_method": "GET", + "execute_method": "POST", + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "execute_body": {"execute": True, "limit": max_limit}, + "execution_note": "GET 只返回 dry-run;只有 POST execute=true 会实际清理,并写入 assistant/history。", + "limit": max_limit, + } + + def _assistant_maintain_public_data(self, execute: bool = False, limit: int = 100) -> Dict[str, Any]: + before = self._assistant_maintenance_snapshot(limit=limit) + executed_actions: List[Dict[str, Any]] = [] + if execute: + if before.get("stale_sessions", 0) > 0: + result = self._clear_assistant_sessions(stale_only=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_stale_sessions", + "success": True, + "removed": result.get("cleared_count") or 0, + }) + if before.get("saved_plans_executed", 0) > 0: + result = self._clear_workflow_plans(executed=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_executed_plans", + "success": bool(result.get("ok")), + "removed": result.get("removed") or 0, + }) + after = self._assistant_maintenance_snapshot(limit=limit) + return { + "protocol_version": "assistant.v1", + "action": "maintain", + "ok": True, + "compact": True, + "version": self.plugin_version, + "execute_requested": bool(execute), + "executed": bool(executed_actions), + "executed_actions": executed_actions, + "before": before, + "after": after, + "next_actions": after.get("recommended_actions") or [], + "action_templates": after.get("action_templates") or [], + } + + def _format_assistant_maintain_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_maintain_public_data(execute=False) + before = payload.get("before") or {} + after = payload.get("after") or before + lines = [ + "Agent影视助手 低风险维护", + f"版本:{payload.get('version')}", + f"执行:{'是' if payload.get('execute_requested') else '否'}", + "维护前:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**before), + "维护后:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**after), + ] + executed_actions = payload.get("executed_actions") or [] + if executed_actions: + lines.append("已执行:" + " / ".join(f"{item.get('name')}({item.get('removed')})" for item in executed_actions)) + recommended = after.get("recommended_actions") or [] + if recommended: + lines.append("仍建议:" + " / ".join(str(item) for item in recommended if item)) + return "\n".join(lines) + + def _assistant_startup_public_data(self) -> Dict[str, Any]: + pulse = self._assistant_pulse_public_data() + selfcheck = self._assistant_selfcheck_public_data() + toolbox = self._assistant_toolbox_public_data() + maintenance = self._assistant_maintenance_snapshot(limit=100) + request_templates = self._assistant_request_templates_public_data(limit=100) + tools = toolbox.get("tools") or {} + endpoints = toolbox.get("endpoints") or {} + key_names = [ + "startup", + "maintain", + "pulse", + "selfcheck", + "request_templates", + "recover", + "workflow", + "route", + "pick", + "execute_action", + "execute_actions", + ] + key_tools = {name: tools.get(name) for name in key_names if tools.get(name)} + key_endpoints = { + name: endpoints.get(name) + for name in ["startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "action", "actions", "route", "pick"] + if endpoints.get(name) + } + recovery = pulse.get("recovery") or {} + recommended_templates_recipe = "continue" if bool(recovery.get("can_resume")) else "bootstrap" + recommended_templates_reason = ( + "检测到可恢复会话,优先读取继续会话流程。" + if recommended_templates_recipe == "continue" + else "未检测到必须恢复的会话,优先读取安全启动流程。" + ) + return { + "protocol_version": "assistant.v1", + "action": "startup", + "ok": bool(pulse.get("can_start")) and bool(selfcheck.get("ok")), + "compact": True, + "version": self.plugin_version, + "services": pulse.get("services") or {}, + "warnings": pulse.get("warnings") or [], + "session": pulse.get("session"), + "session_id": pulse.get("session_id"), + "recovery": pulse.get("recovery") or {}, + "selected_session": pulse.get("selected_session"), + "action_templates": pulse.get("action_templates") or [], + "maintenance": maintenance, + "selfcheck": { + "ok": bool(selfcheck.get("ok")), + "checks": selfcheck.get("checks") or {}, + }, + "defaults": toolbox.get("defaults") or {}, + "startup_order": toolbox.get("startup_order") or [], + "tools": key_tools, + "endpoints": key_endpoints, + "workflows": [item.get("name") for item in (toolbox.get("workflows") or []) if item.get("name")], + "actions": toolbox.get("actions") or [], + "command_examples": (toolbox.get("command_examples") or [])[:6], + "request_templates": request_templates, + "request_templates_schema_version": self.request_templates_schema_version, + "recommended_request_templates": self._assistant_recommended_request_templates_data( + recipe=recommended_templates_recipe, + reason=recommended_templates_reason, + ), + "next_actions": pulse.get("next_actions") or ["assistant_recover", "assistant_workflow", "smart_entry"], + "recommended_endpoints": key_endpoints, + } + + def _assistant_recommended_request_templates_data(self, recipe: str = "bootstrap", reason: str = "") -> Dict[str, Any]: + recipe_name = self._clean_text(recipe) or "bootstrap" + return { + "recipe": recipe_name, + "reason": self._clean_text(reason), + "include_templates": False, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/request_templates?apikey={MP_API_TOKEN}", + "tool": "agent_resource_officer_request_templates", + "tool_args": { + "recipe": recipe_name, + "include_templates": False, + }, + } + + def _format_assistant_startup_text(self) -> str: + data = self._assistant_startup_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + checks = (data.get("selfcheck") or {}).get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 启动聚合包", + f"版本:{data.get('version')}", + f"可启动:{'是' if data.get('ok') else '否'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'};影巢:{'已配' if services.get('hdhive_configured') else '未配'};夸克:{'已配' if services.get('quark_configured') else '未配'}", + f"自检:{'通过' if not failed else '失败 ' + ', '.join(failed)}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + f"可执行模板:{len(data.get('action_templates') or [])} 个", + "状态:活跃会话 {active_sessions};过期会话 {stale_sessions};保存计划 {saved_plans_total};待执行计划 {saved_plans_pending};已执行计划 {saved_plans_executed}".format(**(data.get("maintenance") or {})), + "下一步:优先按 recovery 建议执行;没有待恢复任务时使用 workflow 或 smart_entry", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + maintenance = data.get("maintenance") or {} + recommended_actions = maintenance.get("recommended_actions") or [] + if recommended_actions: + lines.append("维护建议:" + " / ".join(str(item) for item in recommended_actions if item)) + return "\n".join(lines) + + def _assistant_toolbox_public_data(self) -> Dict[str, Any]: + workflows = [dict(item or {}) for item in (self._assistant_workflow_catalog().get("workflows") or []) if isinstance(item, dict)] + return { + "protocol_version": "assistant.v1", + "action": "toolbox", + "ok": True, + "version": self.plugin_version, + "defaults": { + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "hdhive_path": self._hdhive_default_path, + "p115_client_type": self._p115_client_type, + }, + "startup_order": [ + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_selfcheck", + "agent_resource_officer_request_templates", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + ], + "endpoints": { + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "request_templates": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "action": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + "route": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "pick": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + }, + "tools": { + "startup": "agent_resource_officer_startup", + "maintain": "agent_resource_officer_maintain", + "pulse": "agent_resource_officer_pulse", + "toolbox": "agent_resource_officer_toolbox", + "request_templates": "agent_resource_officer_request_templates", + "selfcheck": "agent_resource_officer_selfcheck", + "recover": "agent_resource_officer_recover", + "workflow": "agent_resource_officer_run_workflow", + "route": "agent_resource_officer_smart_entry", + "pick": "agent_resource_officer_smart_pick", + "execute_action": "agent_resource_officer_execute_action", + "execute_actions": "agent_resource_officer_execute_actions", + }, + "workflows": [ + { + "name": item.get("name"), + "fields": item.get("fields") or [], + } + for item in workflows + ], + "actions": [ + "start_pansou_search", + "start_smart_resource_search", + "start_smart_resource_decision", + "start_smart_resource_plan", + "start_smart_resource_execute", + "pick_pansou_result", + "start_hdhive_search", + "pick_hdhive_candidate", + "candidate_detail", + "candidate_next_page", + "pick_hdhive_resource", + "route_share", + "start_115_login", + "check_115_login", + "show_115_status", + "resume_pending_115", + "execute_latest_plan", + "execute_session_latest_plan", + "query_mp_download_tasks", + "query_mp_download_history", + "query_mp_lifecycle_status", + "query_mp_ingest_status", + "query_execution_followup", + "query_mp_media_detail", + "query_mp_search_result_detail", + "query_mp_best_result_detail", + "pick_mp_best_download", + "query_mp_downloaders", + "query_mp_sites", + "query_mp_subscribes", + "query_mp_transfer_history", + "query_mp_ingest_failures", + "query_mp_recent_activity", + "query_mp_local_diagnose", + "clear_stale_sessions", + "clear_executed_plans", + ], + "command_examples": [ + "盘搜搜索 大君夫人", + "智能搜索 蜘蛛侠", + "影巢搜索 蜘蛛侠", + "1大君夫人", + "2蜘蛛侠", + "链接 https://pan.quark.cn/s/xxxx path=/飞书", + "选择 1", + "详情", + "下一页", + "识别 蜘蛛侠", + "115登录", + ], + "request_templates": self._assistant_request_templates_public_data(limit=100), + "request_templates_schema_version": self.request_templates_schema_version, + } + + def _assistant_request_templates_public_data(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + return { + "startup_probe": { + "description": "读取启动聚合包,适合外部智能体开场获取状态、端点、工具和恢复建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "tool": "agent_resource_officer_startup", + "tool_args": {}, + "query": {}, + }, + "selfcheck_probe": { + "description": "执行协议自检,确认模板、compact、布尔解析和核心入口是否健康。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "tool": "agent_resource_officer_selfcheck", + "tool_args": {}, + "query": {}, + }, + "maintain_preview": { + "description": "预览低风险维护建议,不执行清理;适合高频探测。", + "side_effect": "dry_run", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": False, "limit": max_limit}, + "query": {"execute": True, "limit": max_limit}, + }, + "maintain_execute": { + "description": "执行低风险维护,清理过期会话和已执行计划;会写入 assistant/history。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": True, "limit": max_limit}, + "body": {"execute": True, "limit": max_limit}, + }, + "preferences_get": { + "description": "读取智能体片源偏好画像;未初始化时应先询问用户偏好。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": {"session": "assistant", "compact": True}, + "query": {"session": "assistant", "compact": True}, + }, + "preferences_save": { + "description": "保存智能体片源偏好画像;影响云盘与 PT 评分、自动化建议和安全阈值。", + "side_effect": "state", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + "body": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + }, + "scoring_policy": { + "description": "读取插件内置云盘/PT 评分策略、硬门槛和 score_summary 使用约定;只读,可缓存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "medium_lived", + "cache_ttl_seconds": 3600, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "tool": "agent_resource_officer_capabilities", + "tool_args": {"compact": True}, + "query": {"compact": True}, + "response_field": "scoring_policy", + }, + "workflow_dry_run": { + "description": "生成并保存工作流计划,不实际执行;适合先让用户确认。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "static_template", + "cache_ttl_seconds": 3600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": { + "name": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + "body": { + "workflow": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + }, + "mp_search": { + "description": "执行 MP 原生搜索,返回 PT 候选与 PT 评分摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "smart_search": { + "description": "按用户偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只返回推荐意见,不直接下载或转存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_decision": { + "description": "按用户偏好统一执行盘搜 -> 影巢 -> MP/PT 决策,并给出查看详情、生成计划或直接执行的首选建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_plan": { + "description": "按用户偏好自动搜索并为当前首选生成待确认 plan_id,不直接执行下载、解锁或转存。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_execute": { + "description": "按用户偏好自动搜索当前首选并立即执行写入动作;仅在用户已明确要求直接执行时使用。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_media_detail": { + "description": "使用 MoviePilot 原生识别确认片名、年份、类型和 TMDB/Douban/IMDB ID;适合搜索前消歧。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_search_detail": { + "description": "执行 MP 原生搜索并查看指定编号的 PT 详情和评分理由;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + }, + "mp_search_best": { + "description": "执行 MP 原生搜索并展示当前评分最高的 PT 候选详情;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "mp_best_download_plan": { + "description": "在已有 MP 搜索会话中按当前最高分候选生成下载计划;不会直接下载。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + "body": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + }, + "mp_search_download_plan": { + "description": "MP 原生搜索并选择编号下载;写入动作默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_tasks": { + "description": "查询 MP 下载任务状态,可按下载中、等待、已暂停等状态过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_download_control_plan": { + "description": "暂停、恢复或删除 MP 下载任务;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_history": { + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_lifecycle_status": { + "description": "一次查询 MP 下载任务、下载历史和整理/入库历史,适合追踪资源当前卡在哪一步。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_ingest_status": { + "description": "按片名或 hash 输出下载到入库的当前阶段,并附带结构化 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_downloaders": { + "description": "查询 MP 下载器配置摘要,不返回密码、Cookie 或 Token。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_downloaders", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_downloaders", "session": "assistant", "compact": True}, + }, + "mp_sites": { + "description": "查询 MP PT 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + }, + "mp_subscribe_plan": { + "description": "按关键词创建 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribe_search_plan": { + "description": "创建 MP 订阅并立即触发搜索;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribes": { + "description": "查询 MP 订阅列表,可按状态、类型和关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_subscribe_control_plan": { + "description": "搜索、暂停、恢复或删除 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_transfer_history": { + "description": "查询 MP 最近整理/入库历史,判断下载后是否已经落库;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_ingest_failures": { + "description": "聚合最近整理/入库失败记录,并返回失败态 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_failed_samples": { + "description": "读取 AI 识别增强插件保存的失败样本,可按关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_worklist": { + "description": "读取 AI 失败样本工作清单,适合先挑需要二次识别重放的样本;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_insights": { + "description": "读取 AI 失败样本洞察,查看主要失败原因、重复样本组和优先处理项。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + }, + "ai_replay_failed_sample_plan": { + "description": "对指定 AI 失败样本生成二次识别重放计划;确认后才会真正执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_recent_activity": { + "description": "查看最近下载和最近入库活动,适合先发现目标再进入单资源追踪。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_local_diagnose": { + "description": "面向“为什么没入库/卡在哪”的一站式只读诊断入口。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_recommend": { + "description": "读取 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_recommend_search": { + "description": "读取 MP 原生推荐并按编号继续搜索;mode 可选 smart_decision、smart_plan、smart_execute、mp、hdhive、pansou。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + }, + "smart_discovery": { + "description": "读取 MP 原生热门推荐,并优先引导到统一资源决策链。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "saved_plan_execute": { + "description": "执行已保存的 dry_run 工作流计划,可按 session 自动选择未执行计划。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "tool": "agent_resource_officer_execute_plan", + "tool_args": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + "body": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + }, + "execution_followup": { + "description": "按最近已执行计划自动追踪下载、订阅或入库后续状态,由插件决定先查哪个只读动作。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + }, + "smart_followup": { + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "action_execute": { + "description": "按动作名执行单个 action template,适合无映射继续执行。", + "side_effect": "depends_on_action", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + }, + "route_text": { + "description": "统一自然语言入口,适合 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体直接转发用户文本。", + "side_effect": "depends_on_text", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + "body": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + }, + "pick_continue": { + "description": "按编号继续当前会话,适合盘搜、影巢候选或资源列表选择。", + "side_effect": "depends_on_session", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + "tool": "agent_resource_officer_smart_pick", + "tool_args": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + "body": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + }, + } + + def _assistant_request_template_names(self, value: Any) -> List[str]: + if isinstance(value, (list, tuple, set)): + rows = value + else: + rows = re.split(r"[,,\s]+", self._clean_text(value)) + names: List[str] = [] + for item in rows: + name = self._clean_text(item) + if name and name not in names: + names.append(name) + return names + + def _assistant_request_templates_response_data( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> Dict[str, Any]: + all_templates = self._assistant_request_templates_public_data(limit=limit) + recipe_templates_map = { + "safe_bootstrap": ["startup_probe", "selfcheck_probe", "maintain_preview"], + "plan_then_confirm": ["workflow_dry_run", "saved_plan_execute"], + "post_execute_followup": ["smart_followup", "execution_followup", "mp_download_history", "mp_lifecycle_status", "mp_subscribes", "mp_transfer_history"], + "continue_existing_session": ["pick_continue"], + "maintenance_cycle": ["maintain_preview", "maintain_execute"], + "external_agent_quickstart": ["startup_probe", "route_text", "pick_continue"], + "preferences_onboarding": ["preferences_get", "scoring_policy", "preferences_save"], + "smart_search": ["smart_search", "preferences_get", "scoring_policy"], + "smart_decision": ["smart_decision", "preferences_get", "scoring_policy"], + "smart_search_plan": ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"], + "smart_search_execute": ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"], + "mp_pt_mainline": [ + "mp_media_detail", + "mp_search", + "mp_search_detail", + "mp_search_best", + "mp_search_download_plan", + "mp_best_download_plan", + "mp_download_tasks", + "mp_download_control_plan", + "mp_download_history", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_downloaders", + "mp_sites", + "mp_subscribe_plan", + "mp_subscribe_search_plan", + "mp_subscribes", + "mp_subscribe_control_plan", + "mp_transfer_history", + "mp_ingest_failures", + "mp_recent_activity", + "mp_local_diagnose", + "saved_plan_execute", + ], + "mp_recommendation": [ + "smart_discovery", + "mp_recommend", + "mp_recommend_search", + "mp_search", + "mp_search_best", + "mp_search_download_plan", + "saved_plan_execute", + ], + "ai_reingest_readonly": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + ], + "ai_reingest": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "ai_replay_failed_sample_plan", + "saved_plan_execute", + "execution_followup", + ], + "local_ingest": [ + "smart_followup", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_download_history", + "mp_transfer_history", + "mp_ingest_failures", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "mp_recent_activity", + "mp_local_diagnose", + ], + } + recipe_aliases = { + "bootstrap": "safe_bootstrap", + "safe": "safe_bootstrap", + "start": "safe_bootstrap", + "启动": "safe_bootstrap", + "plan": "plan_then_confirm", + "dry_run": "plan_then_confirm", + "confirm": "plan_then_confirm", + "计划": "plan_then_confirm", + "followup": "post_execute_followup", + "follow": "post_execute_followup", + "post_execute": "post_execute_followup", + "post-execute": "post_execute_followup", + "after_execute": "post_execute_followup", + "after-execute": "post_execute_followup", + "执行后": "post_execute_followup", + "执行后追踪": "post_execute_followup", + "后续追踪": "post_execute_followup", + "跟进": "post_execute_followup", + "进展": "post_execute_followup", + "continue": "continue_existing_session", + "pick": "continue_existing_session", + "resume": "continue_existing_session", + "继续": "continue_existing_session", + "选择": "continue_existing_session", + "maintain": "maintenance_cycle", + "maintenance": "maintenance_cycle", + "cleanup": "maintenance_cycle", + "维护": "maintenance_cycle", + "external_agent": "external_agent_quickstart", + "external-agent": "external_agent_quickstart", + "agent": "external_agent_quickstart", + "外部智能体": "external_agent_quickstart", + "微信智能体": "external_agent_quickstart", + "workbuddy": "external_agent_quickstart", + "work_buddy": "external_agent_quickstart", + "workbody": "external_agent_quickstart", + "work_body": "external_agent_quickstart", + "preferences": "preferences_onboarding", + "preference": "preferences_onboarding", + "prefs": "preferences_onboarding", + "pref": "preferences_onboarding", + "片源偏好": "preferences_onboarding", + "偏好画像": "preferences_onboarding", + "评分偏好": "preferences_onboarding", + "mp": "mp_pt_mainline", + "pt": "mp_pt_mainline", + "mp_native": "mp_pt_mainline", + "mp-native": "mp_pt_mainline", + "mp_pt": "mp_pt_mainline", + "mp-pt": "mp_pt_mainline", + "pt_mainline": "mp_pt_mainline", + "pt-mainline": "mp_pt_mainline", + "原生mp": "mp_pt_mainline", + "mp原生": "mp_pt_mainline", + "原生搜索": "mp_pt_mainline", + "pt下载": "mp_pt_mainline", + "下载订阅": "mp_pt_mainline", + "local_ingest": "local_ingest", + "local-ingest": "local_ingest", + "ingest": "local_ingest", + "local": "local_ingest", + "本地入库": "local_ingest", + "入库诊断": "local_ingest", + "ai_reingest": "ai_reingest", + "ai-reingest": "ai_reingest", + "失败样本": "ai_reingest_readonly", + "失败样本诊断": "ai_reingest_readonly", + "识别重放": "ai_reingest", + "recommend": "mp_recommendation", + "recommendation": "mp_recommendation", + "discover": "mp_recommendation", + "discovery": "mp_recommendation", + "智能发现": "mp_recommendation", + "热门发现": "mp_recommendation", + "mp_recommend": "mp_recommendation", + "mp-recommend": "mp_recommendation", + "推荐": "mp_recommendation", + "热门": "mp_recommendation", + "smart_search": "smart_search", + "smart-search": "smart_search", + "smart": "smart_search", + "智能搜索": "smart_search", + "smart_decision": "smart_decision", + "smart-decision": "smart_decision", + "decision": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart_search_plan": "smart_search_plan", + "smart-search-plan": "smart_search_plan", + "smartplan": "smart_search_plan", + "智能计划": "smart_search_plan", + "smart_search_execute": "smart_search_execute", + "smart-search-execute": "smart_search_execute", + "smartexecute": "smart_search_execute", + "智能执行": "smart_search_execute", + } + requested_recipe = self._clean_text(recipe) + selected_recipe = recipe_aliases.get(requested_recipe, requested_recipe) + invalid_recipe = requested_recipe if requested_recipe and selected_recipe not in recipe_templates_map else "" + selected_names = self._assistant_request_template_names(names) + if not selected_names and selected_recipe in recipe_templates_map: + selected_names = list(recipe_templates_map[selected_recipe]) + invalid_names = [name for name in selected_names if name not in all_templates] + templates = { + name: all_templates[name] + for name in selected_names + if name in all_templates + } if selected_names else all_templates + confirmation_required = [ + name + for name, item in templates.items() + if bool((item or {}).get("requires_confirmation")) + ] + safe_without_confirmation = [ + name + for name, item in templates.items() + if not bool((item or {}).get("requires_confirmation")) + ] + write_side_effects = [ + name + for name, item in templates.items() + if self._clean_text((item or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ] + cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) > 0 + ] + non_cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) <= 0 + ] + auth = { + "mode": "query_apikey", + "query_param": "apikey", + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}", + "description": "调用插件 HTTP 接口时推荐使用 ?apikey=你的MP_API_TOKEN;MP Tool 调用不需要此参数。", + } + external_agent_execution_policy_contract = { + "auto_continue": "直接执行 auto_run_command。", + "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", + "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", + "show_only": "只展示 display_command,不要自动继续。", + "stop": "当前没有适合自动继续的命令,不要继续执行。", + } + external_agent_execution_loop_contract = [ + { + "step": "startup", + "template": "startup_probe", + "when": "新会话开始时先读取启动聚合包。", + }, + { + "step": "decide", + "template": "startup_probe", + "when": "结合恢复信息和推荐 recipe,确定先继续会话还是开始新流程。", + }, + { + "step": "route", + "template": "route_text", + "when": "处理用户的自然语言资源指令。", + }, + { + "step": "policy", + "template": "", + "when": "读取 recommended_agent_behavior、auto_run_command、confirm_command 决定自动继续、确认或停止。", + }, + { + "step": "followup", + "template": "execution_followup", + "when": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ] + entry_patterns = { + "external_agent": { + "label": "外部智能体", + "client_role": "客户端调度层", + "start_with": "startup", + "decide_with": "decide --summary-only", + "route_with": "route --summary-only", + "followup_with": "followup --summary-only", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "notes": "适用于 WorkBuddy、Hermes、OpenClaw(小龙虾)等外部智能体;优先使用 Skill/helper。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体", + "client_role": "客户端调度层", + "start_with": "assistant/request_templates", + "decide_with": "agent_resource_officer_request_templates", + "route_with": "agent_resource_officer_smart_entry", + "followup_with": "agent_resource_officer_execution_followup", + "read_fields": [ + "recommended_recipe_detail", + "recommended_agent_behavior", + "preferred_command", + "compact_commands", + ], + "notes": "优先走 Agent Tool / request_templates,不在模型侧直拼底层资源 API。", + }, + "feishu_channel": { + "label": "飞书入口", + "client_role": "客户端消息入口", + "start_with": "feishu message -> route", + "decide_with": "插件内置命令解析", + "route_with": "route/pick/followup", + "followup_with": "followup / smart_followup", + "read_fields": [ + "recommended_agent_behavior", + "preferred_command", + "fallback_command", + "compact_commands", + ], + "notes": "飞书只负责把消息送进同一套 assistant 协议;确认策略与外部智能体保持一致。", + }, + } + orchestration_contract = { + "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", + "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", + "recommended_first_call": "startup", + "recommended_decision_call": "decide --summary-only", + "recommended_route_call": "route --summary-only", + "recommended_followup_call": "followup --summary-only", + "recommended_read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", + } + entry_playbooks = { + "external_agent": { + "label": "外部智能体最小执行流", + "transport": "skill_helper_or_http", + "steps": [ + { + "step": "startup", + "helper_command": "python3 scripts/aro_request.py startup", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "读取启动状态、恢复建议和推荐 recipe。", + "read_fields": ["recommended_request_templates", "recovery", "services"], + }, + { + "step": "decide", + "helper_command": "python3 scripts/aro_request.py decide --summary-only", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "决定继续会话、初始化偏好还是直接进入 route。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "confirm_command"], + }, + { + "step": "route", + "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/route?apikey={MP_API_TOKEN}", + }, + "purpose": "执行自然语言主入口。", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "preferred_command", + "compact_commands", + ], + }, + { + "step": "followup", + "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/action?apikey={MP_API_TOKEN}", + }, + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "followup_summary"], + }, + ], + "auto_rule": "优先读取 recommended_agent_behavior;只读步骤可自动续跑,写入步骤默认确认。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体最小执行流", + "transport": "moviepilot_agent_tool", + "steps": [ + { + "step": "request_templates", + "tool": "agent_resource_officer_request_templates", + "tool_args": {"recipe": "external_agent", "compact": True}, + "purpose": "读取最小流程、确认策略和推荐入口。", + "read_fields": ["orchestration_contract", "entry_patterns", "recommended_recipe_detail"], + }, + { + "step": "route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": {"text": "<用户原始指令>"}, + "purpose": "处理搜索、链接、登录状态等主入口。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "followup", + "tool": "agent_resource_officer_execution_followup", + "tool_args": {}, + "purpose": "执行计划后继续查看下载、入库和失败状态。", + "read_fields": ["followup_summary", "preferred_command", "compact_commands"], + }, + ], + "auto_rule": "依然遵守服务端 compact 协议;不要在模型侧拼底层资源站点 API。", + }, + "feishu_channel": { + "label": "飞书入口最小执行流", + "transport": "feishu_channel", + "steps": [ + { + "step": "message_in", + "channel": "feishu", + "purpose": "用户消息进入内置 Channel。", + "read_fields": ["session", "text", "reply_target"], + }, + { + "step": "route", + "internal": "route / pick / followup", + "purpose": "复用同一套 assistant 协议,不维护单独状态机。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "reply", + "channel": "feishu", + "purpose": "按确认策略回消息、展示编号或提示下一步。", + "read_fields": ["display_command", "confirm_command", "message"], + }, + ], + "auto_rule": "飞书只负责消息承载;写入动作依然由插件服务端统一确认。", + }, + } + recommended_sequence = [ + { + "step": "bootstrap", + "template": "startup_probe", + "when": "每次新会话开始时先读取启动聚合包。", + }, + { + "step": "healthcheck", + "template": "selfcheck_probe", + "when": "当外部智能体需要确认协议健康或怀疑环境变化时执行。", + }, + { + "step": "maintenance_preview", + "template": "maintain_preview", + "when": "长会话或多轮执行前先看是否有低风险维护建议。", + }, + { + "step": "plan", + "template": "workflow_dry_run", + "when": "需要先生成计划、等待用户确认或减少重复大 JSON 时使用。", + }, + { + "step": "execute_saved_plan", + "template": "saved_plan_execute", + "when": "已确认 dry_run 计划后执行。", + }, + { + "step": "post_execute_followup", + "template": "execution_followup", + "when": "计划执行成功后,自动决定先查下载历史、生命周期、订阅或入库历史。", + }, + { + "step": "continue_session", + "template": "pick_continue", + "when": "盘搜、影巢候选或资源列表需要按编号继续时使用。", + }, + { + "step": "preferences_onboarding", + "template": "preferences_get", + "when": "外部智能体首次接入时,先读取偏好和评分策略,再保存用户片源偏好。", + }, + { + "step": "mp_pt_mainline", + "template": "mp_search", + "when": "需要 MP 原生 PT 搜索、评分、下载计划、订阅或任务追踪时使用。", + }, + { + "step": "mp_recommendation", + "template": "mp_recommend", + "when": "需要 TMDB、豆瓣、Bangumi 等 MP 原生推荐,并继续搜索时使用。", + }, + ] + recipes = [ + { + "name": "safe_bootstrap", + "description": "新会话安全启动:先拿启动聚合包,再自检,再看维护建议。", + "templates": recipe_templates_map["safe_bootstrap"], + }, + { + "name": "plan_then_confirm", + "description": "先生成计划,等待用户确认后再执行保存计划。", + "templates": recipe_templates_map["plan_then_confirm"], + }, + { + "name": "post_execute_followup", + "description": "执行计划后的统一只读跟踪:由插件自动选择最合适的后续查询动作。", + "templates": recipe_templates_map["post_execute_followup"], + }, + { + "name": "continue_existing_session", + "description": "已有盘搜、影巢或资源会话时,直接按编号继续。", + "templates": recipe_templates_map["continue_existing_session"], + }, + { + "name": "maintenance_cycle", + "description": "先预览维护建议,再在确认后执行低风险维护。", + "templates": recipe_templates_map["maintenance_cycle"], + }, + { + "name": "external_agent_quickstart", + "description": "外部智能体接入:启动探测后,把用户文本交给统一入口,再按编号继续。", + "templates": recipe_templates_map["external_agent_quickstart"], + }, + { + "name": "preferences_onboarding", + "description": "首次接入时先读取偏好、评分策略,再保存用户的片源偏好画像。", + "templates": recipe_templates_map["preferences_onboarding"], + }, + { + "name": "smart_decision", + "description": "统一搜索并返回明确的下一步决策:查看详情、生成计划或直接执行。", + "templates": recipe_templates_map["smart_decision"], + }, + { + "name": "smart_search_plan", + "description": "统一搜索决策后,直接为当前首选生成待确认计划;仍需后续执行计划才会真正写入。", + "templates": recipe_templates_map["smart_search_plan"], + }, + { + "name": "smart_search_execute", + "description": "统一搜索决策后,直接执行当前首选写入动作;只适用于用户明确要求立即执行的场景。", + "templates": recipe_templates_map["smart_search_execute"], + }, + { + "name": "mp_pt_mainline", + "description": "MP 原生 PT 主线:识别、搜索、评分、下载计划、任务、订阅、站点和入库追踪。", + "templates": recipe_templates_map["mp_pt_mainline"], + }, + { + "name": "mp_recommendation", + "description": "MP 原生推荐主线:读取热门推荐,再按编号进入 MP、影巢或盘搜搜索。", + "templates": recipe_templates_map["mp_recommendation"], + }, + ] + recipe_summaries: List[Dict[str, Any]] = [] + for recipe in recipes: + template_names = [ + self._clean_text(item) + for item in (recipe.get("templates") or []) + if self._clean_text(item) + ] + current_templates = [ + templates[name] + for name in template_names + if isinstance(templates.get(name), dict) + ] + recipe_summaries.append({ + **recipe, + "requires_confirmation": any(bool(item.get("requires_confirmation")) for item in current_templates), + "has_write_effect": any( + self._clean_text(item.get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + for item in current_templates + ), + "cache_ttl_seconds": min( + [self._safe_int(item.get("cache_ttl_seconds"), 0) for item in current_templates] or [0] + ), + }) + recommended_recipe = "safe_bootstrap" + recommended_recipe_reason = "默认优先安全启动,先读取启动聚合包、自检并查看维护建议。" + selected_set = set(selected_names or []) + if selected_recipe in recipe_templates_map: + recommended_recipe = selected_recipe + recommended_recipe_reason = f"当前请求显式指定 recipe={selected_recipe}。" + elif {"workflow_dry_run", "saved_plan_execute"} & selected_set: + recommended_recipe = "plan_then_confirm" + recommended_recipe_reason = "当前模板集合包含 dry_run 或执行保存计划,更适合先计划后确认。" + elif "pick_continue" in selected_set: + recommended_recipe = "continue_existing_session" + recommended_recipe_reason = "当前模板集合包含 pick_continue,说明更像继续既有会话。" + elif {"preferences_get", "preferences_save", "scoring_policy"} & selected_set: + recommended_recipe = "preferences_onboarding" + recommended_recipe_reason = "当前模板集合包含偏好或评分策略模板,优先推荐偏好初始化流程。" + elif {"maintain_preview", "maintain_execute"} & selected_set: + recommended_recipe = "maintenance_cycle" + recommended_recipe_reason = "当前模板集合包含维护模板,优先推荐维护流程。" + recommended_recipe_detail = next( + (item for item in recipe_summaries if item.get("name") == recommended_recipe), + {}, + ) + recommended_recipe_templates = [ + name + for name in (recommended_recipe_detail.get("templates") or []) + if name in all_templates + ] + first_template = recommended_recipe_templates[0] if recommended_recipe_templates else "" + first_template_data = all_templates.get(first_template) or {} + recommended_recipe_calls = [] + for template_name in recommended_recipe_templates: + template_data = all_templates.get(template_name) or {} + if not template_data: + continue + recommended_recipe_calls.append({ + "template": template_name, + "auth": auth, + "method": template_data.get("method"), + "endpoint": template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(template_data.get("endpoint")), + ), + "query": template_data.get("query") or {}, + "body": template_data.get("body") or {}, + "tool": template_data.get("tool"), + "tool_args": template_data.get("tool_args") or {}, + "requires_confirmation": bool(template_data.get("requires_confirmation")), + "side_effect": template_data.get("side_effect"), + }) + recommended_recipe_detail = { + **recommended_recipe_detail, + "templates": recommended_recipe_templates, + "first_template": first_template, + "confirmation_required_templates": [ + name + for name in recommended_recipe_templates + if bool((all_templates.get(name) or {}).get("requires_confirmation")) + ], + "write_templates": [ + name + for name in recommended_recipe_templates + if self._clean_text((all_templates.get(name) or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ], + "first_call": { + "template": first_template, + "auth": auth, + "method": first_template_data.get("method"), + "endpoint": first_template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(first_template_data.get("endpoint")), + ), + "query": first_template_data.get("query") or {}, + "body": first_template_data.get("body") or {}, + "tool": first_template_data.get("tool"), + "tool_args": first_template_data.get("tool_args") or {}, + "requires_confirmation": bool(first_template_data.get("requires_confirmation")), + "side_effect": first_template_data.get("side_effect"), + } if first_template_data else {}, + "calls": recommended_recipe_calls, + } + if recommended_recipe == "external_agent_quickstart": + recommended_recipe_detail["execution_policy_contract"] = external_agent_execution_policy_contract + recommended_recipe_detail["execution_loop_contract"] = external_agent_execution_loop_contract + recommended_recipe_detail["entry_patterns"] = entry_patterns + recommended_recipe_detail["orchestration_contract"] = orchestration_contract + recommended_recipe_detail["entry_playbooks"] = entry_playbooks + confirmation_templates = recommended_recipe_detail.get("confirmation_required_templates") or [] + recommended_recipe_detail["first_confirmation_template"] = confirmation_templates[0] if confirmation_templates else "" + recommended_recipe_detail["confirmation_message"] = ( + f"执行 {recommended_recipe} 的 {recommended_recipe_detail['first_confirmation_template']} 前需要用户确认。" + if confirmation_templates + else f"{recommended_recipe} 当前推荐流程无需用户确认。" + ) + return { + "protocol_version": "assistant.v1", + "action": "request_templates", + "ok": True, + "compact": True, + "version": self.plugin_version, + "schema_version": self.request_templates_schema_version, + "auth": auth, + "templates_included": bool(include_templates), + "request_templates": templates if include_templates else {}, + "available_names": list(all_templates.keys()), + "available_recipes": list(recipe_templates_map.keys()), + "recipe_aliases": recipe_aliases, + "selected_names": selected_names, + "invalid_names": invalid_names, + "requested_recipe": requested_recipe, + "selected_recipe": selected_recipe if selected_recipe in recipe_templates_map else "", + "invalid_recipe": invalid_recipe, + "execution_policy": { + "safe_without_confirmation": safe_without_confirmation, + "confirmation_required": confirmation_required, + "write_side_effects": write_side_effects, + "cacheable_templates": cacheable_templates, + "non_cacheable_templates": non_cacheable_templates, + }, + "recommended_sequence": recommended_sequence, + "recipes": recipe_summaries, + "recommended_recipe": recommended_recipe, + "recommended_recipe_reason": recommended_recipe_reason, + "recommended_recipe_detail": recommended_recipe_detail, + "external_agent_execution_policy_contract": external_agent_execution_policy_contract, + "external_agent_execution_loop_contract": external_agent_execution_loop_contract, + "entry_patterns": entry_patterns, + "orchestration_contract": orchestration_contract, + "entry_playbooks": entry_playbooks, + } + + def _format_assistant_request_templates_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_request_templates_response_data() + templates = payload.get("request_templates") or {} + lines = [ + "Agent影视助手 请求模板", + f"版本:{payload.get('version')}", + ] + detail = payload.get("recommended_recipe_detail") or {} + first_call = detail.get("first_call") or {} + if payload.get("recommended_recipe"): + lines.append(f"推荐流程:{payload.get('recommended_recipe')}") + if detail.get("first_template"): + lines.append( + "首步:{template} -> {method} {endpoint}".format( + template=detail.get("first_template"), + method=first_call.get("method") or "", + endpoint=first_call.get("endpoint") or "", + ).strip() + ) + if detail.get("confirmation_message"): + lines.append(f"确认提示:{detail.get('confirmation_message')}") + if detail.get("execution_policy_contract"): + lines.append("外部智能体执行分支:" + " / ".join(str(key) for key in (detail.get("execution_policy_contract") or {}).keys())) + if detail.get("execution_loop_contract"): + lines.append( + "外部智能体最小循环:" + + " -> ".join(self._clean_text(item.get("step")) for item in (detail.get("execution_loop_contract") or []) if self._clean_text(item.get("step"))) + ) + orchestration_contract = payload.get("orchestration_contract") or detail.get("orchestration_contract") or {} + if orchestration_contract: + lines.append( + "最小执行流:{startup} -> {decide} -> {route} -> policy -> {followup}".format( + startup=self._clean_text(orchestration_contract.get("recommended_first_call")) or "startup", + decide=self._clean_text(orchestration_contract.get("recommended_decision_call")) or "decide --summary-only", + route=self._clean_text(orchestration_contract.get("recommended_route_call")) or "route --summary-only", + followup=self._clean_text(orchestration_contract.get("recommended_followup_call")) or "followup --summary-only", + ) + ) + read_fields = [ + self._clean_text(item) + for item in (orchestration_contract.get("recommended_read_fields") or []) + if self._clean_text(item) + ] + if read_fields: + lines.append("优先读取字段:" + " / ".join(read_fields)) + entry_patterns = payload.get("entry_patterns") or detail.get("entry_patterns") or {} + for key in ["external_agent", "mp_builtin_agent", "feishu_channel"]: + item = entry_patterns.get(key) or {} + if not item: + continue + lines.append( + "{label}:{start} -> {route}".format( + label=self._clean_text(item.get("label")) or key, + start=self._clean_text(item.get("start_with")) or "", + route=self._clean_text(item.get("route_with")) or "", + ) + ) + entry_playbooks = payload.get("entry_playbooks") or detail.get("entry_playbooks") or {} + external_playbook = (entry_playbooks.get("external_agent") or {}).get("steps") or [] + if external_playbook: + lines.append( + "外部智能体脚手架:" + + " -> ".join( + self._clean_text(item.get("step")) + for item in external_playbook + if self._clean_text(item.get("step")) + ) + ) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "pick_continue", + ]: + item = templates.get(name) or {} + if item: + tool = self._clean_text(item.get("tool")) + suffix = f" -> {tool}" if tool else "" + lines.append(f"{name}: {item.get('method')} {item.get('endpoint')}{suffix}") + return "\n".join(lines) + + def _format_assistant_toolbox_text(self) -> str: + data = self._assistant_toolbox_public_data() + workflows = data.get("workflows") or [] + lines = [ + "Agent影视助手 轻量工具清单", + f"版本:{data.get('version')}", + "推荐启动顺序:" + " -> ".join(str(item) for item in (data.get("startup_order") or [])[:5]), + "常用工作流:" + " / ".join(str(item.get("name")) for item in workflows if item.get("name")), + "默认目录:115={p115_path};夸克={quark_path};影巢={hdhive_path}".format(**(data.get("defaults") or {})), + ] + return "\n".join(lines) + + def _assistant_selfcheck_public_data(self) -> Dict[str, Any]: + action_template = self._assistant_action_template( + name="show_115_status", + description="自检模板:查看 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={"session": "selfcheck"}, + ) + route_template = self._assistant_action_template( + name="start_hdhive_search", + description="自检模板:发起影巢搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={"session": "selfcheck", "keyword": "蜘蛛侠", "media_type": "movie"}, + ) + bool_cases = { + "true": self._parse_bool_value("true", False), + "false": self._parse_bool_value("false", True), + "one": self._parse_bool_value("1", False), + "zero": self._parse_bool_value("0", True), + "off": self._parse_bool_value("off", True), + "default_true": self._parse_bool_value(None, True), + "default_false": self._parse_bool_value(None, False), + } + compact_templates_ok = all([ + (action_template.get("body") or {}).get("compact") is True, + (action_template.get("action_body") or {}).get("compact") is True, + (route_template.get("body") or {}).get("compact") is True, + (route_template.get("action_body") or {}).get("compact") is True, + ]) + bool_parse_ok = ( + bool_cases["true"] is True + and bool_cases["false"] is False + and bool_cases["one"] is True + and bool_cases["zero"] is False + and bool_cases["off"] is False + and bool_cases["default_true"] is True + and bool_cases["default_false"] is False + ) + pulse = self._assistant_pulse_public_data() + toolbox = self._assistant_toolbox_public_data() + startup_request_templates = self._assistant_recommended_request_templates_data() + startup_continue_request_templates = self._assistant_recommended_request_templates_data( + recipe="continue", + reason="selfcheck", + ) + maintain = self._assistant_maintain_public_data(execute=False) + request_templates = self._assistant_request_templates_public_data(limit=100) + filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute,missing_template", + ) + recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="startup_probe,maintain_preview,workflow_dry_run,saved_plan_execute,pick_continue,maintain_execute", + include_templates=False, + ) + recipe_filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="plan", + include_templates=False, + ) + external_recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="external_agent", + include_templates=False, + ) + policy_only_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute", + include_templates=False, + ) + maintenance_templates = maintain.get("action_templates") or [] + maintenance_template_names = { + self._clean_text(item.get("name")) + for item in maintenance_templates + if isinstance(item, dict) + } + maintenance_templates_compact_ok = all( + (item.get("body") or {}).get("compact") is True + and (item.get("action_body") or {}).get("compact") is True + for item in maintenance_templates + if isinstance(item, dict) + ) + maintain_dry_run_ok = ( + maintain.get("action") == "maintain" + and maintain.get("execute_requested") is False + and maintain.get("executed") is False + and {"clear_stale_sessions", "clear_executed_plans"}.issubset(maintenance_template_names) + ) + protocol_ok = ( + pulse.get("protocol_version") == "assistant.v1" + and toolbox.get("protocol_version") == "assistant.v1" + and pulse.get("action") == "pulse" + and toolbox.get("action") == "toolbox" + and maintain.get("protocol_version") == "assistant.v1" + ) + startup_request_templates_ok = ( + startup_request_templates.get("recipe") == "bootstrap" + and startup_request_templates.get("include_templates") is False + and startup_request_templates.get("tool") == "agent_resource_officer_request_templates" + and "{MP_API_TOKEN}" in self._clean_text(startup_request_templates.get("url_template")) + and startup_continue_request_templates.get("recipe") == "continue" + and ((startup_continue_request_templates.get("tool_args") or {}).get("recipe")) == "continue" + and bool(self._clean_text(startup_continue_request_templates.get("reason"))) + ) + request_templates_ok = all( + isinstance(request_templates.get(name), dict) + and self._clean_text((request_templates.get(name) or {}).get("endpoint")) + and self._clean_text((request_templates.get(name) or {}).get("method")) + and self._clean_text((request_templates.get(name) or {}).get("tool")) + and self._clean_text((request_templates.get(name) or {}).get("description")) + and self._clean_text((request_templates.get(name) or {}).get("side_effect")) + and isinstance((request_templates.get(name) or {}).get("requires_confirmation"), bool) + and self._clean_text((request_templates.get(name) or {}).get("cache_scope")) + and isinstance((request_templates.get(name) or {}).get("cache_ttl_seconds"), int) + and isinstance((request_templates.get(name) or {}).get("tool_args"), dict) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "route_text", + "pick_continue", + ] + ) + request_templates_filter_ok = ( + list((filtered_request_templates.get("request_templates") or {}).keys()) == ["maintain_execute"] + and filtered_request_templates.get("selected_names") == ["maintain_execute", "missing_template"] + and filtered_request_templates.get("invalid_names") == ["missing_template"] + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("body") or {}).get("limit") == 5 + ) + request_templates_policy_ok = ( + "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("write_side_effects") or []) + ) + request_templates_schema_ok = filtered_request_templates.get("schema_version") == self.request_templates_schema_version + request_templates_cache_ok = ( + ((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_scope") == "no_cache" + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_ttl_seconds")) == 0 + ) + request_templates_sequence_ok = ( + isinstance(filtered_request_templates.get("recommended_sequence"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "startup_probe" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "saved_plan_execute" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + ) + request_templates_recipes_ok = ( + isinstance(recipe_request_templates.get("recipes"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recipe_summary_ok = ( + any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "safe_bootstrap" + and item.get("requires_confirmation") is False + and item.get("has_write_effect") is False + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "plan_then_confirm" + and item.get("requires_confirmation") is True + and item.get("has_write_effect") is True + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recommended_recipe_ok = ( + filtered_request_templates.get("recommended_recipe") == "maintenance_cycle" + and bool(self._clean_text(filtered_request_templates.get("recommended_recipe_reason"))) + and recipe_request_templates.get("recommended_recipe") == "plan_then_confirm" + ) + request_templates_recipe_filter_ok = ( + recipe_filtered_request_templates.get("requested_recipe") == "plan" + and recipe_filtered_request_templates.get("selected_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("selected_names") == ["workflow_dry_run", "saved_plan_execute"] + and recipe_filtered_request_templates.get("recommended_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("templates_included") is False + and "plan_then_confirm" in (recipe_filtered_request_templates.get("available_recipes") or []) + and ((recipe_filtered_request_templates.get("recipe_aliases") or {}).get("plan")) == "plan_then_confirm" + ) + recommended_recipe_detail = filtered_request_templates.get("recommended_recipe_detail") or {} + request_templates_recommended_recipe_detail_ok = ( + recommended_recipe_detail.get("name") == "maintenance_cycle" + and recommended_recipe_detail.get("first_template") == "maintain_preview" + and "maintain_execute" in (recommended_recipe_detail.get("confirmation_required_templates") or []) + and "maintain_execute" in (recommended_recipe_detail.get("write_templates") or []) + and recommended_recipe_detail.get("first_confirmation_template") == "maintain_execute" + and "maintain_execute" in self._clean_text(recommended_recipe_detail.get("confirmation_message")) + and ((recommended_recipe_detail.get("first_call") or {}).get("template")) == "maintain_preview" + and (((recommended_recipe_detail.get("first_call") or {}).get("auth") or {}).get("mode")) == "query_apikey" + and self._clean_text((recommended_recipe_detail.get("first_call") or {}).get("url_template")).endswith( + "/assistant/maintain?apikey={MP_API_TOKEN}" + ) + and ((recommended_recipe_detail.get("first_call") or {}).get("method")) == "GET" + and ((recommended_recipe_detail.get("first_call") or {}).get("tool")) == "agent_resource_officer_maintain" + and [ + (item or {}).get("template") + for item in (recommended_recipe_detail.get("calls") or []) + ] == ["maintain_preview", "maintain_execute"] + and all( + (((item or {}).get("auth") or {}).get("query_param")) == "apikey" + for item in (recommended_recipe_detail.get("calls") or []) + ) + and all( + "{MP_API_TOKEN}" in self._clean_text((item or {}).get("url_template")) + for item in (recommended_recipe_detail.get("calls") or []) + ) + ) + request_templates_policy_only_ok = ( + policy_only_request_templates.get("templates_included") is False + and (policy_only_request_templates.get("request_templates") or {}) == {} + and policy_only_request_templates.get("selected_names") == ["maintain_execute"] + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("non_cacheable_templates") or []) + ) + external_recipe_detail = external_recipe_request_templates.get("recommended_recipe_detail") or {} + request_templates_external_agent_contract_ok = ( + external_recipe_request_templates.get("selected_recipe") == "external_agent_quickstart" + and external_recipe_request_templates.get("recommended_recipe") == "external_agent_quickstart" + and bool((external_recipe_request_templates.get("external_agent_execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_request_templates.get("external_agent_execution_loop_contract") or []) >= 5 + and ((external_recipe_request_templates.get("orchestration_contract") or {}).get("recommended_first_call")) == "startup" + and bool(((external_recipe_request_templates.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with")) + and len((((external_recipe_request_templates.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4 + and bool((external_recipe_detail.get("execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_detail.get("execution_loop_contract") or []) >= 5 + and ((external_recipe_detail.get("orchestration_contract") or {}).get("recommended_route_call")) == "route --summary-only" + and bool(((external_recipe_detail.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with")) + and bool(((((external_recipe_detail.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool")) + and any( + self._clean_text(item.get("step")) == "policy" + for item in (external_recipe_detail.get("execution_loop_contract") or []) + if isinstance(item, dict) + ) + ) + start_new_recovery = self._assistant_recovery_public_data( + session_state={"has_session": False}, + action_templates=[{"name": "start_pansou_search", "tool": "agent_resource_officer_smart_entry"}], + ) + start_new_recovery_ok = ( + start_new_recovery.get("mode") == "start_new" + and start_new_recovery.get("can_resume") is False + and start_new_recovery.get("recommended_action") == "start_pansou_search" + ) + executed_plan_recovery = self._assistant_recovery_public_data( + session_state={ + "has_session": True, + "session": "selfcheck", + "session_id": "assistant::selfcheck", + "saved_plan": { + "has_pending": False, + "has_plan": True, + "latest": { + "plan_id": "plan-selfcheck-executed", + "executed": True, + }, + }, + }, + ) + executed_plan_recovery_ok = ( + executed_plan_recovery.get("mode") == "followup_executed_plan" + and executed_plan_recovery.get("can_resume") is True + and executed_plan_recovery.get("recommended_action") == "query_execution_followup" + ) + execute_plan_followup_samples = { + workflow: self._assistant_plan_execute_followup( + workflow=workflow, + session="selfcheck", + session_id="selfcheck-session", + session_state={ + "keyword": keyword, + "session": "selfcheck", + "session_id": "selfcheck-session", + }, + ok=True, + plan_id=f"plan-selfcheck-{workflow}", + ) + for workflow, keyword in [ + ("mp_best_download", "蜘蛛侠"), + ("mp_subscribe", "钢铁侠"), + ("hdhive_unlock_selected", "复仇者联盟"), + ("ai_replay_failed_sample", "地狱乐"), + ] + } + execute_plan_followups_ok = ( + [item.get("name") for item in (execute_plan_followup_samples.get("mp_best_download") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_ingest_status", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_best_download") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_best_download") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("mp_subscribe") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + and (execute_plan_followup_samples.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_subscribe") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_subscribe") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("action_templates") or []] + == ["query_mp_local_diagnose", "query_mp_ingest_status", "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + and (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("recommended_action") == "query_mp_local_diagnose" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("preferred_command") == "诊断" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("fallback_command") == "入库状态" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("recommended_agent_behavior") == "auto_continue" + ) + ai_replay_execution_summary_resolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=True, + has_title=True, + ) + ai_replay_execution_summary_unresolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_failed = self._assistant_ai_replay_execution_decision_summary( + ok=False, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_ok = ( + ai_replay_execution_summary_resolved.get("preferred_command") == "诊断" + and ai_replay_execution_summary_resolved.get("recommended_agent_behavior") == "auto_continue" + and ai_replay_execution_summary_unresolved.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_unresolved.get("fallback_command") == "样本洞察" + and ai_replay_execution_summary_failed.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_failed.get("recommended_agent_behavior") == "show_only" + ) + checks = { + "compact_templates": compact_templates_ok, + "bool_parser": bool_parse_ok, + "protocol": protocol_ok, + "maintain_dry_run": maintain_dry_run_ok, + "maintenance_templates_compact": maintenance_templates_compact_ok, + "request_templates": request_templates_ok, + "request_templates_filter": request_templates_filter_ok, + "request_templates_policy": request_templates_policy_ok, + "request_templates_schema": request_templates_schema_ok, + "request_templates_cache": request_templates_cache_ok, + "request_templates_sequence": request_templates_sequence_ok, + "request_templates_recipes": request_templates_recipes_ok, + "request_templates_recipe_summary": request_templates_recipe_summary_ok, + "request_templates_recommended_recipe": request_templates_recommended_recipe_ok, + "request_templates_recipe_filter": request_templates_recipe_filter_ok, + "request_templates_recommended_recipe_detail": request_templates_recommended_recipe_detail_ok, + "request_templates_policy_only": request_templates_policy_only_ok, + "request_templates_external_agent_contract": request_templates_external_agent_contract_ok, + "startup_request_templates": startup_request_templates_ok, + "start_new_recovery_not_resumable": start_new_recovery_ok, + "executed_plan_recovery": executed_plan_recovery_ok, + "execute_plan_followups": execute_plan_followups_ok, + "ai_replay_execution_summary": ai_replay_execution_summary_ok, + "toolbox_startup_endpoint": bool((toolbox.get("endpoints") or {}).get("startup")), + "toolbox_maintain_endpoint": bool((toolbox.get("endpoints") or {}).get("maintain")), + "toolbox_request_templates_endpoint": bool((toolbox.get("endpoints") or {}).get("request_templates")), + "toolbox_maintain_tool": bool((toolbox.get("tools") or {}).get("maintain")), + "toolbox_request_templates_tool": bool((toolbox.get("tools") or {}).get("request_templates")), + "toolbox_selfcheck_endpoint": bool((toolbox.get("endpoints") or {}).get("selfcheck")), + } + ok = all(bool(value) for value in checks.values()) + return { + "protocol_version": "assistant.v1", + "action": "selfcheck", + "ok": ok, + "compact": True, + "version": self.plugin_version, + "checks": checks, + "bool_cases": bool_cases, + "template_samples": { + "action": { + "name": action_template.get("name"), + "body_compact": (action_template.get("body") or {}).get("compact"), + "action_body_compact": (action_template.get("action_body") or {}).get("compact"), + }, + "route": { + "name": route_template.get("name"), + "body_compact": (route_template.get("body") or {}).get("compact"), + "action_body_compact": (route_template.get("action_body") or {}).get("compact"), + }, + "request_templates": { + name: { + "method": (request_templates.get(name) or {}).get("method"), + "endpoint": (request_templates.get(name) or {}).get("endpoint"), + } + for name in ["maintain_execute", "workflow_dry_run", "saved_plan_execute"] + }, + "execute_plan_followups": { + workflow: { + "next_actions": (sample or {}).get("next_actions") or [], + "recommended_action": self._clean_text((sample or {}).get("recommended_action")), + "follow_up_hint": self._clean_text((sample or {}).get("follow_up_hint")), + "template_names": [ + self._clean_text(item.get("name")) + for item in ((sample or {}).get("action_templates") or []) + if isinstance(item, dict) and self._clean_text(item.get("name")) + ], + } + for workflow, sample in execute_plan_followup_samples.items() + }, + }, + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_pulse", "assistant_toolbox", "assistant_readiness"], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "readiness": "/api/v1/plugin/AgentResourceOfficer/assistant/readiness?compact=true", + }, + } + + def _format_assistant_selfcheck_text(self) -> str: + data = self._assistant_selfcheck_public_data() + checks = data.get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 协议自检", + f"版本:{data.get('version')}", + f"结果:{'通过' if data.get('ok') else '失败'}", + ] + if failed: + lines.append("失败项:" + " / ".join(failed)) + return "\n".join(lines) + + def _assistant_response_data( + self, + *, + session: str, + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload = dict(data or {}) + session_name = self._clean_text(session) or "default" + session_state = self._assistant_session_public_data(session=session_name) + payload["action"] = self._clean_text(payload.get("action")) or "assistant_response" + payload["ok"] = bool(payload.get("ok", True)) + payload["write_effect"] = payload.get("write_effect") or self._assistant_write_effect_for_action(payload["action"]) + payload["error_code"] = self._clean_text(payload.get("error_code")) or ("" if payload["ok"] else "assistant_error") + payload["protocol_version"] = "assistant.v1" + payload["session"] = session_name + payload["session_id"] = session_state.get("session_id") or self._assistant_session_id(session_name) + payload["session_state"] = session_state + payload["preference_status"] = payload.get("preference_status") or self._assistant_preferences_status_brief(session=session_name) + payload["next_actions"] = payload.get("next_actions") or session_state.get("suggested_actions") or [] + if payload["preference_status"].get("needs_onboarding") and "preferences.init" not in payload["next_actions"]: + payload["next_actions"] = ["preferences.init", *list(payload["next_actions"] or [])] + payload["action_templates"] = payload.get("action_templates") or session_state.get("action_templates") or [] + payload["recovery"] = payload.get("recovery") or session_state.get("recovery") or self._assistant_recovery_public_data(session_state=session_state) + if payload.get("action") == "mp_recommendations" or self._clean_text(session_state.get("kind")) == "assistant_mp_recommend": + payload["source"] = self._clean_text(payload.get("source") or session_state.get("source")) + payload["requested_source"] = self._clean_text(payload.get("requested_source") or session_state.get("requested_source")) + payload["fallback_source"] = self._clean_text(payload.get("fallback_source") or session_state.get("fallback_source")) + payload["media_type"] = self._clean_text(payload.get("media_type") or session_state.get("media_type")) + recommend_handoff = session_state.get("recommend_handoff") if isinstance(session_state.get("recommend_handoff"), dict) else {} + if recommend_handoff: + payload["recommend_handoff"] = recommend_handoff + payload["return_short_command"] = self._clean_text(payload.get("return_short_command") or recommend_handoff.get("return_short_command") or "回推荐") + return payload + + @staticmethod + def _assistant_write_effect_for_action(action: str) -> str: + action_name = str(action or "").strip() + if action_name in { + "share_route", + "hdhive_unlock", + "transfer_115", + "quark_transfer", + "quark_clear_default_dir", + "p115_clear_default_dir", + "p115_resume", + "p115_cancel", + "p115_qrcode_start", + "p115_qrcode_check", + "hdhive_checkin", + "mp_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_search", + "pick_mp_download", + "start_mp_subscribe", + "start_mp_subscribe_search", + "ai_replay_failed_sample", + "preferences_save", + "preferences_reset", + "execute_actions", + "execute_plan", + "maintain", + }: + return "write" + if action_name in {"workflow_plan", "plans_clear", "session_clear", "sessions_clear"}: + return "state" + return "read" + + def _merge_assistant_structured_input(self, body: Dict[str, Any], parsed: Dict[str, str]) -> Dict[str, str]: + merged = dict(parsed or {}) + body = dict(body or {}) + + mode = self._clean_text(body.get("mode")) + if mode in {"mp", "mp_download_title", "pansou", "hdhive", "smart", "smart_decision", "smart_plan", "smart_execute", "cloud_transfer_execute"}: + merged["mode"] = mode + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if keyword: + merged["keyword"] = keyword + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if share_url: + merged["url"] = share_url + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + if access_code: + merged["access_code"] = access_code + path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + if path: + merged["path"] = path + media_type = self._clean_text(body.get("media_type") or body.get("type")).lower() + if media_type: + merged["type"] = media_type + year = self._clean_text(body.get("year")) + if year: + merged["year"] = year + cloud_provider = self._clean_text(body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider in {"115", "quark"}: + merged["cloud_provider"] = cloud_provider + client_type = self._clean_text(body.get("client_type") or body.get("client")) + if client_type: + merged["client_type"] = P115TransferService.normalize_qrcode_client_type(client_type) + if "is_gambler" in body: + merged["is_gambler"] = "true" if self._parse_bool_value(body.get("is_gambler"), False) else "false" + action = self._clean_text(body.get("action")) + if action: + merged["action"] = action + result_filter = self._clean_text(body.get("result_filter") or body.get("filter")).lower() + if result_filter in {"latest_episode", "latest", "latest_episodes"}: + merged["result_filter"] = "latest_episode" + plan_id = self._clean_text(body.get("plan_id") or body.get("plan")) + if plan_id: + merged["plan_id"] = plan_id + download_control = self._clean_text(body.get("download_control") or body.get("control") or body.get("operation")) + if download_control: + merged["download_control"] = download_control + subscribe_control = self._clean_text(body.get("subscribe_control") or body.get("control") or body.get("operation")) + if subscribe_control: + merged["subscribe_control"] = subscribe_control + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index > 0: + merged["sample_index"] = str(sample_index) + if "remove_if_resolved" in body: + merged["remove_if_resolved"] = "true" if self._parse_bool_value(body.get("remove_if_resolved"), True) else "false" + return merged + + @staticmethod + def _parse_assistant_text(text: str) -> Dict[str, str]: + raw = str(text or "").strip() + compact = re.sub(r"\s+", "", raw).lower() + share_url = AgentResourceOfficer._extract_first_url(raw) + remain = raw.replace(share_url, " ").strip() if share_url else raw + mode, query = AgentResourceOfficer._normalize_search_prefix(remain) + plan_match = re.search(r"\bplan-[a-zA-Z0-9]+\b", raw) + options: Dict[str, str] = { + "text": raw, + "url": share_url, + "access_code": "", + "path": "", + "mode": mode, + "keyword": query or remain, + "result_filter": "", + "source_order_text": "", + "cloud_provider": "", + "type": "", + "year": "", + "action": "", + "client_type": "", + "status": "", + "hash": "", + "plan_id": plan_match.group(0) if plan_match else "", + "decision_intent": "", + "sample_index": "", + "remove_if_resolved": "true", + } + if options.get("mode") in {"smart", "smart_decision"} and options.get("keyword"): + cleaned_keyword, decision_intent = AgentResourceOfficer._extract_smart_decision_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if decision_intent: + options["decision_intent"] = decision_intent + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + if raw.startswith("云盘搜索") or raw.startswith("云盘搜"): + options["source_order_text"] = "pansou,hdhive" + transfer_provider_prefixes = [ + ("夸克转存资源", "quark"), + ("夸克转存", "quark"), + ("115转存资源", "115"), + ("115转存", "115"), + ] + for prefix, provider in transfer_provider_prefixes: + if raw == prefix: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = "" + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + replay_match = re.match(r"^\s*(重放样本|重识别样本|重跑样本)\s*(\d+)?(?:\s+(.*))?$", raw) + if replay_match: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if replay_match.group(2): + options["sample_index"] = replay_match.group(2) + remain_text = AgentResourceOfficer._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if options.get("plan_id") and compact.startswith(("执行plan-", "确认plan-", "executeplan-")): + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif options.get("plan_id") and compact.startswith(("取消plan-", "清理plan-", "删除plan-", "clearplan-", "cancelplan-")): + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "帮助", + "使用帮助", + "命令帮助", + "help", + "agenthelp", + "arohelp", + "插件帮助", + }: + options["action"] = "assistant_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "执行计划", + "执行最新计划", + "确认计划", + "确认执行计划", + "执行plan", + "执行最新plan", + "executeplan", + "executelatestplan", + }: + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "计划列表", + "查看计划", + "待执行计划", + "保存计划", + "plans", + "listplans", + }: + options["action"] = "plans_list" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "偏好", + "片源偏好", + "查看偏好", + "偏好设置", + "智能体偏好", + "preferences", + "getpreferences", + }: + options["action"] = "preferences_get" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重置偏好", + "清除偏好", + "重设偏好", + "恢复默认偏好", + "resetpreferences", + }: + options["action"] = "preferences_reset" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "评分策略", + "评分规则", + "自动化规则", + "scoringpolicy", + "scoringrules", + }: + options["action"] = "scoring_policy" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消计划", + "清理计划", + "删除计划", + "cancelplan", + "clearplan", + "deleteplan", + }: + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录", + "115扫码", + "扫码115", + "登录115", + "115login", + "115qrcode", + "p115login", + "p115qrcode", + }: + options["action"] = "p115_qrcode_start" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "检查115登录", + "检查115扫码", + "检查扫码", + "115check", + "check115login", + "p115check", + }: + options["action"] = "p115_qrcode_check" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录状态", + "115状态", + "查看115状态", + "115健康", + "115status", + "p115status", + }: + options["action"] = "p115_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115帮助", + "115命令", + "115使用", + "115help", + "p115help", + }: + options["action"] = "p115_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115任务", + "待处理115", + "待继续115", + "115pending", + "p115pending", + }: + options["action"] = "p115_pending" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空夸克默认目录", + "清空夸克默认转存目录", + "清空夸克转存目录", + "清空夸克目录", + }: + options["action"] = "quark_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空115默认目录", + "清空115默认转存目录", + "清空115转存目录", + "清空115目录", + "清空115网盘转存目录", + "清空115网盘目录", + }: + options["action"] = "p115_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续115任务", + "重试115任务", + "继续115转存", + "重试115转存", + "continue115", + "resume115", + }: + options["action"] = "p115_resume" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消115任务", + "取消115转存", + "清除115任务", + "cancel115", + "clear115", + }: + options["action"] = "p115_cancel" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到", + "签到", + "hdhivecheckin", + "hdhivesign", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到日志", + "签到日志", + "影巢日志", + "hdhivecheckinhistory", + "hdhivesignhistory", + }: + options["action"] = "hdhive_checkin_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢普通签到", + "普通签到", + "普通", + "hdhivenormalcheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "false" + elif compact in { + "影巢赌狗签到", + "赌狗签到", + "赌狗", + "hdhivegamblercheckin", + "gamblercheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "true" + elif compact in { + "资源决策", + "智能决策", + "decision", + "smartdecision", + }: + options["mode"] = "smart_decision" + options["keyword"] = "" + elif compact in { + "后续", + "执行后追踪", + "继续追踪", + "followup", + "postexecute", + }: + options["action"] = "execution_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "跟进", + "继续跟进", + "查看进展", + "进展", + "smartfollowup", + }: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续决策", + "继续资源决策", + "decisioncontinue", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_continue" + elif compact in { + "换影巢", + "切换影巢", + "走影巢", + "用影巢", + "decisionhdhive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_hdhive" + elif compact in { + "换盘搜", + "切换盘搜", + "走盘搜", + "用盘搜", + "decisionpansou", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_pansou" + elif compact in { + "换pt", + "换原生", + "换mp", + "切换pt", + "切换原生", + "切换mp", + "decisionmppt", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_mp_pt" + elif compact in { + "保守一点", + "更保守", + "保守模式", + "decisionconservative", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_conservative" + elif compact in { + "激进一点", + "更激进", + "激进模式", + "decisionaggressive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_aggressive" + elif compact in { + "下载任务", + "下载状态", + "正在下载", + "下载列表", + "查看下载", + "下载进度", + "downloadtasks", + "downloadstatus", + }: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载最佳", + "下载推荐", + "下载最好", + "下载最佳片源", + "下载推荐片源", + "downloadbest", + }: + options["action"] = "mp_download_best" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载历史", + "下载记录", + "记录", + "历史下载", + "downloadhistory", + }: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近下载", + "recentdownloads", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["download_only"] = "true" + elif compact in { + "最近", + "最近动态", + "动态", + "recentactivity", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "追踪", + "资源追踪", + "下载追踪", + "媒体状态", + "落库状态", + "状态", + "进度", + "lifecyclestatus", + }: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库状态", + "本地入库", + "入库", + "ingeststatus", + }: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "识别", + "媒体识别", + "媒体详情", + "mp识别", + "mp媒体识别", + "mpmediadetail", + }: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载器", + "下载器状态", + "下载器列表", + "查看下载器", + "downloaders", + }: + options["action"] = "mp_downloaders" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "站点", + "站点状态", + "站点列表", + "pt站点", + "pt站点状态", + "sites", + }: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "订阅列表", + "订阅状态", + "查看订阅", + "mp订阅", + "subscribes", + }: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库历史", + "整理历史", + "转移历史", + "入库记录", + "最近整理", + "transferhistory", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近入库", + "recentingest", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["transfer_only"] = "true" + elif compact in { + "入库失败", + "整理失败", + "失败入库", + "失败整理", + "transferfailed", + }: + options["action"] = "mp_ingest_failures" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "failed" + elif compact in { + "入库成功", + "整理成功", + "成功入库", + "成功整理", + "transfersuccess", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "success" + elif compact in { + "本地诊断", + "诊断", + "失败原因", + "localdiagnose", + }: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "失败样本", + "识别样本", + "ai失败样本", + "aifailedsamples", + }: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "工作清单", + "识别工作清单", + "样本清单", + "sampleworklist", + }: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "样本洞察", + "识别洞察", + "失败洞察", + "sampleinsights", + }: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重放样本", + "重识别样本", + "重跑样本", + "aireplay", + "replaysample", + }: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + else: + for prefix, action in [ + ("执行计划", "execute_plan"), + ("执行", "execute_plan"), + ("确认计划", "execute_plan"), + ("确认", "execute_plan"), + ("查看计划", "plans_list"), + ("计划列表", "plans_list"), + ("取消计划", "plans_clear"), + ("清理计划", "plans_clear"), + ("删除计划", "plans_clear"), + ("保存偏好", "preferences_save"), + ("设置偏好", "preferences_save"), + ("更新偏好", "preferences_save"), + ("偏好设置", "preferences_save"), + ("偏好", "preferences_save"), + ("查看偏好", "preferences_get"), + ("片源偏好", "preferences_get"), + ("重置偏好", "preferences_reset"), + ("清除偏好", "preferences_reset"), + ("评分策略", "scoring_policy"), + ("评分规则", "scoring_policy"), + ("自动化规则", "scoring_policy"), + ]: + if raw.startswith(prefix + " ") or raw.startswith(prefix + ":") or raw.startswith(prefix + ":"): + remain_text = raw[len(prefix):].lstrip(" ::").strip() + if action in {"plans_list", "preferences_get", "preferences_reset"} or options.get("plan_id") or remain_text: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + if remain_text and not options.get("plan_id"): + match = re.search(r"\bplan-[a-zA-Z0-9]+\b", remain_text) + if match: + options["plan_id"] = match.group(0) + break + for prefix, control in [ + ("暂停下载", "pause"), + ("停止下载", "pause"), + ("恢复下载", "resume"), + ("继续下载", "resume"), + ("开始下载", "resume"), + ("删除下载", "delete"), + ("移除下载", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_download_control" + options["mode"] = "" + options["keyword"] = target_text + options["download_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载任务", "下载状态", "下载列表", "查看下载", "下载进度"]) + if prefix_match: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源决策", "智能决策"]) + if prefix_match: + options["mode"] = "smart_decision" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["继续跟进", "查看进展", "跟进", "进展"]) + if prefix_match: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载历史", "下载记录", "记录", "最近下载", "历史下载"]) + if prefix_match: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "追踪"]) + if prefix_match: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["入库状态", "本地入库"]) + if prefix_match: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["MP识别", "mp识别", "媒体识别", "媒体详情", "详情媒体", "识别"]) + if prefix_match: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["站点状态", "站点列表", "PT站点", "pt站点", "站点"]) + if prefix_match: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, control in [ + ("搜索订阅", "search"), + ("刷新订阅", "search"), + ("暂停订阅", "pause"), + ("恢复订阅", "resume"), + ("删除订阅", "delete"), + ("移除订阅", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_subscribe_control" + options["mode"] = "" + options["keyword"] = target_text + options["subscribe_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["订阅列表", "订阅状态", "查看订阅", "MP订阅", "mp订阅"]) + if prefix_match: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, status_name in [ + ("入库失败", "failed"), + ("整理失败", "failed"), + ("失败入库", "failed"), + ("失败整理", "failed"), + ("入库成功", "success"), + ("整理成功", "success"), + ("成功入库", "success"), + ("成功整理", "success"), + ("入库历史", "all"), + ("入库记录", "all"), + ("整理历史", "all"), + ("转移历史", "all"), + ("最近入库", "all"), + ("最近整理", "all"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + options["action"] = "mp_ingest_failures" if status_name == "failed" else "mp_transfer_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + options["status"] = status_name + break + if not options.get("action") and raw.startswith("入库"): + blocked_prefixes = ("入库失败", "入库成功", "入库历史", "入库记录", "入库状态") + if not any(raw.startswith(prefix) for prefix in blocked_prefixes): + remain_text = raw[len("入库"):].lstrip(" ::").strip() + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = remain_text + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["为什么没入库", "本地诊断", "诊断", "失败原因"]) + if prefix_match: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["失败样本", "识别样本", "AI失败样本"]) + if prefix_match: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["工作清单", "识别工作清单", "样本清单"]) + if prefix_match: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["样本洞察", "识别洞察", "失败洞察"]) + if prefix_match: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["重放样本", "重识别样本", "重跑样本"]) + if prefix_match: + remain_text = prefix_match[1] + match = re.search(r"\d+", remain_text) + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if match: + options["sample_index"] = match.group(0) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if not options.get("action"): + for prefix, action in [ + ("转存资源", "cloud_transfer"), + ("转存", "cloud_transfer"), + ("下载资源", "mp_download"), + ("下载", "mp_download"), + ("订阅并搜索", "mp_subscribe_search"), + ("订阅搜索", "mp_subscribe_search"), + ("订阅媒体", "mp_subscribe"), + ("订阅", "mp_subscribe"), + ("热门推荐", "mp_recommendations"), + ("推荐", "mp_recommendations"), + ("智能发现", "mp_recommendations"), + ("热门发现", "mp_recommendations"), + ]: + if raw == prefix: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + if action == "cloud_transfer": + if remain_text: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if download_match: + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + else: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + else: + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if raw.startswith(prefix): + remain_text = raw[len(prefix):].strip() + if not remain_text: + continue + if action == "cloud_transfer": + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if not download_match: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + break + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + break + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if not options.get("action") and any( + marker in compact + for marker in [ + "热门影视", + "热门电影", + "热门电视剧", + "热门剧集", + "最近热门", + "有什么热门", + "看看热门", + "影视推荐", + "电影推荐", + "剧集推荐", + "电视剧推荐", + "豆瓣热门", + "豆瓣top250", + "正在热映", + "今日番剧", + "每日放送", + "bangumi", + "tmdb热门", + ] + ): + options["action"] = "mp_recommendations" + options["mode"] = "" + options["keyword"] = raw + for token in remain.split(): + item = token.strip() + if not item: + continue + if "=" in item: + key, value = item.split("=", 1) + key = key.strip().lower() + value = value.strip() + if key in {"pwd", "passcode", "code", "提取码"} and value: + options["access_code"] = value + continue + if key in {"path", "dir", "目录", "位置"} and value: + options["path"] = AgentResourceOfficer._resolve_pan_path_value(value) + continue + if key in {"type", "媒体类型"} and value: + options["type"] = value.strip().lower() + continue + if key in {"year", "年份"} and value: + options["year"] = value.strip() + continue + if key in {"client_type", "client", "客户端"} and value: + options["client_type"] = P115TransferService.normalize_qrcode_client_type(value) + continue + if key in {"hash", "download_hash", "任务hash"} and value: + options["hash"] = value.strip() + continue + if item.startswith("/") and not options["path"]: + options["path"] = AgentResourceOfficer._resolve_pan_path_value(item) + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + return options + + def _call_pansou_search(self, keyword: str) -> Tuple[bool, Dict[str, Any], str]: + last_error = "" + queries = [ + {"kw": keyword, "res": "merge", "src": "all"}, + {"kw": keyword}, + {"keyword": keyword}, + ] + urls = [] + base_urls = [] + configured_base = self._clean_text(self._pansou_base_url).rstrip("/") + if configured_base: + base_urls.append(configured_base) + for fallback_base in ("http://host.docker.internal:805", "http://127.0.0.1:805"): + if fallback_base not in base_urls: + base_urls.append(fallback_base) + for query in queries: + for base_url in base_urls: + urls.append(f"{base_url}/api/search?{urlencode(query)}") + data: Dict[str, Any] = {} + for url in urls: + try: + request = UrlRequest(url=url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=self._pansou_timeout) as response: + data = json.loads(response.read().decode("utf-8", "ignore")) + break + except Exception as exc: + last_error = str(exc) + data = {} + if not data: + return False, {}, f"盘搜请求失败:{last_error or '未知错误'}" + ok = str(data.get("code")) == "0" + if not ok: + return False, data, str(data.get("message") or "盘搜搜索失败") + return True, data, str(data.get("message") or "success") + + @staticmethod + def _normalize_pansou_channel_name(channel: str) -> str: + text = str(channel or "").strip().lower() + if text == "115" or "115" in text: + return "115" + if "quark" in text: + return "quark" + return str(channel or "").strip() or "未知" + + def _collect_pansou_channel_items( + self, + merged: Dict[str, Any], + channel_name: str, + limit: int = 6, + ) -> List[Dict[str, Any]]: + raw_items = merged.get(channel_name) or [] + if not isinstance(raw_items, list): + return [] + results: List[Dict[str, Any]] = [] + seen = set() + for item in raw_items: + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + if not url: + continue + note = str(item.get("note") or "未命名资源").strip() + password = str(item.get("password") or "").strip() + source = str(item.get("source") or "").strip() + dt = self._format_pansou_datetime(item.get("datetime")) + key = (url, note) + if key in seen: + continue + seen.add(key) + normalized = { + "channel": self._normalize_pansou_channel_name(channel_name), + "url": url, + "password": password, + "note": note, + "source": source, + "datetime": dt, + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + normalized[extra_key] = value + results.append(normalized) + if len(results) >= limit: + break + return results + + @staticmethod + def _extract_size_text(text: str) -> str: + match = re.search(r"(\d+(?:\.\d+)?)\s*(gb|g|mb|m)\b", str(text or ""), flags=re.IGNORECASE) + if not match: + return "" + value = match.group(1) + unit = match.group(2).upper() + if unit == "G": + unit = "GB" + if unit == "M": + unit = "MB" + return f"{value}{unit}" + + def _pansou_item_brief_summary(self, item: Dict[str, Any]) -> str: + text = self._score_text_blob(item) + parts: List[str] = [] + if "2160" in text or "4k" in text or "uhd" in text: + parts.append("4K") + elif "1080" in text: + parts.append("1080P") + elif "720" in text: + parts.append("720P") + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + parts.append("杜比视界") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + parts.append("HDR") + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "双语", "官中", "内封简繁"]): + parts.append("中字") + elif self._score_has_any(text, ["无中字", "无字幕"]): + parts.append("无中字") + progress = self._extract_series_progress(text) + if progress.get("episode_count", 0) >= max(3, progress.get("max_episode", 0)): + parts.append(f"E01-E{progress['max_episode']:02d}") + elif progress.get("max_episode", 0) > 0: + parts.append(f"更新到E{progress['max_episode']:02d}") + size_text = self._extract_size_text(text) or self._extract_size_text(self._list_text(item.get("share_size") or item.get("size"))) + if size_text: + parts.append(size_text) + return " | ".join(parts[:5]) + + @staticmethod + def _format_markdown_link(title: Any, url: Any) -> str: + clean_title = str(title or "").strip() + clean_url = str(url or "").strip() + if not clean_title: + return clean_url + if not clean_url: + return clean_title + safe_title = clean_title.replace("[", "[").replace("]", "]") + return f"[{safe_title}]({clean_url})" + + @staticmethod + def _parse_simple_cjk_number(text: str) -> Optional[int]: + raw = str(text or "").strip() + if not raw: + return None + if raw.isdigit(): + return int(raw) + mapping = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + if raw == "十": + return 10 + if "十" in raw: + left, right = raw.split("十", 1) + left_value = 1 if left == "" else mapping.get(left) + if left_value is None: + return None + right_value = 0 if right == "" else mapping.get(right) + if right_value is None: + return None + return left_value * 10 + right_value + total = 0 + for ch in raw: + if ch not in mapping: + return None + total = total * 10 + mapping[ch] + return total if total >= 0 else None + + @classmethod + def _normalize_simple_cjk_numbers(cls, text: str) -> str: + raw = str(text or "") + if not raw: + return "" + pattern = re.compile(r"[零〇一二两三四五六七八九十]{1,3}") + def repl(match: re.Match[str]) -> str: + value = cls._parse_simple_cjk_number(match.group(0)) + return str(value) if value is not None else match.group(0) + return pattern.sub(repl, raw) + + @classmethod + def _build_keyword_variants(cls, keyword: str) -> List[str]: + raw = cls._clean_text(keyword) + if not raw: + return [] + variants: List[str] = [] + + def add(value: Any) -> None: + text = cls._clean_text(value) + if not text: + return + if text not in variants: + variants.append(text) + + add(raw) + trimmed = re.split(r"[::((【\[\-_/|]", raw, 1)[0].strip() + add(trimmed) + if "的" in raw: + add(raw.split("的", 1)[0].strip()) + base_candidates = list(variants) + for base in base_candidates: + numeric = cls._normalize_simple_cjk_numbers(base) + add(numeric) + add(numeric.replace("个月", "ヶ月").replace("個月", "ヶ月")) + add(numeric.replace("个月", "个月").replace("個月", "个月")) + add(base.replace("三个月", "3个月").replace("三個月", "3个月")) + add(base.replace("三个月", "3ヶ月").replace("三個月", "3ヶ月")) + add(re.sub(r"\s+", "", base)) + return variants + + def _pansou_payload_item_count(self, payload: Dict[str, Any]) -> int: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + total = 0 + for channel in ("115", "quark"): + items = merged.get(channel) or [] + if isinstance(items, list): + total += len(items) + return total + + def _call_pansou_search_with_variants(self, keyword: str) -> Tuple[bool, Dict[str, Any], str, str]: + variants = self._build_keyword_variants(keyword) + last_error = "" + last_payload: Dict[str, Any] = {} + for variant in variants: + ok, payload, message = self._call_pansou_search(variant) + if ok and self._pansou_payload_item_count(payload) > 0: + return True, payload, message, variant + last_error = message + last_payload = payload if isinstance(payload, dict) else {} + return False, last_payload, last_error or "盘搜暂无结果", self._clean_text(keyword) + + def _assistant_pansou_entry_summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(items) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "pansou_result", + "label": "先看条目再选编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "pansou_result", + "label": "盘搜列表已返回", + "decision_hint": "默认直接回编号即可转存;想先确认可回复“选择 编号 详情”。只有明确要生成计划时才发“计划选择 编号”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _best_series_progress_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return {} + candidates.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("max_episode")), 0), + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + ), + reverse=True, + ) + return candidates[0] + + def _latest_series_progress_items(self, items: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return [] + best_episode = max(self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) for item in candidates) + if best_episode <= 0: + return [] + matches = [ + item for item in candidates + if self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) == best_episode + ] + matches.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + self._safe_int(value.get("updated_at"), 0), + self._clean_text(value.get("datetime")), + ), + reverse=True, + ) + return matches[: max(1, limit)] + + def _latest_episode_mp_items(self, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + candidates: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + if max_episode <= 0: + continue + enriched = dict(item) + enriched["_series_progress"] = progress + candidates.append(enriched) + if not candidates: + return [], 0 + latest_episode = max(self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) for item in candidates) + if latest_episode <= 0: + return [], 0 + latest_items = [ + item for item in candidates + if self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) == latest_episode + ] + return latest_items, latest_episode + + def _episode_filter_mp_items(self, items: List[Dict[str, Any]], episode: int) -> List[Dict[str, Any]]: + target = self._safe_int(episode, 0) + if target <= 0: + return [] + matches: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + episode_count = self._safe_int(progress.get("episode_count"), 0) + if max_episode == target: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + continue + if episode_count > 0 and max_episode >= target: + start_episode = max(1, max_episode - episode_count + 1) + if start_episode <= target <= max_episode: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + return matches + + def _latest_resource_date_text(self, items: List[Dict[str, Any]]) -> str: + latest_text = "" + latest_unix = 0 + for item in items or []: + if not isinstance(item, dict): + continue + text = self._clean_text(item.get("datetime") or item.get("updated_at_text")) + if text and text > latest_text: + latest_text = text + unix_value = self._safe_int(item.get("updated_at"), 0) + if unix_value > latest_unix: + latest_unix = unix_value + if latest_text: + return latest_text + if latest_unix > 0: + return self._format_unix_time(latest_unix) + return "" + + def _format_update_resource_choice(self, item: Dict[str, Any], source_type: str) -> str: + current = dict(item or {}) + index = self._safe_int(current.get("_index") or current.get("index"), 0) + provider = self._clean_text(current.get("channel") or current.get("pan_type")).upper() + if provider == "QUARK": + provider = "夸克" + elif provider == "115": + provider = "115" + provider_emoji = "🗄" if provider == "夸克" else "📺" if provider == "115" else "🔗" + label = f"{index}. {provider_emoji}" if index > 0 else f"?. {provider_emoji}" + if provider: + label = f"{label} {provider}" + if source_type == "pansou": + brief = self._pansou_item_brief_summary(current) + date_text = self._format_pansou_display_datetime(current.get("datetime")) + title_text = self._clean_text(current.get("note") or current.get("title") or "盘搜资源") + parts = [label] + if date_text: + parts.append(date_text) + if brief: + parts.append(brief) + if title_text and title_text != brief: + parts.append(self._truncate_text(title_text, 80)) + return " · ".join(parts) + progress = self._format_update_progress_label(current.get("_series_progress")) + subtitle = self._resource_subtitle_text(current) + resolution = "/".join(current.get("video_resolution") or []) or "" + date_text = self._clean_text(current.get("updated_at_text")) + parts = [label] + if date_text: + parts.append(self._format_pansou_display_datetime(date_text)) + if resolution: + parts.append(f"✨{resolution}" if "4" in resolution or "2160" in resolution else resolution) + if subtitle: + parts.append(f"字幕:{subtitle}") + if progress and progress != "未识别到集数": + parts.append(f"📌 {progress}") + detail = self._clean_text(current.get("title") or current.get("remark") or current.get("description")) + if detail: + parts.append(self._truncate_text(detail, 70)) + return " · ".join(parts) + + def _format_update_progress_label(self, progress: Optional[Dict[str, Any]] = None) -> str: + current = dict(progress or {}) + max_episode = self._safe_int(current.get("max_episode"), 0) + episode_count = self._safe_int(current.get("episode_count"), 0) + if max_episode <= 0: + return "未识别到集数" + if episode_count >= max_episode >= 2: + return f"E01-E{max_episode:02d}" + return f"更新到 E{max_episode:02d}" + + async def _assistant_update_check( + self, + *, + keyword: str, + session: str, + cache_key: str, + year: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + if not clean_keyword: + return { + "success": False, + "message": "用法:更新检查 片名", + "data": self._assistant_response_data(session=session, data={"action": "update_check", "ok": False, "error_code": "missing_keyword"}), + } + official = self._tmdb_latest_episode_progress(clean_keyword, year) + official_episode = self._safe_int(official.get("episode"), 0) + + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + + pansou_best: Dict[str, Any] = {} + pansou_latest_items: List[Dict[str, Any]] = [] + pansou_recent_date = "" + pansou_total = 0 + search_ok, payload, _search_message = self._call_pansou_search(clean_keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + pansou_total = self._safe_int(data.get("total"), 0) + raw_items = self._collect_pansou_channel_items(merged, "115", 20) + self._collect_pansou_channel_items(merged, "quark", 20) + scored_items = self._attach_cloud_scores(raw_items, preferences=preferences, source_type="pansou", target_path=self._hdhive_default_path) + pansou_best = self._best_series_progress_item(scored_items) + pansou_latest_items = self._latest_series_progress_items(scored_items, limit=5) + pansou_recent_date = self._latest_resource_date_text(scored_items) + + hdhive_best: Dict[str, Any] = {} + hdhive_latest_items: List[Dict[str, Any]] = [] + hdhive_recent_date = "" + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + candidate_ok, candidate_result, _candidate_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type="tv", + year=self._clean_text(year), + candidate_limit=max(10, self._hdhive_candidate_page_size), + ) + if candidate_ok: + candidates = candidate_result.get("candidates") or [] + chosen = next((item for item in candidates if self._clean_text(item.get("media_type")).lower() in {"tv", "series"}), None) + if chosen is None and candidates: + chosen = candidates[0] + if isinstance(chosen, dict): + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen.get("media_type") or "tv", + tmdb_id=str(chosen.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=self._hdhive_default_path, + ) + hdhive_best = self._best_series_progress_item(preview) + hdhive_latest_items = self._latest_series_progress_items(preview, limit=5) + hdhive_recent_date = self._latest_resource_date_text(preview) + + pansou_progress = dict(pansou_best.get("_series_progress") or {}) + hdhive_progress = dict(hdhive_best.get("_series_progress") or {}) + lines = [f"更新检查:{clean_keyword}"] + if official_episode > 0: + official_title = self._clean_text(official.get("title")) or clean_keyword + lines.append(f"📺 TMDB 进度:{official_title} S{self._safe_int(official.get('season'), 1):02d}E{official_episode:02d}") + else: + lines.append("📺 TMDB 进度:未稳定识别到最新集数") + if pansou_best: + lines.append( + f"\n🟨 盘搜结果:{self._format_update_progress_label(pansou_progress)}" + f" · 最佳 #{self._safe_int(pansou_best.get('_index'), 0)}" + ) + if pansou_latest_items: + for item in pansou_latest_items: + lines.append(self._format_update_resource_choice(item, "pansou")) + elif pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟨 盘搜结果:暂无可识别更新结果") + if pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + if hdhive_best: + lines.append( + f"\n🟦 影巢结果:{self._format_update_progress_label(hdhive_progress)}" + f" · 最佳 #{self._safe_int(hdhive_best.get('_index'), 0)}" + ) + if hdhive_latest_items: + for item in hdhive_latest_items: + lines.append(self._format_update_resource_choice(item, "hdhive")) + elif hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟦 影巢结果:暂无可识别更新结果") + if hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + + latest_seen = max( + official_episode, + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + downloadable_latest = max( + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + if latest_seen > 0: + lines.append(f"\n🔎 当前观察到最高更新:E{latest_seen:02d}") + if downloadable_latest > 0: + lines.append(f"✅ 已可下载最新集:E{downloadable_latest:02d}") + else: + lines.append("⚠️ 已可下载最新集:暂未稳定识别") + if official_episode > 0: + pansou_ok = self._safe_int(pansou_progress.get("max_episode"), 0) >= official_episode + hdhive_ok = self._safe_int(hdhive_progress.get("max_episode"), 0) >= official_episode + if pansou_ok and hdhive_ok: + lines.append("✅ 盘搜和影巢都已跟上 TMDB 进度。") + else: + lines.append(f"{'✅' if pansou_ok else '⏳'} 盘搜:{'已跟上' if pansou_ok else '还没稳定跟上'}TMDB 进度。") + lines.append(f"{'✅' if hdhive_ok else '⏳'} 影巢:{'已跟上' if hdhive_ok else '还没稳定跟上'}TMDB 进度。") + pt_search_needed = latest_seen <= 0 and official_episode <= 0 + if pt_search_needed: + lines.append(f"\n下一步:官方和云盘侧都还没看到明确新集;如果要继续扩搜,可以回复:PT搜索 {clean_keyword}。") + else: + lines.append("\n下一步:直接回编号可继续处理;想先确认可发“选择 编号 详情”。") + lines.append(f"也可以发“云盘搜索 {clean_keyword}”查看更多资源,或“影巢搜索 {clean_keyword}”只看影巢。") + update_items: List[Dict[str, Any]] = [] + for item in pansou_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "pansou"}) + for item in hdhive_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "hdhive"}) + self._save_session(cache_key, { + "kind": "assistant_update_check", + "stage": "result", + "keyword": clean_keyword, + "year": self._clean_text(year), + "items": update_items, + "pansou_items": pansou_latest_items, + "hdhive_items": hdhive_latest_items, + "target_path": self._hdhive_default_path, + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check", + "ok": True, + "keyword": clean_keyword, + "official_progress": official, + "pansou_best": pansou_best, + "pansou_latest_items": pansou_latest_items, + "pansou_recent_date": pansou_recent_date, + "hdhive_best": hdhive_best, + "hdhive_latest_items": hdhive_latest_items, + "hdhive_recent_date": hdhive_recent_date, + "latest_seen_episode": latest_seen, + "downloadable_latest_episode": downloadable_latest, + "decision_summary": { + "stage": "update_check", + "label": "更新检查已完成", + "preferred_command": f"PT搜索 {clean_keyword}" if pt_search_needed else f"云盘搜索 {clean_keyword}", + "fallback_command": f"云盘搜索 {clean_keyword}" if pt_search_needed else f"影巢搜索 {clean_keyword}", + "compact_commands": [f"PT搜索 {clean_keyword}", f"云盘搜索 {clean_keyword}"] if pt_search_needed else [f"云盘搜索 {clean_keyword}", f"影巢搜索 {clean_keyword}"], + "recommended_agent_behavior": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + }, + }), + } + + @classmethod + def _read_tmdb_api_key(cls) -> str: + for value in [ + os.environ.get("TMDB_API_KEY", ""), + os.environ.get("TMDB_KEY", ""), + cls._clean_text(getattr(settings, "TMDB_API_KEY", "") if settings is not None else ""), + ]: + if cls._clean_text(value): + return cls._clean_text(value) + compose_path = Path("/Applications/Dockge/moviepilot-ai-recognizer-gateway/docker-compose.yml") + if compose_path.exists(): + for line in compose_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + if "TMDB_API_KEY" not in line: + continue + _, _, value = line.partition(":") + key = cls._clean_text(value.strip().strip("'\"")) + if key: + return key + return "" + + @classmethod + def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: + clean_tmdb_id = cls._clean_text(tmdb_id) + clean_media_type = cls._clean_text(media_type).lower() + if not clean_tmdb_id or clean_media_type not in {"movie", "tv"}: + return [] + cache_key = f"{clean_media_type}:{clean_tmdb_id}" + with cls._candidate_actor_cache_lock: + cached = cls._candidate_actor_cache.get(cache_key) + if cached is not None: + return list(cached) + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return [] + endpoint = "movie" if clean_media_type == "movie" else "tv" + url = ( + f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?" + f"{urlencode({'api_key': tmdb_api_key, 'language': 'zh-CN', 'append_to_response': 'credits'})}" + ) + actors: List[str] = [] + try: + request = UrlRequest(url=url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + cast = ((payload.get("credits") or {}).get("cast") or []) if isinstance(payload, dict) else [] + for member in cast[:10]: + name = cls._clean_text((member or {}).get("name")) + department = cls._clean_text((member or {}).get("known_for_department")) + if not name: + continue + if department and department != "Acting": + continue + if name not in actors: + actors.append(name) + if len(actors) >= 2: + break + except Exception: + actors = [] + with cls._candidate_actor_cache_lock: + cls._candidate_actor_cache[cache_key] = list(actors) + return actors + + @classmethod + def _tmdb_latest_episode_progress(cls, keyword: str, year: str = "") -> Dict[str, Any]: + title = cls._clean_text(keyword) + if not title: + return {} + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return {} + params = { + "api_key": tmdb_api_key, + "language": "zh-CN", + "query": title, + } + try: + search_url = "https://api.themoviedb.org/3/search/tv?" + urlencode(params) + request = UrlRequest(url=search_url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + results = payload.get("results") or [] + if not isinstance(results, list) or not results: + return {} + target_year = cls._clean_text(year)[:4] + picked = None + for item in results: + first_air = cls._clean_text((item or {}).get("first_air_date"))[:4] + if target_year and first_air and first_air == target_year: + picked = item + break + if picked is None: + picked = results[0] + tv_id = cls._clean_text((picked or {}).get("id")) + if not tv_id: + return {} + detail_url = ( + f"https://api.themoviedb.org/3/tv/{tv_id}?" + + urlencode({"api_key": tmdb_api_key, "language": "zh-CN"}) + ) + detail_request = UrlRequest(url=detail_url, headers={"Accept": "application/json"}) + with urlopen(detail_request, timeout=20) as response: + detail = json.loads(response.read().decode("utf-8", "ignore")) + last_episode = (detail.get("last_episode_to_air") or {}) if isinstance(detail, dict) else {} + season_number = cls._safe_int(last_episode.get("season_number"), 0) + episode_number = cls._safe_int(last_episode.get("episode_number"), 0) + return { + "tmdb_id": tv_id, + "title": cls._clean_text(detail.get("name") or picked.get("name") or title), + "year": cls._clean_text(detail.get("first_air_date") or picked.get("first_air_date"))[:4], + "season": season_number, + "episode": episode_number, + "status": cls._clean_text(detail.get("status")), + } + except Exception: + return {} + + def _maybe_enrich_hdhive_candidate_with_actors(self, candidate: Dict[str, Any]) -> Dict[str, Any]: + enriched = dict(candidate or {}) + if enriched.get("actors"): + return enriched + enriched["actors"] = self._fetch_candidate_actors( + enriched.get("tmdb_id"), + str(enriched.get("media_type") or enriched.get("type") or ""), + ) + return enriched + + def _enrich_hdhive_candidates_with_actors(self, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + indexed = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] + pending = [(idx, item) for idx, item in indexed if not (item.get("actors") or [])] + enriched_map: Dict[int, Dict[str, Any]] = {idx: item for idx, item in indexed} + if pending: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as executor: + future_map = { + executor.submit(self._maybe_enrich_hdhive_candidate_with_actors, item): idx + for idx, item in pending + } + for future in concurrent.futures.as_completed(future_map): + idx = future_map[future] + try: + enriched_map[idx] = future.result() + except Exception: + enriched_map[idx] = dict(indexed[idx][1]) + return [enriched_map[idx] for idx, _ in indexed] + + @staticmethod + def _format_candidate_label(item: Dict[str, Any]) -> str: + title = str(item.get("title") or "未命名") + year = str(item.get("year") or "?") + raw_media_type = str(item.get("media_type") or item.get("type") or "?") + media_type = { + "movie": "电影", + "tv": "剧集", + "series": "剧集", + }.get(raw_media_type.lower(), raw_media_type) + actors = item.get("actors") or [] + parts = [year, media_type] + actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) + if actor_text: + parts.append(f"主演:{actor_text}") + return f"{title} ({' | '.join([part for part in parts if part])})" + + @staticmethod + def _format_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "候选影片:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"候选影片:{len(candidates)} 个,请先选择影片:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") + lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @staticmethod + def _format_mp_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "MP 搜索候选:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"MP 搜索候选:{len(candidates)} 个,请先选择正确的电影/剧集:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复编号或“选择 编号”,选定后再搜索 PT 资源。") + lines.append("如需补充当前候选页主演,可回复:详情 或 审查。") + lines.append("如果已经知道年份,也可以直接发:MP搜索 片名 年份。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _keyword_has_explicit_year(cls, keyword: Any, year: Any = "") -> bool: + return bool(cls._clean_text(year) or re.search(r"(?:19|20)\d{2}", cls._clean_text(keyword))) + + @staticmethod + def _list_text(value: Any, separator: str = "/") -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if str(item).strip()] + return separator.join(parts) + return str(value).strip() + + @staticmethod + def _truncate_text(value: Any, limit: int = 140) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)]}…" + + @staticmethod + def _resource_points_text(item: Dict[str, Any]) -> str: + points = item.get("unlock_points") + if points is None: + points = item.get("cost") + if points is None: + points = item.get("points") + if points in (0, "0"): + return "免费" + if points in (None, "", "未知"): + return "免费" if AgentResourceOfficer._resource_has_free_marker(item) else "未标积分" + return f"{points}分" + + @staticmethod + def _resource_subtitle_text(item: Dict[str, Any]) -> str: + language = AgentResourceOfficer._list_text( + item.get("subtitle_language") + or item.get("subtitle_languages") + or item.get("subtitles") + or item.get("subtitle") + ) + subtitle_type = AgentResourceOfficer._list_text(item.get("subtitle_type") or item.get("subtitle_types")) + if language and subtitle_type: + return f"{language} · {subtitle_type}" + return language or subtitle_type + + @staticmethod + def _resource_episode_text(item: Dict[str, Any]) -> str: + explicit_keys = [ + "episode_range", + "episodes_range", + "episode_info", + "episodes", + "episode", + "update_status", + "update_info", + "season_episode", + ] + for key in explicit_keys: + text = AgentResourceOfficer._list_text(item.get(key)) + if text: + return AgentResourceOfficer._truncate_text(text, 40) + + source_text = " ".join( + str(item.get(key) or "") + for key in ["title", "remark", "description", "desc", "detail", "note"] + ) + patterns = [ + r"(全\s*\d+\s*集)", + r"(全集)", + r"(更新至\s*第?\s*\d+\s*集)", + r"(更\s*\d+\s*集)", + r"(第?\s*\d+\s*[-~到至]\s*\d+\s*集)", + r"(\d+\s*-\s*\d+\s*集)", + r"(S\d{1,2}E\d{1,3}(?:\s*[-~]\s*E?\d{1,3})?)", + r"(EP?\s*\d{1,3}(?:\s*[-~]\s*EP?\s*\d{1,3})?)", + ] + for pattern in patterns: + match = re.search(pattern, source_text, flags=re.IGNORECASE) + if match: + return re.sub(r"\s+", "", match.group(1)) + return "" + + @staticmethod + def _resource_remark_text(item: Dict[str, Any]) -> str: + for key in ["remark", "description", "desc", "detail", "details", "summary", "note"]: + text = AgentResourceOfficer._truncate_text(item.get(key), 160) + if text: + return text + return "" + + def _format_resource_lines( + self, + resources: List[Dict[str, Any]], + candidate: Optional[Dict[str, Any]] = None, + *, + page: int = 1, + page_size: int = 20, + total_resources: Optional[int] = None, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(resources), page=page, page_size=page_size) + page_items = resources[start:end] + total_count = max(0, self._safe_int(total_resources if total_resources is not None else len(resources), len(resources))) + lines = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {total_count} 条") + if total_pages > 1 and page_items: + first_visible = self._safe_int((page_items[0] or {}).get("pick_index"), start + 1) + last_visible = self._safe_int((page_items[-1] or {}).get("pick_index"), min(len(resources), end)) + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {first_visible}-{last_visible} / 共 {total_count} 条:") + current_provider = "" + for local_idx, item in enumerate(page_items, start=1): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + global_index = self._safe_int(item.get("pick_index"), start + local_idx) + lines.append(self._format_hdhive_resource_summary_line(item, global_index)) + lines.append("") + summary = self._score_summary(page_items, limit=5) + best_index = self._safe_int((((summary or {}).get("best") or {}).get("index")), 0) + detail_hint = f"选择 {best_index} 详情" if best_index > 0 else "选择 1 详情" + suggestion_lines = self._format_hdhive_selection_suggestion(page_items) + if suggestion_lines: + lines.append("") + lines.extend(suggestion_lines) + lines.append(f"下一步:直接回编号即可转存;想先确认可发“{detail_hint}”。") + lines.append("如需保留计划确认链,可再发“计划选择 编号”。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(line for line in lines if line is not None) + + def _assistant_hdhive_resource_entry_summary(self, resources: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(resources) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "hdhive_resource", + "label": "先查看条目再选择编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "hdhive_resource", + "label": "影巢资源列表已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + @classmethod + def _normalize_hdhive_resource_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_hdhive" or cls._clean_text(current_state.get("stage")) != "resource": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + return {} + + @staticmethod + def _format_route_result(result: Dict[str, Any]) -> str: + lines = ["已执行资源路由"] + route = result.get("route") or {} + provider = str(route.get("provider") or route.get("pan_type") or "") + if provider: + lines.append(f"网盘:{provider}") + if route.get("message"): + lines.append(f"结果:{route.get('message')}") + if route.get("target_path"): + lines.append(f"目录:{route.get('target_path')}") + return "\n".join(lines) + + async def _unlock_and_route( + self, + slug: str, + target_path: str = "", + resource: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any], str]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return False, disabled.get("data") or {}, disabled.get("message") or "影巢资源入口已关闭" + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(resource) + if not points_ok: + return False, {"resource_guard": points_data, "resource": resource or {}}, points_message + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return False, result, unlock_message + + unlock_data = result.get("data") or {} + share_url = self._clean_text(unlock_data.get("full_url") or unlock_data.get("url")) + access_code = self._clean_text(unlock_data.get("access_code")) + pan_type = self._clean_text(unlock_data.get("pan_type")).lower() + + route_result: Dict[str, Any] = { + "unlock": result, + "route": { + "pan_type": pan_type or "unknown", + "share_url": share_url, + "access_code": access_code, + "executed": False, + "message": "", + }, + } + + if share_url and (pan_type == "quark" or self._is_quark_url(share_url)): + quark_service = self._ensure_quark_service() + transfer_ok, transfer_result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "quark", + "target_path": target_path or self._quark_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, ( + "影巢解锁成功,但" + + self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ) + ) + return True, route_result, "success" + + if share_url and (pan_type == "115" or self._is_115_url(share_url)): + p115_service = self._ensure_p115_service() + transfer_ok, transfer_result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "115", + "target_path": target_path or self._p115_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + title="影巢解锁成功,但 115 转存失败", + ) + return True, route_result, "success" + + route_result["route"]["message"] = "当前解锁结果未识别到可自动路由的 115 / 夸克链接" + return True, route_result, "success" + + @staticmethod + def _is_quark_url(value: str) -> bool: + return QuarkTransferService.is_quark_share_url(value) + + @staticmethod + def _is_115_url(value: str) -> bool: + host = urlparse(value or "").netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + @staticmethod + def _run_coroutine_sync(coro): + try: + return asyncio.run(coro) + except RuntimeError: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def feishu_assistant_route(self, text: str, session: str) -> Dict[str, Any]: + return self._run_coroutine_sync( + self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "feishu", + "compact": False, + }, + ) + ) + ) + + def feishu_assistant_pick(self, arg: str, session: str) -> Dict[str, Any]: + index, target_path, action, mode = self._parse_feishu_pick_arg(arg) + return self._run_coroutine_sync( + self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "feishu", + "index": index, + "action": action, + "mode": mode, + "path": target_path, + "compact": False, + }, + ) + ) + ) + + @classmethod + def _parse_feishu_pick_arg(cls, arg: str) -> Tuple[int, str, str, str]: + return cls._parse_pick_text(arg) + + async def tool_hdhive_search_session( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + target_path: str = "", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=self._clean_text(keyword), + media_type=self._clean_text(media_type or "auto").lower(), + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return f"影巢搜索失败:{search_message}" + + candidates = result.get("candidates") or [] + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type or "auto").lower(), + "year": self._clean_text(year), + "target_path": self._clean_text(target_path), + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return ( + f"{self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size)}\n" + f"session_id: {session_id}\n" + "下一步:调用 agent_resource_officer_hdhive_pick,并传入 session_id 与 choice" + ) + + async def tool_hdhive_pick_session(self, session_id: str, index: int, target_path: str = "", action: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + session = self._load_session(self._clean_text(session_id)) + if not session: + return "会话不存在或已过期" + + stage = session.get("stage") + service = self._ensure_hdhive_service() + action = self._normalize_pick_action(action) + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(self._clean_text(session_id), {**session, "candidates": enriched}) + return self._format_candidate_lines(enriched, page=current_page, page_size=page_size) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_candidate_lines(candidates, page=next_page, page_size=page_size) + if index <= 0 or index > len(candidates): + return "候选编号超出范围" + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return f"影巢资源查询失败:{resource_message}" + preferences = self._normalize_assistant_preferences( + (self._assistant_preferences or {}).get(self._normalize_preference_key(session=self._clean_text(session.get("keyword")) or "default")) + ) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path or session.get("target_path") or "", + ) + self._save_session( + self._clean_text(session_id), + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": self._clean_text(target_path) or session.get("target_path") or "", + }, + ) + return self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)) + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)) + if index <= 0 or index > len(resources): + return "资源编号超出范围" + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=self._clean_text(target_path) or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return f"资源处理失败:{route_message}" + return self._format_route_result(route_result) + + return f"当前会话阶段不支持继续选择:{stage}" + + async def tool_route_share(self, share_url: str, access_code: str = "", target_path: str = "") -> str: + share_url = self._clean_text(share_url) + if not share_url: + return "缺少分享链接" + + if self._is_quark_url(share_url): + service = self._ensure_quark_service() + ok, result, message = service.transfer_share( + share_url, + access_code=self._clean_text(access_code), + target_path=self._clean_text(target_path) or self._quark_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_quark_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._quark_default_path, + ) + return f"夸克转存成功\n目录:{result.get('target_path') or self._quark_default_path}" + + if self._is_115_url(share_url): + ok, result, message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(access_code), + path=self._clean_text(target_path) or self._p115_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_p115_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._p115_default_path, + ) + return f"115 转存成功\n目录:{result.get('path') or self._p115_default_path}" + + return "当前链接不是可识别的 115 / 夸克分享链接" + + async def tool_assistant_route( + self, + text: str = "", + session: str = "default", + session_id: str = "", + target_path: str = "", + mode: str = "", + keyword: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + action: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "path": self._clean_text(target_path), + "mode": self._clean_text(mode), + "keyword": self._clean_text(keyword), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "action": self._clean_text(action), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "处理完成") + + async def tool_assistant_pick( + self, + session: str = "default", + session_id: str = "", + index: int = 0, + action: str = "", + mode: str = "", + target_path: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": index, + "action": self._clean_text(action), + "mode": self._clean_text(mode), + "path": self._clean_text(target_path), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "继续处理完成") + + async def tool_assistant_help(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + return self._format_assistant_help_text(session=session_name) + + async def tool_assistant_capabilities(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_capabilities_compact_data(self._assistant_capabilities_public_data()) + return ( + f"Agent影视助手:{data.get('version')};" + f"工作流 {len(data.get('workflows') or [])} 个;" + f"Tool {len(data.get('agent_tools') or [])} 个" + ) + return self._format_assistant_capabilities_text() + + async def tool_assistant_readiness(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_readiness_compact_data(self._assistant_readiness_public_data()) + services = data.get("services") or {} + return ( + f"就绪:{'是' if data.get('can_start') else '否'};" + f"115:{'可用' if services.get('p115_ready') else '不可用'};" + f"影巢:{'已配' if services.get('hdhive_configured') else '未配'};" + f"夸克:{'已配' if services.get('quark_configured') else '未配'};" + f"待计划:{data.get('saved_plans_pending') or 0}" + ) + return self._format_assistant_readiness_text() + + async def tool_feishu_health(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + channel = self._ensure_feishu_channel() + data = { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + } + if not compact: + return json.dumps(data, ensure_ascii=False, indent=2) + return ( + f"飞书入口:{'已开启' if data.get('enabled') else '未开启'};" + f"长连接:{'运行中' if data.get('running') else '未运行'};" + f"SDK:{'可用' if data.get('sdk_available') else '缺失'};" + f"AppID:{'已填' if data.get('app_id_configured') else '未填'};" + f"AppSecret:{'已填' if data.get('app_secret_configured') else '未填'};" + f"白名单:chat {data.get('allowed_chat_count') or 0} / user {data.get('allowed_user_count') or 0};" + f"其他飞书入口:{'检测到运行中' if data.get('legacy_bridge_running') else '未检测到'}" + ) + + async def tool_assistant_pulse(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_pulse_text() + + async def tool_assistant_startup(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_startup_text() + + async def tool_assistant_maintain(self, execute: bool = False, limit: int = 100) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + return self._format_assistant_maintain_text(data) + + async def tool_assistant_toolbox(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_toolbox_text() + + async def tool_assistant_request_templates( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return self._format_assistant_request_templates_text(data) + + async def tool_assistant_selfcheck(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_selfcheck_text() + + async def tool_assistant_history( + self, + session: str = "", + session_id: str = "", + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_history_compact_data( + self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + ) + failed = sum(1 for item in (data.get("items") or []) if not item.get("success")) + return f"最近执行历史:{len(data.get('items') or [])} 条;失败 {failed} 条" + return self._format_assistant_history_text(session=session, session_id=session_id, limit=limit) + + async def tool_assistant_execute_action( + self, + name: str, + session: str = "default", + session_id: str = "", + choice: Optional[int] = None, + target_path: str = "", + keyword: str = "", + media_type: str = "", + year: str = "", + share_url: str = "", + access_code: str = "", + client_type: str = "", + source: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = "", + prefer_unexecuted: bool = True, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_action( + _JsonRequestShim( + _RequestContextShim(), + { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": choice, + "path": self._clean_text(target_path), + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + "plan_id": self._clean_text(plan_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "动作执行完成") + + async def tool_assistant_execute_actions( + self, + actions: List[Dict[str, Any]], + session: str = "default", + session_id: str = "", + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_actions( + _JsonRequestShim( + _RequestContextShim(), + { + "actions": actions or [], + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "批量动作执行完成") + + async def tool_assistant_workflow( + self, + name: str, + session: str = "default", + session_id: str = "", + keyword: str = "", + choice: Optional[int] = None, + candidate_choice: Optional[int] = None, + resource_choice: Optional[int] = None, + target_path: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + source: str = "", + limit: int = 20, + dry_run: Optional[bool] = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + payload = { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "keyword": self._clean_text(keyword), + "choice": choice, + "candidate_choice": candidate_choice, + "resource_choice": resource_choice, + "path": self._clean_text(target_path), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "limit": self._safe_int(limit, 20), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + } + if dry_run is not None: + payload["dry_run"] = bool(dry_run) + result = await self.api_assistant_workflow( + _JsonRequestShim(_RequestContextShim(), payload) + ) + return str(result.get("message") or "工作流执行完成") + + async def tool_assistant_preferences( + self, + session: str = "default", + session_id: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + reset: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + method = "DELETE" if reset else "POST" if preferences else "GET" + result = await self.api_assistant_preferences( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "user_key": self._clean_text(user_key), + "preferences": preferences or {}, + "compact": bool(compact), + }, + method=method, + ) + ) + return str(result.get("message") or "偏好画像处理完成") + + async def tool_assistant_execute_plan( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_plan_execute( + _JsonRequestShim( + _RequestContextShim(), + { + "plan_id": self._clean_text(plan_id), + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "计划执行完成") + + async def tool_assistant_plans( + self, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_plans_compact_data( + self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=False, + limit=limit, + ) + ) + pending = sum(1 for item in (data.get("items") or []) if not item.get("executed")) + return f"已保存计划:{len(data.get('items') or [])} 条;待执行 {pending} 条" + return self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + + async def tool_assistant_plans_clear( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = self._clear_workflow_plans( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + return str(result.get("message") or "计划清理完成") + + async def tool_assistant_recover( + self, + session: str = "", + session_id: str = "", + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_recover( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "execute": bool(execute), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + "limit": self._safe_int(limit, 20), + }, + ) + ) + return str(result.get("message") or "恢复检查完成") + + async def tool_assistant_session_state(self, session: str = "default", session_id: str = "", compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if compact: + state = self._assistant_session_compact_data(self._assistant_session_public_data(session=session_name)) + recovery = state.get("recovery") or {} + parts = [ + f"会话:{state.get('session')}", + f"阶段:{state.get('kind') or '-'} / {state.get('stage') or '-'}", + f"恢复:{recovery.get('mode') or '-'}", + ] + if recovery.get("recommended_action"): + parts.append(f"推荐动作:{recovery.get('recommended_action')}") + return "\n".join(parts) + return self._format_assistant_session_summary(session=session_name) + + async def tool_assistant_sessions( + self, + kind: str = "", + has_pending_p115: Optional[bool] = None, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_sessions_compact_data( + self._assistant_sessions_public_data(kind=kind, has_pending_p115=has_pending_p115, limit=limit) + ) + return f"活跃会话:{data.get('total') or 0} 个;展示 {len(data.get('items') or [])} 个" + return self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + + async def tool_assistant_session_clear(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + existing = self._load_session(cache_key) + if not existing: + return "当前没有需要清理的会话。" + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return f"已清理会话:{session_name}" + + async def tool_assistant_sessions_clear( + self, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_sessions_clear( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + }, + ) + ) + return str(result.get("message") or "会话清理完成") + + async def tool_p115_qrcode_start(self, client_type: str = "alipaymini") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + final_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=final_client_type) + if not qr_ok: + return f"115 扫码二维码生成失败:{qr_message}" + return ( + "115 扫码二维码已生成\n" + f"client_type: {data.get('client_type')}\n" + f"uid: {data.get('uid')}\n" + f"time: {data.get('time')}\n" + f"sign: {data.get('sign')}\n" + f"qrcode: {data.get('qrcode')}\n" + "下一步:调用 agent_resource_officer_p115_qrcode_check,并传入 uid、time、sign 和 client_type" + ) + + async def tool_p115_qrcode_check( + self, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(uid), + time_value=self._clean_text(time_value), + sign=self._clean_text(sign), + client_type=P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type), + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": self._p115_client_type, + }) + data["cookie_saved"] = True + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"status: {status or 'unknown'}", + f"message: {qr_message}", + ] + if data.get("cookie_saved"): + lines.append("cookie_saved: true") + lines.append(self._format_p115_status_summary(title="115 登录完成")) + if data.get("cookie_keys"): + lines.append(f"cookie_keys: {', '.join(data.get('cookie_keys') or [])}") + return "\n".join(lines) + + async def tool_p115_status(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_p115_status_summary() + + async def tool_p115_pending(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + return summary or "当前没有待继续的 115 任务。" + + async def tool_p115_resume(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return "当前没有待继续的 115 任务。" + if not self._p115_status_snapshot().get("ready"): + return f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。" + resume_ok, resume_message, _ = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 Agent Tool 手动继续 115 任务", + ) + lines = ["已手动继续 115 任务", resume_message] + if not resume_ok: + lines.append("任务仍未成功,保留待继续状态。") + return "\n".join(line for line in lines if line) + + async def tool_p115_cancel(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + if not summary: + return "当前没有待取消的 115 任务。" + self._clear_pending_p115_share(session_id) + return f"{summary}\n已取消并清除这次待继续的 115 任务。" + + async def api_p115_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_p115_service() + health_ok, result, health_message = service.health() + cookie_state = result.get("cookie_state") or {} + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "p115_ready": health_ok, + "p115_direct_ready": bool(result.get("direct_ready")), + "p115_direct_source": result.get("direct_source") or "", + "p115_helper_ready": bool(result.get("helper_ready")), + "default_target_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie_configured": bool(cookie_state.get("configured")), + "p115_cookie_valid": bool(cookie_state.get("valid")), + "p115_cookie_mode": cookie_state.get("mode") or "none", + "p115_cookie_keys": cookie_state.get("cookie_keys") or [], + "message": "" if health_ok else health_message, + "raw": result, + }, + } + + async def api_p115_qrcode(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": qr_message} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_qrcode_check(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + uid = self._clean_text(request.query_params.get("uid")) + time_value = self._clean_text(request.query_params.get("time")) + sign = self._clean_text(request.query_params.get("sign")) + if not uid or not time_value or not sign: + return {"success": False, "message": "缺少 uid/time/sign,无法检查扫码状态"} + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=uid, + time_value=time_value, + sign=sign, + client_type=client_type, + ) + if qr_ok and (data.get("status") == "success"): + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + data["status_summary"] = self._format_p115_status_summary(title="115 登录完成") + if not qr_ok: + return {"success": False, "message": qr_message, "data": data} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + url = self._clean_text(body.get("url") or body.get("share_url")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_p115_service() + transfer_ok, result, transfer_message = service.transfer_share( + url=url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_p115_pending(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + data = self._pending_p115_public_data(state) + data["session_id"] = session_id + return { + "success": True, + "message": summary or "当前没有待继续的 115 任务。", + "data": data, + } + + async def api_p115_pending_resume(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return { + "success": False, + "message": "当前没有待继续的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。", + "data": {"session_id": session_id, **self._pending_p115_public_data(state)}, + } + resume_ok, resume_message, resume_data = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 API 手动继续 115 任务", + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": { + "session_id": session_id, + "result": resume_data, + "pending": self._pending_p115_public_data(self._load_session(session_id) or {}), + }, + } + + async def api_p115_pending_cancel(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + if not summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + pending_data = self._pending_p115_public_data(state) + self._clear_pending_p115_share(session_id) + return { + "success": True, + "message": f"{summary}\n已取消并清除这次待继续的 115 任务。", + "data": {"session_id": session_id, "cancelled": True, "pending": pending_data}, + } + + async def api_hdhive_unlock_and_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + route_ok, route_result, route_message = await self._unlock_and_route(slug, target_path=target_path, resource=body) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return {"success": True, "message": route_message, "data": route_result} + + async def api_share_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_url = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 自动路由") + + if self._is_quark_url(share_url): + quark_service = self._ensure_quark_service() + transfer_ok, result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": { + "provider": "quark", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "quark", + "result": result, + }, + } + + if self._is_115_url(share_url): + p115_service = self._ensure_p115_service() + transfer_ok, result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": { + "provider": "115", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "115", + "result": result, + }, + } + + return { + "success": False, + "message": "当前链接不是可识别的 115 / 夸克分享链接", + "data": {"provider": "unknown", "url": share_url}, + } + + async def api_assistant_preferences(self, request: Request): + body: Dict[str, Any] = {} + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session") or "default") + user_key = self._clean_text(body.get("user_key") or request.query_params.get("user_key")) + if request.method.upper() == "DELETE" or self._parse_bool_value(body.get("reset"), False): + preferences_data = self._reset_assistant_preferences(session=session, user_key=user_key) + return { + "success": True, + "message": "智能体片源偏好已重置,下一次启动会重新进入偏好询问。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_reset", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + if request.method.upper() == "POST": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else { + key: value + for key, value in body.items() + if key not in {"apikey", "session", "session_id", "user_key", "compact", "reset"} + } + preferences_data = self._save_assistant_preferences(session=session, user_key=user_key, preferences=preferences) + return { + "success": True, + "message": "智能体片源偏好已保存。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + + preferences_data = self._assistant_preferences_public_data(session=session, user_key=user_key) + message_text = "智能体片源偏好未初始化,请先询问用户偏好。" if preferences_data.get("needs_onboarding") else "智能体片源偏好已初始化。" + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "preferences", + "ok": True, + "preferences": preferences_data, + }), + } + + async def api_assistant_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + text = self._clean_text(body.get("text") or body.get("query") or body.get("message") or "") + state = self._load_session(cache_key) or {} + compact = self._parse_bool_value(body.get("compact"), False) + saved_plan = self._session_workflow_plan_public_data(session=session, session_id=cache_key) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + pending_plan_group = self._clean_text(state.get("pending_plan_group")) + numeric_confirmation = self._parse_pending_plan_numeric_confirmation(text) + pending_multi_plan = None + if pending_plan_group and (numeric_confirmation > 0 or self._is_pending_plan_confirmation_text(text)): + pending_multi_plan = self._find_pending_multi_plan( + session=session, + session_id=cache_key, + rank=numeric_confirmation if numeric_confirmation > 0 else 1, + group_id=pending_plan_group, + ) + pending_plan = pending_multi_plan or self._find_workflow_plan(session=session, session_id=cache_key, executed=False) + if ( + pending_plan + and ( + self._is_pending_plan_confirmation_text(text) + or self._is_pending_plan_numeric_confirmation(text, pending_plan) + ) + and not self._clean_text(body.get("plan_id")) + ): + if isinstance(state.get("recommend_handoff"), dict) and state.get("recommend_handoff"): + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(pending_plan.get("plan_id")) if pending_multi_plan else "", + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_direct_intent = self._normalize_mp_recommend_direct_intent(text) + if recommend_direct_intent: + source_name, inferred_media_type = self._normalize_mp_recommend_request( + recommend_direct_intent.get("keyword") or "tmdb_trending" + ) + recommend_result = await self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + ) + if not recommend_result.get("success"): + return finish(recommend_result) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_direct_intent.get("index") or 1, + "action": recommend_direct_intent.get("action"), + "mode": recommend_direct_intent.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_short_direct = self._normalize_mp_recommend_short_action(text, state=state) + if recommend_short_direct: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_short_direct.get("index"), + "action": recommend_short_direct.get("action"), + "mode": recommend_short_direct.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_source_compound = self._normalize_recommend_source_compound_action(text, state=state) + if recommend_source_compound: + return finish(await self._assistant_run_recommend_source_compound( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(recommend_source_compound.get("mode")), + followup_action=self._clean_text(recommend_source_compound.get("followup_action")), + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + + hdhive_resource_short = self._normalize_hdhive_resource_short_action(text, state=state) + if hdhive_resource_short: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": hdhive_resource_short.get("index"), + "action": hdhive_resource_short.get("action"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + parsed = self._merge_assistant_structured_input(body, self._parse_assistant_text(text)) + target_path = parsed.get("path") or "" + + recommend_followup = self._normalize_mp_recommend_followup(text, state=state) + has_recommend_followup = bool(recommend_followup) + if self._clean_text((recommend_followup or {}).get("action")) == "mp_recommendations": + return finish(await self._assistant_mp_recommendations( + source=self._clean_text(recommend_followup.get("keyword") or state.get("requested_source") or state.get("source") or "tmdb_trending"), + media_type=self._clean_text(recommend_followup.get("type") or state.get("media_type") or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if recommend_followup: + for key, value in recommend_followup.items(): + if value not in {None, ""}: + parsed[key] = str(value) + preparsed_action = self._clean_text(parsed.get("action")) + recommend_handoff_short = self._normalize_recommend_handoff_short_action(text, state=state) + recommend_short = self._normalize_mp_recommend_short_action(text, state=state) + if preparsed_action not in {"ai_replay_failed_sample"} and recommend_short: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": recommend_short.get("index"), + "action": recommend_short.get("action"), + "mode": recommend_short.get("mode"), + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + pick_index, pick_path, pick_action, pick_mode = self._parse_pick_text(text) + handoff_route_action = self._clean_text(recommend_handoff_short.get("route_action")) + if not pick_index and handoff_route_action: + pick_action = handoff_route_action + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"} and (pick_index > 0 or pick_action): + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": pick_index, + "action": pick_action, + "mode": pick_mode, + "path": target_path or pick_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"}: + route_action = self._normalize_pick_action(text) + smart_route_action = self._normalize_smart_search_short_action(text, state_kind=state.get("kind")) + if smart_route_action: + route_action = smart_route_action + if handoff_route_action: + route_action = handoff_route_action + if route_action: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": 0, + "action": route_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not text and not any(parsed.get(key) for key in ["mode", "keyword", "url", "action"]): + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + + assistant_action = preparsed_action + ai_short_action = self._normalize_ai_reingest_short_action(text, state=state) + if not assistant_action and ai_short_action: + for key, value in ai_short_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + recommend_handoff_action = self._normalize_recommend_handoff_action(text, state=state) + if not assistant_action and recommend_handoff_action: + for key, value in recommend_handoff_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + if not assistant_action and self._clean_text(recommend_handoff_short.get("action")): + for key, value in recommend_handoff_short.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + keyword = self._clean_text(parsed.get("keyword")) + if assistant_action == "assistant_help": + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + if assistant_action == "execute_plan": + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(parsed.get("plan_id")), + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + if assistant_action == "plans_list": + include_actions = self._parse_bool_value(body.get("include_actions"), False) + executed = self._parse_optional_bool(body.get("executed")) + limit = self._safe_int(body.get("limit"), 10) + plans_data = self._assistant_plans_public_data( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + return finish({ + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_list", + "ok": True, + **plans_data, + }), + }) + if assistant_action == "plans_clear": + plan_id = self._clean_text(parsed.get("plan_id")) + if not plan_id: + return finish({ + "success": False, + "message": "取消或清理计划需要指定 plan_id,例如:取消计划 plan-xxxx。", + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": False, + "error_code": "missing_plan_id", + }), + }) + clear_result = self._clear_workflow_plans(plan_id=plan_id, limit=1) + return finish({ + "success": bool(clear_result.get("ok")), + "message": str(clear_result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": bool(clear_result.get("ok")), + **clear_result, + }), + }) + if assistant_action in {"preferences_get", "preferences_save", "preferences_reset"}: + if assistant_action == "preferences_save": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else self._parse_assistant_preferences_text(text) + if not preferences: + return finish({ + "success": False, + "message": ( + "保存偏好缺少可识别内容。示例:保存偏好 4K 杜比 HDR 中字 全集 " + "做种>=3 影巢积分20 不自动入库" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": False, + "error_code": "missing_preferences", + }), + }) + payload = { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "preferences": preferences, + "compact": compact, + "apikey": self._extract_apikey(request, body), + } + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, payload, method="POST"))) + if assistant_action == "preferences_reset": + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="DELETE"))) + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="GET"))) + if assistant_action == "scoring_policy": + return finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if assistant_action == "hdhive_checkin": + is_gambler = self._parse_bool_value(parsed.get("is_gambler"), self._hdhive_checkin_gambler_mode) + result = self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 智能入口") + data = result.get("data") if isinstance(result.get("data"), dict) else {} + status = data.get("status") or ("签到成功" if result.get("success") else "签到失败") + mode_text = "赌狗签到" if is_gambler else "普通签到" + summary = f"影巢{mode_text}:{status}\n{result.get('message') or ''}".strip() + return finish({ + "success": bool(result.get("success")), + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin", + "ok": bool(result.get("success")), + "is_gambler": is_gambler, + "status_summary": summary, + "result": data, + }), + }) + if assistant_action == "hdhive_checkin_history": + summary = self._format_hdhive_checkin_history_text(limit=10) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin_history", + "ok": True, + "status_summary": summary, + "history": self._hdhive_checkin_history_public_data(limit=10), + }), + }) + if assistant_action == "mp_media_detail": + if not keyword: + return finish({ + "success": False, + "message": "媒体识别失败:缺少片名。用法:识别 蜘蛛侠", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_mp_media_detail( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "auto"), + year=self._clean_text(body.get("year") or parsed.get("year")), + )) + if assistant_action == "mp_downloaders": + return finish(await self._assistant_mp_downloaders(session=session, cache_key=cache_key)) + if assistant_action == "mp_sites": + return finish(await self._assistant_mp_sites( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "active"), + name=keyword, + limit=self._safe_int(body.get("limit"), 30), + )) + if assistant_action == "mp_subscribes": + return finish(await self._assistant_mp_subscribes( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "all"), + name=keyword, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "mp_transfer_history": + return finish(await self._assistant_mp_transfer_history( + session=session, + cache_key=cache_key, + title=keyword, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_ingest_failures": + return finish(await self._assistant_mp_ingest_failures( + session=session, + cache_key=cache_key, + title=keyword, + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "ai_failed_samples": + return finish(await self._assistant_ai_failed_samples( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_worklist": + return finish(await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_insights": + return finish(await self._assistant_ai_sample_insights( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if assistant_action == "ai_replay_failed_sample": + sample_index = self._safe_int(parsed.get("sample_index") or body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(parsed.get("remove_if_resolved") or body.get("remove_if_resolved"), True) + return finish(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if assistant_action == "mp_subscribe_control": + control = self._clean_text(parsed.get("subscribe_control") or body.get("subscribe_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "搜索": "search", + "刷新": "search", + "search": "search", + "run": "search", + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + target = self._clean_text(keyword or body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if control not in {"search", "pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“订阅列表”,再发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”或“删除订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + }) + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id): + return finish({ + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return finish(await self._assistant_mp_subscribe_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if assistant_action == "mp_download_tasks": + return finish(await self._assistant_mp_download_tasks( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "downloading"), + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "mp_download_history": + return finish(await self._assistant_mp_download_history( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_lifecycle_status": + return finish(await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_ingest_status": + return finish(await self._assistant_mp_ingest_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_recent_activity": + return finish(await self._assistant_mp_recent_activity( + session=session, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only") or parsed.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only") or parsed.get("transfer_only"), False), + )) + if assistant_action == "smart_followup": + return finish(await self._assistant_smart_followup( + request, + session=session, + session_id=cache_key, + keyword=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "smart_decision_adjust": + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=self._clean_text(parsed.get("decision_adjust") or body.get("decision_adjust") or "decision_continue"), + )) + if assistant_action == "execution_followup": + return finish(await self._assistant_execution_followup( + request, + session=session, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id") or parsed.get("plan_id")), + )) + if assistant_action == "mp_local_diagnose": + return finish(await self._assistant_mp_local_diagnose( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_download_control": + control = self._clean_text(parsed.get("download_control") or body.get("download_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "开始": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + target = self._clean_text(keyword or body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if control not in {"pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“下载任务”,再发“暂停下载 1”“恢复下载 1”或“删除下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + }) + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return finish({ + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_task_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ))) + return finish(await self._assistant_mp_download_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + )) + if assistant_action == "mp_download_best": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + return finish(await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action == "mp_download": + choice = self._safe_int(parsed.get("keyword") or body.get("choice") or body.get("index"), 0) + if choice <= 0: + return finish({ + "success": False, + "message": "用法:下载资源 1", + "data": self._assistant_response_data(session=session, data={"action": "mp_download", "ok": False}), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ))) + return finish(await self._assistant_mp_download( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action in {"mp_subscribe", "mp_subscribe_search"}: + if not keyword: + return finish({ + "success": False, + "message": "用法:订阅媒体 片名 或 订阅并搜索 片名", + "data": self._assistant_response_data(session=session, data={"action": assistant_action, "ok": False}), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session, + cache_key=cache_key, + immediate_search=assistant_action == "mp_subscribe_search", + ))) + return finish(await self._assistant_mp_subscribe( + keyword=keyword, + session=session, + immediate_search=assistant_action == "mp_subscribe_search", + )) + if assistant_action == "mp_recommendations": + source, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or parsed.get("keyword") or text or "tmdb_trending" + ) + return finish(await self._assistant_mp_recommendations( + source=source, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if assistant_action == "return_to_recommend": + return finish(await self._assistant_restore_mp_recommendation_handoff( + session=session, + cache_key=cache_key, + state=state, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "switch_recommend_handoff_source": + return finish(await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(parsed.get("mode")), + )) + if assistant_action == "return_to_smart_decision": + return finish(await self._assistant_route_recommend_handoff_to_smart_decision( + request, + session=session, + cache_key=cache_key, + state=state, + )) + if assistant_action == "confirm_recommend_handoff": + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=target_path, + )) + if assistant_action == "p115_help": + summary = self._format_p115_help_text() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_help", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + } + if assistant_action == "p115_status": + summary = self._format_p115_status_summary() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_status", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }) + if assistant_action == "p115_pending": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + return { + "success": True, + "message": pending_summary, + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + if assistant_action == "quark_clear_default_dir": + bridge_result = self._assistant_quarkdisk_clear_directory(self._quark_default_path) + if bridge_result is None: + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + else: + clear_ok, clear_result, clear_message = bridge_result + if not clear_ok and isinstance(clear_result, dict) and clear_result.get("fallback_to_direct"): + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + target_path = self._clean_text((clear_result or {}).get("target_path")) or self._quark_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + failed_items = (clear_result or {}).get("failed_items") or [] + lines = [ + f"清空夸克默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if removed_count: + lines.append(f"已删除:{removed_count} 项") + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + if isinstance(failed_items, list) and failed_items: + names = [ + self._clean_text(item.get("name") if isinstance(item, dict) else item) + for item in failed_items[:5] + ] + names = [name for name in names if name] + if names: + lines.append("失败项:" + "、".join(names)) + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"夸克默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"夸克默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_clear_default_dir": + p115_service = self._ensure_p115_service() + clear_ok, clear_result, clear_message = p115_service.clear_directory(self._p115_default_path) + target_path = self._clean_text((clear_result or {}).get("path")) or self._p115_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + lines = [ + f"清空115默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"115 默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"115 默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_resume": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + summary = self._format_p115_status_summary() + return { + "success": False, + "message": f"当前没有待继续的 115 任务。\n{summary}", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{pending_summary}\n当前 115 还不可用,请先回复:115登录", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": resume_ok, "result": resume_data}), + } + if assistant_action == "p115_cancel": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + self._clear_pending_p115_share(cache_key) + return { + "success": True, + "message": f"{pending_summary}\n已取消并清除这次待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + if assistant_action == "p115_qrcode_start": + previous_state = state + client_type = P115TransferService.normalize_qrcode_client_type( + parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": f"115 扫码二维码生成失败:{qr_message}"} + self._save_session( + cache_key, + { + **previous_state, + "kind": "assistant_p115_login", + "stage": "qrcode", + "client_type": client_type, + "uid": self._clean_text(data.get("uid")), + "time": self._clean_text(data.get("time")), + "sign": self._clean_text(data.get("sign")), + }, + ) + pending_text = "" + if (previous_state.get("pending_p115") or {}).get("share_url"): + pending_text = "\n检测到有待继续的 115 任务,登录成功后我会自动继续执行。" + return { + "success": True, + "message": ( + "115 扫码二维码已生成\n" + f"客户端:{client_type}\n" + "请使用 115 App 扫码确认后,再回复:检查115登录" + f"{pending_text}" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_start", + "ok": True, + "qrcode": data.get("qrcode"), + "uid": data.get("uid"), + "time": data.get("time"), + "sign": data.get("sign"), + "client_type": client_type, + }), + } + if assistant_action == "p115_qrcode_check": + if not state or str(state.get("kind") or "").strip() != "assistant_p115_login": + pending_summary = self._pending_p115_summary(state) + if pending_summary and self._p115_status_snapshot().get("ready"): + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "没有待检查的扫码会话,但检测到待继续的 115 任务。" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,继续保留待处理状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": resume_ok, "result": resume_data}), + } + summary = self._format_p115_status_summary() + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": ( + "没有待检查的 115 登录会话。\n" + f"{summary}\n" + "如需重新扫码登录,请回复:115登录" + ), + "data": { + **self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_check", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }, + } + client_type = P115TransferService.normalize_qrcode_client_type( + state.get("client_type") or parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(state.get("uid")), + time_value=self._clean_text(state.get("time")), + sign=self._clean_text(state.get("sign")), + client_type=client_type, + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + self._save_session(cache_key, {**state, "stage": "success", "client_type": client_type}) + if not qr_ok: + return { + "success": False, + "message": f"115 扫码状态:{qr_message}", + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": False, **data}), + } + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"状态:{status or 'unknown'}", + f"结果:{qr_message}", + ] + if data.get("cookie_saved"): + lines.append(self._format_p115_status_summary(title="115 登录完成")) + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + if resume_message: + lines.append("已自动继续刚才未完成的 115 任务") + lines.append(resume_message) + data["resume_ok"] = resume_ok + data["resume_result"] = resume_data + if not resume_ok: + lines.append("任务仍未成功,已继续保留待处理状态。") + elif status in {"waiting", "scanned"}: + lines.append("如果还没确认登录,请在 115 App 里点确认后再次回复:检查115登录") + message_text = "\n".join(line for line in lines if line).strip() + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": True, **data}), + } + + if parsed.get("url"): + provider = "quark" if self._is_quark_url(parsed["url"]) else "115" if self._is_115_url(parsed["url"]) else "unknown" + result = await self.api_share_route( + _JsonRequestShim(request, { + "url": parsed["url"], + "access_code": parsed.get("access_code") or "", + "path": target_path, + "trigger": "Agent影视助手 智能入口", + "apikey": self._extract_apikey(request, body), + }) + ) + if provider == "115": + if result.get("success"): + self._clear_pending_p115_share(cache_key) + else: + self._save_pending_p115_share( + cache_key, + share_url=parsed["url"], + access_code=parsed.get("access_code") or "", + target_path=target_path or self._p115_default_path, + source="assistant_link", + last_error=str(result.get("message") or ""), + ) + return finish({ + "success": bool(result.get("success")), + "message": ( + f"{'夸克' if provider == 'quark' else '115' if provider == '115' else '分享'}转存已完成\n目录:" + f"{((result.get('data') or {}).get('result') or {}).get('target_path') or ((result.get('data') or {}).get('result') or {}).get('path') or target_path or '-'}" + if result.get("success") + else ( + f"{str(result.get('message') or '处理失败')}\n{self._format_p115_resume_hint()}" + if provider == "115" + else str(result.get("message") or "处理失败") + ) + ), + "data": self._assistant_response_data(session=session, data={ + "action": "share_route", + "ok": bool(result.get("success")), + "provider": provider, + "result": result.get("data") or {}, + }), + }) + + mode = parsed.get("mode") or "hdhive" + media_type = self._clean_text(parsed.get("type") or "auto").lower() or "auto" + year = self._clean_text(parsed.get("year")) + result_filter = self._clean_text(parsed.get("result_filter")).lower() + decision_intent = self._clean_text(parsed.get("decision_intent")).lower() + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else None + if not source_order: + parsed_source_order_text = self._clean_text(parsed.get("source_order_text")) + if parsed_source_order_text: + source_order = [ + self._clean_text(item).lower() + for item in parsed_source_order_text.split(",") + if self._clean_text(item) + ] + origin = self._clean_text(body.get("origin")) + recommend_handoff = body.get("recommend_handoff") if isinstance(body.get("recommend_handoff"), dict) else {} + apikey = self._extract_apikey(request, body) + session_preference_overrides: Dict[str, Any] = {} + cloud_provider = self._clean_text(parsed.get("cloud_provider") or body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider == "quark": + session_preference_overrides.update({ + "has_quark": True, + "has_115": False, + "prefer_cloud_provider": "quark", + }) + elif cloud_provider == "115": + session_preference_overrides.update({ + "has_quark": False, + "has_115": True, + "prefer_cloud_provider": "115", + }) + + if mode == "update": + return finish(await self._assistant_update_check( + keyword=keyword, + session=session, + cache_key=cache_key, + year=year, + )) + + if mode == "mp_download_title": + if not keyword: + return finish({ + "success": False, + "message": "用法:下载 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_title", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "mp_download_title", + "label": "生成待确认下载计划", + "result_filter": result_filter, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + + if mode == "smart": + if not keyword: + return finish({ + "success": False, + "message": "用法:智能搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_search", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + )) + + if mode == "cloud_transfer_execute": + if not keyword: + return finish({ + "success": False, + "message": "用法:转存 片名;也支持:夸克转存 片名 / 115转存 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_transfer_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + pending_label = "云盘转存" + if cloud_provider == "quark": + pending_label = "夸克转存" + elif cloud_provider == "115": + pending_label = "115转存" + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "cloud_transfer_execute", + "cloud_provider": cloud_provider, + "label": pending_label, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + return finish(await self._assistant_cloud_transfer_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin or cloud_provider or "cloud_transfer", + )) + + if mode == "smart_decision": + if not keyword: + return finish({ + "success": False, + "message": "用法:资源决策 片名;也支持:智能决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + origin=origin, + )) + + if mode == "smart_plan": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能计划 片名;也可以先做“智能搜索 片名”,再回复“计划最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "smart_execute": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能执行 片名;也可以先做“智能搜索 片名”,再回复“执行最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "mp": + if not keyword: + return finish({ + "success": False, + "message": "用法:MP搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "media_search", + "ok": False, + }), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={"mode": "mp", "result_filter": result_filter} if result_filter else None, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + if candidate_year and candidate_year not in candidate_title: + keyword = f"{candidate_title} {candidate_year}" + else: + keyword = candidate_title + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + mp_items = (result.get("data") or {}).get("items") or [] + if (not result.get("success") or not mp_items) and keyword: + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="mp_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("MP/PT 当前暂无可用结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + search_ok, hdhive_result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if search_ok: + candidates = hdhive_result.get("candidates") or [] + if candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="MP/PT 当前暂无可用结果,已自动补查影巢。", + )) + if result.get("success") and recommend_handoff: + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, {**current_state, "recommend_handoff": dict(recommend_handoff)}) + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(self._load_session(cache_key) or {})) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(self._load_session(cache_key) or {}) + result["data"] = self._assistant_response_data(session=session, data=result_data) + return finish(result) + + if mode == "pansou" or mode == "cloud": + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if not search_ok: + if mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜搜索失败:{keyword}\n错误:{search_message}"} + search_ok = False + payload = {} + search_message = search_message + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + if mode == "cloud" and not items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = local_index + hdhive_candidate = dict(chosen_candidate) + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=[], + pansou_total=int(data.get("total") or 0), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if not items and mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜暂无结果:{keyword}"} + if items and mode == "cloud": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_cloud_result_page_size) + + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = len(items) + local_index + hdhive_candidate = dict(chosen_candidate) + + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=items, + pansou_total=int(data.get("total") or len(items)), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=items, + total=int(data.get("total") or len(items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search" if mode == "pansou" else "cloud_search", + search_scope="pansou" if mode == "pansou" else "pansou_then_hdhive", + recommend_handoff=recommend_handoff, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + if mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return {"success": False, "message": f"影巢搜索失败:{search_message}", "data": result} + candidates = result.get("candidates") or [] + if not candidates and mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + )) + + async def api_assistant_action(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + name = self._clean_text(body.get("name") or body.get("action_name")) + if not name: + return {"success": False, "message": "缺少动作名 name"} + compact = self._parse_bool_value(body.get("compact"), False) + + async def finish(awaitable): + result = await awaitable + return self._assistant_single_action_compact_response(name, result) if compact else result + + async def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + route_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + pick_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + + if name == "start_pansou_search": + route_payload.update({ + "mode": "pansou", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_search": + route_payload.update({ + "mode": "smart", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_decision": + route_payload.update({ + "mode": "smart_decision", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + "decision_profile": body.get("decision_profile"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_plan": + route_payload.update({ + "mode": "smart_plan", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_execute": + route_payload.update({ + "mode": "smart_execute", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_hdhive_search": + route_payload.update({ + "mode": "hdhive", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_mp_media_search": + route_payload.update({ + "mode": "mp", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "query_mp_media_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_media_detail( + keyword=self._clean_text(body.get("keyword") or body.get("title")), + session=session_name, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or "auto"), + year=self._clean_text(body.get("year")), + )) + if name == "query_mp_search_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_result_detail( + choice=self._safe_int(body.get("choice") or body.get("index"), 0), + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_best_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_result_detail( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_best_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_download_plan( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + choice = self._safe_int(body.get("choice") or body.get("index"), 0) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + ))) + return await finish(self._assistant_mp_download( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_download_tasks": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_tasks( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "downloading"), + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_mp_download_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_lifecycle_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_ingest_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_downloaders": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_downloaders(session=session_name, cache_key=cache_key)) + if name == "query_mp_sites": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_sites( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "active"), + name=self._clean_text(body.get("site_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 30), + )) + if name == "query_mp_subscribes": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_subscribes( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or "all"), + name=self._clean_text(body.get("subscribe_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 20), + )) + if name == "query_mp_ingest_failures": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_failures( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_ai_failed_samples": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_failed_samples( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_worklist": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_worklist( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_insights": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_insights( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if name == "replay_ai_failed_sample": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(body.get("remove_if_resolved"), True) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + ))) + return await finish(self._assistant_ai_replay_failed_sample( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if name == "query_mp_transfer_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_transfer_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + status=self._clean_text(body.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_recent_activity": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only"), False), + )) + if name == "query_mp_local_diagnose": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_local_diagnose( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_execution_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id")), + )) + if name == "query_smart_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_smart_followup( + request, + session=session_name, + session_id=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "mp_subscribe_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return await finish(self._assistant_mp_subscribe_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if name == "mp_download_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + downloader = self._clean_text(body.get("downloader")) + delete_files = self._parse_bool_value(body.get("delete_files"), False) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + downloader=downloader, + delete_files=delete_files, + ))) + return await finish(self._assistant_mp_download_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + downloader=downloader, + delete_files=delete_files, + )) + if name in {"start_mp_subscribe", "start_mp_subscribe_search"}: + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if not keyword: + state = self._load_session(cache_key) or {} + keyword = self._clean_text(state.get("keyword")) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session_name, + cache_key=cache_key, + immediate_search=name == "start_mp_subscribe_search", + ))) + return await finish(self._assistant_mp_subscribe( + keyword=keyword, + session=session_name, + immediate_search=name == "start_mp_subscribe_search", + )) + if name == "start_mp_recommendations": + recommend_session, recommend_cache_key = self._normalize_assistant_session_ref( + session=self._clean_text(body.get("session")) or "default", + session_id=body.get("session_id"), + ) + source_name, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or "tmdb_trending" + ) + return await finish(self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type) or "all", + limit=self._safe_int(body.get("limit"), 20), + session=recommend_session, + cache_key=recommend_cache_key, + )) + if name == "pick_recommend_search": + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "mode": body.get("mode") or body.get("search_mode") or "mp", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"preferences_get", "preferences_save", "preferences_reset"}: + method = "GET" if name == "preferences_get" else "DELETE" if name == "preferences_reset" else "POST" + payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "user_key": body.get("user_key"), + "preferences": body.get("preferences") or {}, + "compact": body.get("compact", True), + "apikey": self._extract_apikey(request, body), + } + return await finish(self.api_assistant_preferences(_JsonRequestShim(request, payload, method=method))) + if name in {"scoring_policy", "query_scoring_policy"}: + session_name = self._clean_text(body.get("session")) or "default" + return await finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if name == "start_115_login": + route_payload.update({ + "action": "p115_qrcode_start", + "client_type": body.get("client_type"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "route_share": + route_payload.update({ + "url": body.get("url") or body.get("share_url"), + "access_code": body.get("access_code"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "unlock_hdhive_resource": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + resource = body.get("resource") if isinstance(body.get("resource"), dict) else {} + slug = self._clean_text(body.get("slug") or resource.get("slug")) + final_path = self._resolve_pan_path_value( + self._clean_text(body.get("path") or body.get("target_path")) + ) or self._hdhive_default_path + if not slug: + return await finish(immediate({ + "success": False, + "message": "影巢解锁动作缺少 slug", + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "error_code": "missing_slug", + }), + })) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_plan", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return await finish(immediate({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "selected_resource": resource, + "result": route_result, + }), + })) + return await finish(immediate({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + })) + if name == "inspect_session_state": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"execute_latest_plan", "execute_plan", "execute_session_latest_plan"}: + return await finish(self.api_assistant_plan_execute(_JsonRequestShim(request, { + "plan_id": body.get("plan_id"), + "session": body.get("session"), + "session_id": body.get("session_id"), + "prefer_unexecuted": body.get("prefer_unexecuted", True), + "stop_on_error": body.get("stop_on_error", True), + "include_raw_results": body.get("include_raw_results", False), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"pick_pansou_result", "pick_hdhive_candidate", "pick_hdhive_resource"}: + pick_payload.update({"choice": body.get("choice") or body.get("index")}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"plan_pansou_result", "plan_hdhive_resource", "plan_pick_result"}: + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "action": "plan", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_detail": + pick_payload.update({"action": "detail"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_next_page": + pick_payload.update({"action": "next_page"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "check_115_login": + route_payload.update({"action": "p115_qrcode_check"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "show_115_status": + route_payload.update({"action": "p115_status"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "resume_pending_115": + route_payload.update({"action": "p115_resume"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "cancel_pending_115": + route_payload.update({"action": "p115_cancel"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "clear_current_session": + return await finish(self.api_assistant_session_clear(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "inspect_session": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_session_by_id": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_stale_sessions": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "stale_only": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_executed_plans": + return await finish(self.api_assistant_plans_clear(_JsonRequestShim(request, { + "executed": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + + return {"success": False, "message": f"不支持的动作模板:{name}"} + + @staticmethod + def _assistant_result_message_head(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.splitlines()[0][:200] + + def _assistant_action_result_summary( + self, + *, + index: int, + name: str, + result: Dict[str, Any], + ) -> Dict[str, Any]: + data = dict((result or {}).get("data") or {}) + session_state = dict(data.get("session_state") or {}) + if not session_state and ( + "has_session" in data + or "kind" in data + or "stage" in data + or "suggested_actions" in data + ): + session_state = dict(data) + summary = { + "index": index, + "name": self._clean_text(name), + "success": bool((result or {}).get("success")), + "action": self._clean_text(data.get("action")) or self._clean_text(name), + "ok": bool(data.get("ok")) if "ok" in data else bool((result or {}).get("success")), + "message_head": self._assistant_result_message_head((result or {}).get("message")), + "session": self._clean_text(data.get("session") or session_state.get("session")), + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "has_pending_p115": bool(((session_state.get("pending_p115") or {}).get("has_pending"))), + } + if isinstance(data.get("score_summary"), dict): + summary["score_summary"] = data.get("score_summary") + if isinstance(data.get("diagnosis_summary"), dict): + summary["diagnosis_summary"] = data.get("diagnosis_summary") + return summary + + async def api_assistant_actions(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + actions = body.get("actions") or [] + if not isinstance(actions, list) or not actions: + return {"success": False, "message": "缺少 actions 数组"} + + apikey = self._extract_apikey(request, body) + requested_count = min(len(actions), 20) + stop_on_error = self._parse_bool_value(body.get("stop_on_error"), True) + include_raw_results = self._parse_bool_value(body.get("include_raw_results"), False) + compact = self._parse_bool_value(body.get("compact"), False) + batch_session = self._clean_text(body.get("session")) or "default" + batch_session_id = self._clean_text(body.get("session_id")) + + summaries: List[Dict[str, Any]] = [] + raw_results: List[Dict[str, Any]] = [] + halted = False + halted_at = 0 + + for idx, item in enumerate(actions[:requested_count], 1): + payload = dict(item or {}) if isinstance(item, dict) else {"name": self._clean_text(item)} + if not payload.get("session") and batch_session: + payload["session"] = batch_session + if not payload.get("session_id") and batch_session_id: + payload["session_id"] = batch_session_id + if "execute" not in payload and "execute" in body: + payload["execute"] = body.get("execute") + if apikey and not payload.get("apikey") and not payload.get("api_key"): + payload["apikey"] = apikey + action_name = self._clean_text(payload.get("name") or payload.get("action_name")) + result = await self.api_assistant_action(_JsonRequestShim(request, payload)) + summaries.append(self._assistant_action_result_summary(index=idx, name=action_name, result=result)) + if include_raw_results: + raw_results.append(result) + if not result.get("success") and stop_on_error: + halted = True + halted_at = idx + break + + final_session = batch_session + final_session_id = batch_session_id + if summaries: + final_session = self._clean_text(summaries[-1].get("session")) or final_session + final_session_id = self._clean_text(summaries[-1].get("session_id")) or final_session_id + session_name, _ = self._normalize_assistant_session_ref(session=final_session, session_id=final_session_id) + + success = bool(summaries) and all(item.get("success") for item in summaries) + if halted: + success = False + + message_lines = [ + f"批量动作执行完成:{len(summaries)}/{requested_count} 步", + f"成功:{len([item for item in summaries if item.get('success')])} 步", + ] + if halted: + message_lines.append(f"已在第 {halted_at} 步停止") + else: + message_lines.append("已按顺序执行完毕") + if summaries: + last_head = self._clean_text(summaries[-1].get("message_head")) + if last_head: + message_lines.append(f"最后结果:{last_head}") + + data = { + "action": "execute_actions", + "ok": success, + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + } + if include_raw_results: + data["raw_results"] = raw_results + self._record_assistant_execution( + action=self._clean_text(body.get("workflow")) or "execute_actions", + session=session_name, + success=success, + message="\n".join(message_lines), + summary={ + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + }, + ) + full_data = self._assistant_response_data(session=session_name, data=data) + return { + "success": success, + "message": "\n".join(message_lines), + "data": self._assistant_actions_compact_data(full_data) if compact else full_data, + } + + @staticmethod + def _assistant_workflow_catalog() -> Dict[str, Any]: + return { + "workflows": [ + { + "name": "pansou_search", + "description": "按关键词执行盘搜,只返回候选结果并保留会话", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "smart_resource_search", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只读返回推荐结果", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_decision", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并返回查看详情、生成计划或直接执行的统一建议。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "decision_profile", "path", "compact"], + }, + { + "name": "smart_resource_plan", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并为当前首选生成待确认 plan_id", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_execute", + "description": "按偏好自动搜索当前首选,并立即执行对应写入动作;仅适用于用户已明确要求直接执行的场景。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "pansou_transfer", + "description": "按关键词盘搜并直接选择指定编号转存,choice 默认 1", + "fields": ["session", "keyword", "choice", "path", "compact"], + }, + { + "name": "hdhive_candidates", + "description": "按关键词搜索影巢候选影片,等待下一步选片", + "fields": ["session", "keyword", "media_type", "year", "path", "compact"], + }, + { + "name": "hdhive_unlock", + "description": "按关键词搜索影巢,选择候选影片,再选择资源解锁落盘", + "fields": ["session", "keyword", "candidate_choice", "resource_choice", "media_type", "year", "path", "compact"], + }, + { + "name": "mp_search", + "description": "执行 MP 原生搜索,返回 PT 候选结果和评分摘要", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_media_detail", + "description": "使用 MoviePilot 原生识别确认媒体详情、年份、类型和 TMDB/Douban/IMDB ID", + "fields": ["session", "keyword", "media_type", "year", "compact"], + }, + { + "name": "mp_search_detail", + "description": "执行 MP 原生搜索并按编号查看 PT 详情与评分理由,只读", + "fields": ["session", "keyword", "choice", "compact"], + }, + { + "name": "mp_search_best", + "description": "执行 MP 原生搜索并查看当前评分最高的 PT 候选详情,只读", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_search_download", + "description": "执行 MP 原生搜索并按编号下载,默认先生成 plan_id", + "fields": ["session", "keyword", "choice", "compact", "dry_run"], + }, + { + "name": "mp_download_tasks", + "description": "查询 MP 下载任务状态,可按 status/title/hash/downloader 过滤", + "fields": ["session", "status", "title", "hash", "downloader", "limit", "compact"], + }, + { + "name": "mp_download_history", + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态,只读", + "fields": ["session", "keyword", "hash", "limit", "page", "compact"], + }, + { + "name": "mp_lifecycle_status", + "description": "聚合查询 MP 下载任务、下载历史和整理/入库历史,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_ingest_status", + "description": "按片名或 hash 判断当前处于下载中、已下载、整理中、已入库还是失败阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_downloaders", + "description": "查询 MP 下载器配置摘要,不返回敏感字段", + "fields": ["session", "compact"], + }, + { + "name": "mp_sites", + "description": "查询 MP 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文", + "fields": ["session", "status", "keyword", "limit", "compact"], + }, + { + "name": "mp_download_control", + "description": "暂停、恢复或删除 MP 下载任务,默认先生成 plan_id", + "fields": ["session", "control", "target", "downloader", "delete_files", "compact", "dry_run"], + }, + { + "name": "mp_subscribe", + "description": "按关键词创建 MP 订阅,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribe_and_search", + "description": "创建订阅并立即触发搜索,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribes", + "description": "查询 MP 订阅列表,可按 status/media_type/keyword 过滤", + "fields": ["session", "status", "media_type", "keyword", "limit", "compact"], + }, + { + "name": "mp_subscribe_control", + "description": "触发订阅搜索、暂停、恢复或删除订阅,默认先生成 plan_id", + "fields": ["session", "control", "target", "compact", "dry_run"], + }, + { + "name": "mp_transfer_history", + "description": "查询 MP 最近整理/入库历史,可按标题和成功/失败状态过滤,只读", + "fields": ["session", "keyword", "status", "limit", "page", "compact"], + }, + { + "name": "mp_ingest_failures", + "description": "聚合查看最近整理/入库失败记录,只读", + "fields": ["session", "keyword", "limit", "page", "compact"], + }, + { + "name": "ai_failed_samples", + "description": "读取 AI 识别增强插件保存的失败样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_worklist", + "description": "读取 AI 失败样本工作清单,适合先挑需要复查的样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_insights", + "description": "读取 AI 失败样本洞察,查看主要失败原因与优先处理项,只读", + "fields": ["session", "keyword", "limit", "top", "compact"], + }, + { + "name": "ai_replay_failed_sample", + "description": "对指定 AI 失败样本执行二次识别重放;默认只生成 plan_id,确认后执行", + "fields": ["session", "sample_index", "remove_if_resolved", "compact", "confirmed"], + }, + { + "name": "mp_recent_activity", + "description": "查看最近下载和最近整理/入库活动,只读", + "fields": ["session", "limit", "download_only", "transfer_only", "compact"], + }, + { + "name": "mp_local_diagnose", + "description": "一站式诊断为什么还没入库或当前卡在什么阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "execution_followup", + "description": "按最近已执行计划自动查询对应的下载、订阅或入库后续状态,只读", + "fields": ["session", "session_id", "plan_id", "compact"], + }, + { + "name": "smart_followup", + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动,只读", + "fields": ["session", "session_id", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_recommend", + "description": "读取 MP 原生推荐,例如 TMDB、豆瓣、Bangumi", + "fields": ["session", "source", "media_type", "limit", "compact"], + }, + { + "name": "mp_recommend_search", + "description": "读取 MP 推荐并按编号继续搜索;mode 可选 mp / hdhive / pansou,传 keyword 时直接 MP 搜索", + "fields": ["session", "source", "keyword", "choice", "mode", "media_type", "limit", "compact"], + }, + { + "name": "share_transfer", + "description": "识别 115 或夸克分享链接并直接转存", + "fields": ["session", "url", "access_code", "path", "compact"], + }, + { + "name": "p115_login_start", + "description": "发起 115 扫码登录", + "fields": ["session", "client_type", "compact"], + }, + { + "name": "p115_status", + "description": "查看 115 当前可用状态", + "fields": ["session", "compact"], + }, + ] + } + + def _assistant_workflow_actions(self, name: str, body: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: + workflow_name = self._clean_text(name).lower() + session = self._clean_text(body.get("session")) or "default" + session_id = self._clean_text(body.get("session_id")) + path = self._clean_text(body.get("path") or body.get("target_path")) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or "auto") + year = self._clean_text(body.get("year")) + source = self._clean_text(body.get("source")) or "tmdb_trending" + limit = self._safe_int(body.get("limit"), 20) + + def base(payload: Dict[str, Any]) -> Dict[str, Any]: + current = dict(payload) + current.setdefault("session", session) + if session_id: + current.setdefault("session_id", session_id) + if path and "path" not in current: + current["path"] = path + return current + + if workflow_name == "pansou_search": + if not keyword: + return [], "pansou_search 缺少 keyword" + return [base({"name": "start_pansou_search", "keyword": keyword})], "" + + if workflow_name == "smart_resource_search": + if not keyword: + return [], "smart_resource_search 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_decision": + if not keyword: + return [], "smart_resource_decision 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_decision", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + "decision_profile": body.get("decision_profile"), + })], "" + + if workflow_name == "smart_resource_plan": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_plan", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_execute": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_execute", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "pansou_transfer": + if not keyword: + return [], "pansou_transfer 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_pansou_search", "keyword": keyword}), + base({"name": "pick_pansou_result", "choice": max(1, choice)}), + ], "" + + if workflow_name == "hdhive_candidates": + if not keyword: + return [], "hdhive_candidates 缺少 keyword" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }) + ], "" + + if workflow_name == "hdhive_unlock": + if not keyword: + return [], "hdhive_unlock 缺少 keyword" + candidate_choice = self._safe_int(body.get("candidate_choice") or body.get("choice"), 0) + resource_choice = self._safe_int(body.get("resource_choice"), 0) + if candidate_choice <= 0 or resource_choice <= 0: + return [], "hdhive_unlock 需要 candidate_choice 和 resource_choice" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }), + base({"name": "pick_hdhive_candidate", "choice": candidate_choice}), + base({"name": "pick_hdhive_resource", "choice": resource_choice}), + ], "" + + if workflow_name == "mp_search": + if not keyword: + return [], "mp_search 缺少 keyword" + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + + if workflow_name == "mp_media_detail": + if not keyword: + return [], "mp_media_detail 缺少 keyword" + return [base({ + "name": "query_mp_media_detail", + "keyword": keyword, + "media_type": media_type, + "year": year, + })], "" + + if workflow_name == "mp_search_detail": + if not keyword: + return [], "mp_search_detail 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_search_result_detail", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_search_best": + if not keyword: + return [], "mp_search_best 缺少 keyword" + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_best_result_detail"}), + ], "" + + if workflow_name == "mp_search_download": + if not keyword: + return [], "mp_search_download 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "pick_mp_download", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_download_tasks": + return [base({ + "name": "query_mp_download_tasks", + "status": self._clean_text(body.get("status")) or "downloading", + "title": self._clean_text(body.get("title") or body.get("keyword")), + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "downloader": self._clean_text(body.get("downloader")), + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "mp_download_history": + return [base({ + "name": "query_mp_download_history", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_lifecycle_status": + return [base({ + "name": "query_mp_lifecycle_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_ingest_status": + return [base({ + "name": "query_mp_ingest_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_downloaders": + return [base({"name": "query_mp_downloaders"})], "" + + if workflow_name == "mp_sites": + return [base({ + "name": "query_mp_sites", + "status": self._clean_text(body.get("status")) or "active", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 30), + })], "" + + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_download_control 需要 control 和 target" + return [base({ + "name": "mp_download_control", + "control": control, + "target": target, + "downloader": self._clean_text(body.get("downloader")), + "delete_files": self._parse_bool_value(body.get("delete_files"), False), + })], "" + + if workflow_name == "mp_subscribe": + if not keyword: + return [], "mp_subscribe 缺少 keyword" + return [base({"name": "start_mp_subscribe", "keyword": keyword})], "" + + if workflow_name == "mp_subscribe_and_search": + if not keyword: + return [], "mp_subscribe_and_search 缺少 keyword" + return [base({"name": "start_mp_subscribe_search", "keyword": keyword})], "" + + if workflow_name == "mp_subscribes": + return [base({ + "name": "query_mp_subscribes", + "status": self._clean_text(body.get("status")) or "all", + "media_type": self._clean_text(body.get("media_type") or body.get("type")) or "all", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + })], "" + + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_subscribe_control 需要 control 和 target" + return [base({ + "name": "mp_subscribe_control", + "control": control, + "target": target, + })], "" + + if workflow_name == "mp_transfer_history": + return [base({ + "name": "query_mp_transfer_history", + "keyword": keyword, + "status": self._clean_text(body.get("status")) or "all", + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_ingest_failures": + return [base({ + "name": "query_mp_ingest_failures", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "ai_failed_samples": + return [base({ + "name": "query_ai_failed_samples", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_worklist": + return [base({ + "name": "query_ai_sample_worklist", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_insights": + return [base({ + "name": "query_ai_sample_insights", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + "top": self._safe_int(body.get("top"), 5), + })], "" + + if workflow_name == "ai_replay_failed_sample": + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index <= 0: + return [], "ai_replay_failed_sample 需要 sample_index" + return [base({ + "name": "replay_ai_failed_sample", + "sample_index": sample_index, + "remove_if_resolved": self._parse_bool_value(body.get("remove_if_resolved"), True), + })], "" + + if workflow_name == "mp_recent_activity": + return [base({ + "name": "query_mp_recent_activity", + "limit": self._safe_int(body.get("limit"), 10), + "download_only": self._parse_bool_value(body.get("download_only"), False), + "transfer_only": self._parse_bool_value(body.get("transfer_only"), False), + })], "" + + if workflow_name == "mp_local_diagnose": + return [base({ + "name": "query_mp_local_diagnose", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "execution_followup": + return [base({ + "name": "query_execution_followup", + "plan_id": self._clean_text(body.get("plan_id")), + })], "" + + if workflow_name == "smart_followup": + return [base({ + "name": "query_smart_followup", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_recommend": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "smart_discovery": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "mp_recommend_search": + if keyword: + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + actions = [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ] + choice = self._safe_int(body.get("choice"), 0) + if choice > 0: + actions.append(base({ + "name": "pick_recommend_search", + "choice": choice, + "mode": self._clean_text(body.get("mode")) or "mp", + })) + return actions, "" + + if workflow_name == "share_transfer": + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if not share_url: + return [], "share_transfer 缺少 url" + return [ + base({ + "name": "route_share", + "url": share_url, + "access_code": self._clean_text(body.get("access_code")), + }) + ], "" + + if workflow_name == "p115_login_start": + return [ + base({ + "name": "start_115_login", + "client_type": self._clean_text(body.get("client_type")), + }) + ], "" + + if workflow_name == "p115_status": + return [base({"name": "show_115_status"})], "" + + return [], f"不支持的工作流:{name}" + + async def api_assistant_workflow(self, request: Request): + if request.method.upper() == "GET": + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + return { + "success": True, + "message": "Agent影视助手 预设工作流目录", + "data": self._assistant_response_data(session="default", data={ + "action": "workflow_catalog", + "ok": True, + **self._assistant_workflow_catalog(), + }), + } + + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + workflow_name = self._clean_text(body.get("name") or body.get("workflow")) + compact = self._parse_bool_value(body.get("compact"), False) + if not workflow_name: + return {"success": False, "message": "缺少工作流名 name"} + actions, build_error = self._assistant_workflow_actions(workflow_name, body) + if build_error: + return {"success": False, "message": build_error} + + session = self._clean_text(body.get("session")) or "default" + write_workflows = { + "pansou_transfer", + "hdhive_unlock", + "share_transfer", + "ai_replay_failed_sample", + "mp_search_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_and_search", + } + dry_run = self._parse_bool_value( + body.get("dry_run"), + self._clean_text(workflow_name).lower() in write_workflows, + ) + if dry_run: + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + result = self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ) + if not result.get("success"): + return result + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_id = self._parse_bool_value(body.get("allow_raw_id"), False) + result = self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + allow_raw_id=allow_raw_id, + ) + if not result.get("success"): + return result + if workflow_name == "ai_replay_failed_sample": + result = self._assistant_ai_replay_sample_plan_response( + sample_index=self._safe_int(body.get("sample_index") or body.get("index"), 0), + session=session, + cache_key=self._clean_text(body.get("session_id")), + remove_if_resolved=self._parse_bool_value(body.get("remove_if_resolved"), True), + ) + if not result.get("success"): + return result + return self._assistant_workflow_plan_response_compact(result) if compact else result + execute_body = { + **{key: value for key, value in body.items() if key not in {"apikey", "dry_run"}}, + "dry_run": False, + } + plan = self._save_workflow_plan( + workflow=workflow_name, + session=session, + session_id=self._clean_text(body.get("session_id")), + actions=actions, + execute_body=execute_body, + ) + full_data = self._assistant_response_data(session=session, data={ + "action": "workflow_plan", + "ok": True, + "plan_id": plan.get("plan_id"), + "workflow": workflow_name, + "dry_run": True, + "workflow_actions": actions, + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan.get("plan_id")}, + "execute_body": execute_body, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + }) + return { + "success": True, + "message": f"工作流 {workflow_name} 计划已生成:{plan.get('plan_id')},共 {len(actions)} 步,未实际执行。", + "data": self._assistant_workflow_plan_compact_data(full_data) if compact else full_data, + } + + result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": self._clean_text(body.get("session_id")), + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + data = dict(result.get("data") or {}) + data["workflow"] = workflow_name + if not compact: + data["workflow_actions"] = actions + return { + "success": bool(result.get("success")), + "message": f"工作流 {workflow_name} 执行完成\n{result.get('message') or ''}".strip(), + "data": data, + } + + async def api_assistant_plan_execute(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + plan_id = self._clean_text(body.get("plan_id")) + session = self._clean_text(body.get("session")) + session_id = self._clean_text(body.get("session_id")) + prefer_unexecuted = self._parse_bool_value(body.get("prefer_unexecuted"), True) + compact = self._parse_bool_value(body.get("compact"), False) + plan = self._find_workflow_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=False if prefer_unexecuted and not plan_id else None, + ) + if not plan and not plan_id and (session or session_id) and prefer_unexecuted: + plan = self._find_workflow_plan( + session=session, + session_id=session_id, + executed=None, + ) + if not plan: + result = { + "success": False, + "message": f"计划不存在或已过期:{plan_id}" if plan_id else "没有匹配到可执行计划,请先生成 dry_run 计划或改传 plan_id。", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_not_found", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + plan_id = self._clean_text(plan.get("plan_id")) + + actions = plan.get("actions") or [] + if not isinstance(actions, list) or not actions: + result = { + "success": False, + "message": f"计划没有可执行动作:{plan_id}", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_has_no_actions", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + workflow_name = self._clean_text(plan.get("workflow")) or "saved_plan" + session = self._clean_text(plan.get("session")) or "default" + session_id = self._clean_text(plan.get("session_id")) + action_result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": session_id, + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": False, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + + executed_at = int(time.time()) + plan.update({ + "executed": True, + "executed_at": executed_at, + "executed_at_text": self._format_unix_time(executed_at), + "last_success": bool(action_result.get("success")), + "last_message": self._assistant_result_message_head(action_result.get("message")), + }) + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + + data = dict(action_result.get("data") or {}) + data.update({ + "action": "execute_plan", + "plan_id": plan_id, + "workflow": workflow_name, + "plan_auto_selected": not bool(self._clean_text(body.get("plan_id"))), + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "plan_executed_at": executed_at, + "plan_executed_at_text": plan.get("executed_at_text"), + }) + followup_state = dict(data.get("session_state") or {}) if isinstance(data.get("session_state"), dict) else {} + if isinstance(plan.get("execute_body"), dict): + followup_state["plan_execute_body"] = dict(plan.get("execute_body") or {}) + followup = self._assistant_plan_execute_followup( + workflow=workflow_name, + session=session, + session_id=session_id, + session_state=followup_state, + ok=bool(action_result.get("success")), + plan_id=plan_id, + ) + data["recommended_action"] = self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")) + data["follow_up_hint"] = self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")) + data["followup_summary"] = followup.get("followup_summary") or {} + data["next_actions"] = self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or [], + ) + data["action_templates"] = self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ) + if not compact: + data["workflow_actions"] = actions + message_lines = [ + f"计划 {plan_id} 执行完成", + self._clean_text(action_result.get("message")), + ] + if data.get("recommended_action"): + message_lines.append(f"推荐动作:{data.get('recommended_action')}") + if data.get("follow_up_hint"): + message_lines.append(f"下一步:{data.get('follow_up_hint')}") + message_lines.extend(self._format_followup_summary_lines(data.get("followup_summary"))) + result = { + "success": bool(action_result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": data, + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + async def api_assistant_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + raw_action_text = self._clean_text(body.get("action") or body.get("pick_action")) + action = self._normalize_pick_action(raw_action_text) + target_path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + compact = self._parse_bool_value(body.get("compact"), False) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + state = self._load_session(cache_key) + if not state: + return {"success": False, "message": "没有可继续的缓存,请先发起搜索或发送分享链接。"} + if index <= 0 and not action: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + + kind = str(state.get("kind") or "").strip() + smart_short_action = self._normalize_smart_search_short_action(raw_action_text, state_kind=kind) + if smart_short_action: + action = smart_short_action + if kind == "assistant_cloud": + pansou_items = state.get("pansou_items") or [] + hdhive_resources = state.get("hdhive_resources") or [] + hdhive_candidate = dict(state.get("hdhive_candidate") or {}) + hdhive_candidates = state.get("hdhive_candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + pansou_count = len(pansou_items) + total_count = pansou_count + len(hdhive_resources) + if action == "next_page": + total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_cloud_text( + keyword=self._clean_text(state.get("keyword")), + pansou_items=pansou_items, + pansou_total=self._safe_int(state.get("pansou_total"), pansou_count), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best" and pansou_items: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + "total": self._safe_int(state.get("pansou_total"), len(pansou_items)), + "page": max(1, self._safe_int(state.get("page"), 1)), + "page_size": page_size, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + if index <= 0: + if action: + return {"success": False, "message": "云盘搜索结果需要编号,例如:选择 1 或 选择 12 详情。"} + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > total_count: + return {"success": False, "message": f"序号超出范围,请输入 1 到 {total_count} 之间的数字。"} + if index <= pansou_count: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + local_index = index - pansou_count + delegate_state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "selected_candidate": hdhive_candidate, + "resources": [ + {**dict(item or {}), "index": idx} + for idx, item in enumerate(hdhive_resources, start=1) + ], + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": local_index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + + if kind == "assistant_update_check": + items = [dict(item or {}) for item in (state.get("items") or []) if isinstance(item, dict)] + if action == "next_page": + return {"success": False, "message": "更新检查结果当前不分页,可以直接回复编号或“选择 编号 详情”。"} + if action in {"best", "best_execute", "best_plan"}: + best = self._best_scored_source_item(items) + if not best: + return {"success": False, "message": "当前更新检查结果没有可评分条目,请直接回复编号。"} + index = self._safe_int(best.get("_index") or best.get("index"), 0) + action = "plan" if action == "best_plan" else "" if action == "best_execute" else "detail" + if index <= 0: + return {"success": False, "message": "更新检查结果需要编号,例如:选择 25 详情。"} + matched_items = [ + item for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) == index + ] + if not matched_items and 1 <= index <= len(items): + matched_items = [items[index - 1]] + if not matched_items: + available = "、".join( + f"#{self._safe_int(item.get('_index') or item.get('index'), 0)}" + for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) > 0 + ) + return { + "success": False, + "message": f"序号不在当前更新检查结果里。可选编号:{available or '暂无'}", + } + selected = dict(matched_items[0]) + source_type = self._clean_text(selected.get("_update_source")).lower() + choice_index = self._safe_int(selected.get("_index") or selected.get("index"), index) + selected["index"] = choice_index + selected["_index"] = choice_index + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if action == "detail": + title = "盘搜资源详情" if source_type == "pansou" else "影巢资源详情" + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title=title), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_resource_detail", + "ok": True, + "choice": choice_index, + "source_type": source_type, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + }) + if source_type == "pansou": + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法继续处理;可先发“选择 编号 详情”查看。"} + access_code = self._clean_text(selected.get("password")) + route_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_pansou_transfer", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": route_path, + }], + execute_body={ + "workflow": "update_check_pansou_transfer", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": route_path, + }, + message="更新检查盘搜资源转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": route_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "target_path": route_path, + "apikey": self._extract_apikey(request, body), + }) + ) + return finish(route_result) + if source_type == "hdhive": + slug = self._clean_text(selected.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法继续处理;可先发“选择 编号 详情”查看。"} + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_hdhive_unlock", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": selected, + }], + execute_body={ + "workflow": "update_check_hdhive_unlock", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": final_path, + }, + message="更新检查影巢资源解锁/转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": final_path, + "selected_resource": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=selected, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_update_check_hdhive", + title=selected.get("title") or selected.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(selected.get('title') or selected.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_hdhive_unlock", + "ok": True, + "selected_resource": selected, + "result": route_result, + }), + }) + return {"success": False, "message": "当前更新检查条目缺少来源信息,请重新执行更新检查。"} + if kind == "assistant_pansou": + items = state.get("items") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total_items = len(items) + if action == "next_page": + total_pages = max(1, (total_items + page_size - 1) // page_size) if total_items else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_pansou_text( + self._clean_text(state.get("keyword")), + items, + self._safe_int(state.get("total"), total_items), + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法执行最佳。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + plan_result = self._save_assistant_pick_plan_response( + workflow="pansou_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }], + execute_body={ + "workflow": "pansou_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择盘搜最佳候选并执行:#{index}", + extra_data={"choice": index, "source_type": "pansou"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + result = self._save_assistant_pick_plan_response( + workflow="pansou_best_plan", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail": + if index <= 0: + return {"success": False, "message": "盘搜详情需要编号,例如:选择 1 详情。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "盘搜计划需要编号,例如:计划选择 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="pansou_transfer_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_transfer_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜转存计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "盘搜结果当前只支持:选择 编号、计划选择 编号、选择 编号 详情、最佳片源、计划最佳、执行最佳。"} + if index <= 0: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "path": final_path, + "trigger": "Agent影视助手 智能入口盘搜选择", + "apikey": self._extract_apikey(request, body), + }) + ) + if not route_result.get("success"): + if self._is_115_url(share_url): + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=access_code, + target_path=final_path, + source="assistant_pansou_pick", + title=selected.get("note") or "", + last_error=str(route_result.get("message") or ""), + ) + return finish({ + "success": False, + "message": ( + f"{str(route_result.get('message') or '转存失败')}\n" + f"{self._format_p115_resume_hint(selected.get('note') or '')}" + ), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_quark_url(share_url) and self._is_quark_share_restricted_message( + str(route_result.get("message") or "") + ): + recovered = await self._assistant_retry_pansou_quark_candidates( + request, + items=items, + selected_index=index, + selected_url=share_url, + session=session, + target_path=final_path, + apikey=self._extract_apikey(request, body), + ) + if recovered: + return finish(recovered) + return finish({ + "success": False, + "message": str(route_result.get('message') or '转存失败'), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_115_url(share_url): + self._clear_pending_p115_share(cache_key) + provider = ((route_result.get("data") or {}).get("provider") or "").lower() + result_payload = (route_result.get("data") or {}).get("result") or {} + directory = (result_payload.get("result") or {}).get("target_path") or (result_payload.get("result") or {}).get("path") or final_path + text_message = "\n".join([ + "盘搜结果已执行转存", + f"资源:{selected.get('note') or '未命名资源'}", + f"类型:{provider or selected.get('channel') or '-'}", + f"目录:{directory or '-'}", + ]) + return finish({ + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={"action": "share_route", "ok": True}), + }) + + if kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + pick_action = self._normalize_pick_action(body.get("action")) + next_mode = self._clean_text( + body.get("mode") + or body.get("search_mode") + or body.get("target") + or "" + ).lower() + if index <= 0: + index = selected_index + if index <= 0 and ( + pick_action in {"detail", "best", "plan", "best_execute"} + or next_mode + ): + index = 1 + if index <= 0: + return {"success": False, "message": "推荐结果需要先指定编号,例如:选择 1 决策,或先发:详情 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + title = self._clean_text(selected.get("title")) + if not title: + return {"success": False, "message": "选中的推荐条目缺少标题,无法继续搜索。"} + if pick_action == "detail": + self._save_session(cache_key, { + **state, + "selected_index": index, + "selected_item": selected, + }) + return finish({ + "success": True, + "message": self._format_mp_recommend_item_detail_text(selected), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendation_detail", + "ok": True, + "choice": index, + "selected_index": index, + "item": selected, + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + }), + }) + mode_aliases = { + "原生": "mp", + "mp搜索": "mp", + "影巢": "hdhive", + "yc": "hdhive", + "盘搜": "pansou", + "ps": "pansou", + "计划": "smart_plan", + "smart_plan": "smart_plan", + "确认": "smart_execute", + "执行": "smart_execute", + "直接执行": "smart_execute", + "smart_execute": "smart_execute", + "决策": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart": "smart_decision", + "smart_decision": "smart_decision", + } + if not next_mode: + if pick_action == "plan": + next_mode = "smart_plan" + elif pick_action == "best_execute": + next_mode = "smart_execute" + elif pick_action in {"best", "detail"}: + next_mode = "smart_decision" + next_mode = mode_aliases.get(next_mode, next_mode) + if not next_mode: + next_mode = "mp" + if next_mode not in {"mp", "hdhive", "pansou", "smart_decision", "smart_plan", "smart_execute"}: + return {"success": False, "message": "推荐选择只支持 mode=smart_decision、mode=smart_plan、mode=smart_execute、mode=mp、mode=hdhive 或 mode=pansou。"} + selected_media_type = self._clean_text(selected.get("type") or state.get("media_type") or "auto").lower() + media_type_aliases = { + "电影": "movie", + "movie": "movie", + "movies": "movie", + "电视剧": "tv", + "剧集": "tv", + "番剧": "tv", + "tv": "tv", + "series": "tv", + "all": "auto", + "全部": "auto", + } + selected_media_type = media_type_aliases.get(selected_media_type, "auto") + next_keyword = title + if next_mode == "smart_decision" and pick_action in {"best", "detail"}: + next_keyword = f"{title} 详情" + routed_body = { + "session": session, + "session_id": cache_key, + "mode": next_mode, + "keyword": next_keyword, + "media_type": selected_media_type, + "path": target_path, + "apikey": self._extract_apikey(request, body), + } + if next_mode in {"smart_decision", "smart_plan", "smart_execute"}: + routed_body["origin"] = "mp_recommend" + else: + routed_body["recommend_handoff"] = self._assistant_recommend_handoff_state( + source=self._clean_text(state.get("source")), + requested_source=self._clean_text(state.get("requested_source") or state.get("source")), + media_type=selected_media_type, + selected_index=index, + selected_item=selected, + ) + return finish(await self.api_assistant_route( + _JsonRequestShim(request, routed_body) + )) + + if kind == "assistant_smart_search": + if action in { + "decision_continue", + "decision_hdhive", + "decision_pansou", + "decision_mp_pt", + "decision_conservative", + "decision_aggressive", + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=action, + )) + if action == "best_execute": + return finish(await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best_plan": + return finish(self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best": + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + active_source = self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + try: + result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + finally: + self._save_session(cache_key, state) + return finish(result) + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + requested_mode = self._clean_text(body.get("mode") or body.get("search_mode")).lower() + mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp_pt", + "mp": "mp_pt", + "pt": "mp_pt", + } + requested_mode = mode_aliases.get(requested_mode, requested_mode) + active_source = requested_mode or self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + if kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action in {"best", "best_execute", "best_plan", "plan"}: + return {"success": False, "message": "MP 候选阶段还没有 PT 资源评分,请先回复编号选定电影/剧集。"} + if action: + return {"success": False, "message": "MP 候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效候选编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1] or {}) + candidate_title = self._clean_text(candidate.get("title")) or self._clean_text(state.get("keyword")) + candidate_year = self._clean_text(candidate.get("year")) + search_keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + pending_action = dict(state.get("pending_action") or {}) if isinstance(state.get("pending_action"), dict) else {} + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if pending_mode == "cloud_transfer_execute": + cloud_provider = self._clean_text(pending_action.get("cloud_provider")).lower() + overrides: Dict[str, Any] = {} + if cloud_provider == "quark": + overrides = {"has_quark": True, "has_115": False, "prefer_cloud_provider": "quark"} + elif cloud_provider == "115": + overrides = {"has_quark": False, "has_115": True, "prefer_cloud_provider": "115"} + result = await self._assistant_cloud_transfer_execute( + request, + keyword=search_keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", + year=candidate_year or self._clean_text(state.get("year")), + source_order=["pansou", "hdhive"], + target_path=self._clean_text(state.get("target_path")), + session_preference_overrides=overrides, + origin=cloud_provider or "cloud_transfer", + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(result) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + return finish(result) + + if kind == "assistant_mp": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total = self._safe_int(state.get("total"), 0) + if action == "next_page": + total_pages = max(1, (max(0, total) + page_size - 1) // page_size) if total else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号查看详情或下载。"} + next_page = current_page + 1 + all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if all_items: + preview = self._slice_mp_preview_items(all_items, page=next_page, page_size=page_size) + else: + preview = self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=next_page, + page_size=page_size, + ) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + updated_state = { + **state, + "items": preview, + "all_items": all_items, + "page": next_page, + "page_size": page_size, + "total": total, + } + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_mp_search_text( + self._clean_text(state.get("keyword")), + f"{self._clean_text(state.get('keyword'))} — MP搜索", + preview, + total=total, + page=next_page, + page_size=page_size, + result_filter=self._clean_text(state.get("result_filter")), + latest_episode=self._safe_int(state.get("latest_episode"), 0), + episode_filter=self._safe_int(state.get("episode_filter"), 0), + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search_next_page", + "ok": True, + "keyword": self._clean_text(state.get("keyword")), + "items": preview, + "page": next_page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }), + }) + if action == "best": + result = await self._assistant_mp_best_result_detail( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + if action == "best_execute": + plan_result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix="已自动选择当前评分最高的 PT 候选并执行计划", + extra_data={"source_type": "mp_pt"}, + )) + if action == "best_plan": + result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail" and index <= 0: + return {"success": False, "message": "MP 搜索结果详情需要编号,例如:选择 1。"} + if action == "plan" and index <= 0: + return {"success": False, "message": "生成 PT 下载计划需要编号,例如:下载1。"} + if action == "plan" or (not action and index > 0): + result = self._assistant_mp_download_plan_response( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download", + message="PT 下载计划已生成", + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + result = await self._assistant_mp_result_detail( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + + if kind == "assistant_hdhive": + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_pick", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + stage = str(state.get("stage") or "").strip() + service = self._ensure_hdhive_service() + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if stage == "candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action == "best": + return {"success": False, "message": "影巢候选影片阶段没有评分,不能用“最佳片源”;请先回复编号选择影片,进入资源列表后再用“最佳片源”。"} + if action == "best_execute": + return {"success": False, "message": "影巢候选影片阶段还不能直接执行最佳;请先进入资源列表,或直接发送:智能执行 片名。"} + if action == "best_plan": + return {"success": False, "message": "影巢候选影片阶段还不能直接生成最佳计划;请先进入资源列表,或直接发送:智能计划 片名。"} + if action == "plan": + return {"success": False, "message": "影巢候选影片阶段不能生成资源计划;请先回复编号选择影片,进入资源列表后再发:计划选择 1。"} + if action: + return {"success": False, "message": "影巢候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效影片编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or state.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": f"影巢资源查询失败:{resource_message}", "data": resource_result} + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=final_path, + ) + self._save_session( + cache_key, + { + **state, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": final_path, + }, + ) + return finish({ + "success": True, + "message": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_search", + "ok": True, + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "total_pages": max(1, (len(preview) + self._assistant_result_page_size - 1) // self._assistant_result_page_size) if preview else 1, + "score_summary": self._score_summary(preview, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(preview), + }), + }) + resources = state.get("resources") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return finish({ + "success": False, + "message": "已经是最后一页了,可以直接回复编号继续选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": False, + "error_code": "already_last_page", + "page": current_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_resource_lines(resources, dict(state.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": True, + "selected_candidate": dict(state.get("selected_candidate") or {}), + "resources": resources, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + "score_summary": self._score_summary(resources, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(resources), + }), + }) + if action == "best": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="影巢当前最佳资源"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法执行最佳。"} + plan_result = self._save_assistant_pick_plan_response( + workflow="hdhive_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择影巢最佳资源并执行:#{index}", + extra_data={"choice": index, "source_type": "hdhive"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法生成计划。"} + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_best_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + )) + if action == "detail": + if index <= 0: + return {"success": False, "message": "影巢资源详情需要编号,例如:选择 1 详情。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(resource, title="影巢资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resource_detail", + "ok": True, + "choice": index, + "item": resource, + "score_summary": self._score_summary([resource], limit=1), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "影巢资源计划需要编号,例如:计划选择 1。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法生成计划。"} + actions = [{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": resource, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_unlock_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "hdhive_unlock_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢解锁/转存计划已生成", + score_items=[resource], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": resource, + }, + )) + if action: + return {"success": False, "message": "影巢资源阶段只支持:直接回编号、计划选择 编号、选择 编号 详情;也支持短命令“详情 3”“计划 3”。"} + if index <= 0: + return {"success": False, "message": "请选择有效资源编号,例如:选择 1"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_unlock", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(resource.get('title') or resource.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + }) + + return {"success": False, "message": f"当前会话阶段不支持继续选择:{kind or 'unknown'}"} + + async def api_assistant_capabilities(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_capabilities_public_data() + return { + "success": True, + "message": self._format_assistant_capabilities_text(), + "data": self._assistant_capabilities_compact_data(data) if compact else self._assistant_response_data( + session="default", + data=data, + ), + } + + async def api_assistant_readiness(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_readiness_public_data() + response_data = { + "action": "readiness", + "ok": bool(data.get("can_start")), + **data, + } + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_readiness_text(), + "data": self._assistant_readiness_compact_data(response_data) if compact else self._assistant_response_data( + session="default", + data=response_data, + ), + } + + async def api_assistant_pulse(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_pulse_public_data() + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_pulse_text(), + "data": data, + } + + async def api_assistant_startup(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_startup_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_startup_text(), + "data": data, + } + + async def api_assistant_maintain(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + requested_execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + execute = requested_execute and request.method.upper() != "GET" + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + if requested_execute and request.method.upper() == "GET": + data["execute_ignored"] = True + data["warning"] = "GET 请求只返回 dry-run 维护建议;如需执行维护请使用 POST execute=true。" + if execute: + executed_actions = data.get("executed_actions") or [] + self._record_assistant_execution( + action="maintain", + session=self._clean_text(body.get("session")) or "default", + session_id=self._clean_text(body.get("session_id")), + success=bool(data.get("ok")), + message=self._format_assistant_maintain_text(data), + summary={ + "executed": bool(data.get("executed")), + "executed_actions": [ + { + "name": self._clean_text(item.get("name")), + "removed": self._safe_int(item.get("removed"), 0), + } + for item in executed_actions + if isinstance(item, dict) + ], + "after": { + "stale_sessions": (data.get("after") or {}).get("stale_sessions"), + "saved_plans_executed": (data.get("after") or {}).get("saved_plans_executed"), + "saved_plans_pending": (data.get("after") or {}).get("saved_plans_pending"), + }, + }, + ) + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_maintain_text(data), + "data": data, + } + + async def api_assistant_toolbox(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_toolbox_public_data() + return { + "success": True, + "message": self._format_assistant_toolbox_text(), + "data": data, + } + + async def api_assistant_request_templates(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + names = ( + body.get("names") + or body.get("name") + or body.get("template") + or request.query_params.get("names") + or request.query_params.get("name") + or request.query_params.get("template") + ) + recipe = ( + body.get("recipe") + or body.get("recommended_recipe") + or request.query_params.get("recipe") + or request.query_params.get("recommended_recipe") + ) + include_templates = self._parse_bool_value( + body.get("include_templates") if "include_templates" in body else request.query_params.get("include_templates"), + True, + ) + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return { + "success": True, + "message": self._format_assistant_request_templates_text(data), + "data": data, + } + + async def api_assistant_selfcheck(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_selfcheck_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_selfcheck_text(), + "data": data, + } + + async def api_assistant_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + response_data = self._assistant_history_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "history", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_history_text(session=session, session_id=session_id, limit=limit), + "data": response_data, + } + + async def api_assistant_plans(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + executed = self._parse_optional_bool(request.query_params.get("executed")) + include_actions = bool(self._parse_optional_bool(request.query_params.get("include_actions")) or False) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + response_data = self._assistant_plans_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "plans", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": response_data, + } + + async def api_assistant_plans_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_workflow_plans( + plan_id=body.get("plan_id"), + session=body.get("session"), + session_id=body.get("session_id"), + executed=self._parse_optional_bool(body.get("executed")), + all_plans=self._parse_bool_value(body.get("all_plans"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + if not result.get("ok"): + return { + "success": False, + "message": str(result.get("message") or "计划清理参数无效"), + "data": result, + } + return { + "success": True, + "message": str(result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=body.get("session") or "default", data={ + "action": "plans_clear", + "ok": True, + **result, + }), + } + + async def api_assistant_recover(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session")) + session_id = self._clean_text(body.get("session_id") or request.query_params.get("session_id")) + execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + prefer_unexecuted = self._parse_bool_value( + body.get("prefer_unexecuted") if "prefer_unexecuted" in body else None, + True, + ) + stop_on_error = self._parse_bool_value( + body.get("stop_on_error") if "stop_on_error" in body else None, + True, + ) + include_raw_results = self._parse_bool_value( + body.get("include_raw_results") if "include_raw_results" in body else None, + False, + ) + compact = bool( + self._parse_optional_bool(body.get("compact")) + if "compact" in body + else self._parse_optional_bool(request.query_params.get("compact")) + ) + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 20) + data = self._assistant_recover_public_data( + session=session, + session_id=session_id, + limit=limit, + ) + data.update({ + "action": "recover", + "ok": True, + "execute_requested": execute, + "executed": False, + }) + + if not execute: + return { + "success": True, + "message": self._format_assistant_recover_text(data), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + recovery = dict(data.get("recovery") or {}) + if not recovery.get("can_resume"): + data["ok"] = False + data["execute_error"] = recovery.get("reason") or "当前没有可直接恢复的动作" + return { + "success": False, + "message": str(data["execute_error"]), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + template = dict(recovery.get("action_template") or {}) + action_body = dict(template.get("action_body") or {}) + if not action_body and template.get("name"): + action_body = {"name": template.get("name"), **dict(template.get("body") or {})} + if not self._clean_text(action_body.get("name")): + data["ok"] = False + data["execute_error"] = "恢复模板缺少可执行动作名" + return { + "success": False, + "message": data["execute_error"], + "data": self._assistant_recover_response_data(data, compact=compact), + } + + action_body.setdefault("session", data.get("session") or "default") + if data.get("session_id"): + action_body.setdefault("session_id", data.get("session_id")) + action_body.setdefault("prefer_unexecuted", prefer_unexecuted) + action_body.setdefault("stop_on_error", stop_on_error) + action_body.setdefault("include_raw_results", include_raw_results) + action_body["apikey"] = self._extract_apikey(request, body) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + result_data = dict(result.get("data") or {}) + data.update({ + "ok": bool(result.get("success")), + "executed": True, + "execute_success": bool(result.get("success")), + "execute_action": action_body.get("name"), + "execute_message": result.get("message") or "", + "execute_result": result if include_raw_results else { + "success": bool(result.get("success")), + "message": result.get("message") or "", + "data": { + "action": result_data.get("action"), + "ok": result_data.get("ok"), + "session": result_data.get("session"), + "session_id": result_data.get("session_id"), + "plan_id": result_data.get("plan_id"), + "workflow": result_data.get("workflow"), + }, + }, + }) + return { + "success": bool(result.get("success")), + "message": f"恢复动作 {action_body.get('name')} 执行完成\n{result.get('message') or ''}".strip(), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + async def api_assistant_session_state(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, _ = self._normalize_assistant_session_ref( + session=( + (body or {}).get("session") + or request.query_params.get("session") + or request.query_params.get("chat_id") + or request.query_params.get("user_id") + or "default" + ), + session_id=(body or {}).get("session_id") or request.query_params.get("session_id"), + ) + compact = bool( + self._parse_optional_bool((body or {}).get("compact")) + if "compact" in (body or {}) + else self._parse_optional_bool(request.query_params.get("compact")) + ) + summary = self._format_assistant_session_summary(session=session) + session_state = self._assistant_session_public_data(session=session) + if compact: + return { + "success": True, + "message": summary, + "data": self._assistant_session_compact_data(session_state), + } + data = self._assistant_response_data(session=session, data={ + "action": "session_state", + "ok": True, + "session_snapshot": session_state, + **session_state, + }) + return {"success": True, "message": summary, "data": data} + + async def api_assistant_session_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + existing = self._load_session(cache_key) + if not existing: + return { + "success": True, + "message": "当前没有需要清理的会话。", + "data": self._assistant_response_data(session=session, data={"cleared": False}), + } + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return { + "success": True, + "message": f"已清理会话:{session}", + "data": self._assistant_response_data(session=session, data={"cleared": True}), + } + + async def api_assistant_sessions(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + kind = self._clean_text(request.query_params.get("kind")) + has_pending_p115_raw = request.query_params.get("has_pending_p115") + has_pending_p115: Optional[bool] = None + if has_pending_p115_raw is not None: + has_pending_p115 = str(has_pending_p115_raw).strip().lower() in {"1", "true", "yes", "y"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact"))) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + if compact: + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_sessions_compact_data(data), + } + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_response_data(session="default", data={ + "action": "sessions", + "ok": True, + **data, + }), + } + + async def api_assistant_sessions_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_assistant_sessions( + session=body.get("session"), + session_id=body.get("session_id"), + kind=body.get("kind"), + has_pending_p115=body.get("has_pending_p115"), + stale_only=self._parse_bool_value(body.get("stale_only"), False), + all_sessions=self._parse_bool_value(body.get("all_sessions"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + cleared_count = self._safe_int(result.get("cleared_count"), 0) + if cleared_count <= 0: + return { + "success": True, + "message": "没有匹配到需要清理的 assistant 会话。", + "data": result, + } + return { + "success": True, + "message": f"已清理 {cleared_count} 个 assistant 会话。", + "data": result, + } + + async def api_session_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": result.get("candidates") or [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(result.get("candidates") or [], page=1, page_size=self._hdhive_candidate_page_size), + "session_id": session_id, + "stage": "candidate", + "keyword": keyword, + "candidates": result.get("candidates") or [], + "candidate_count": len(result.get("candidates") or []), + "meta": result.get("meta") or {}, + }, + } + + async def api_session_hdhive_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + session_id = self._clean_text(body.get("session_id")) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + action = self._normalize_pick_action(body.get("action") or body.get("pick_action")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + if not session_id or (index <= 0 and not action): + return {"success": False, "message": "session_id 和选择编号必填;详情/翻页动作可传 action"} + + session = self._load_session(session_id) + if not session: + return {"success": False, "message": "会话不存在或已过期"} + + stage = session.get("stage") + service = self._ensure_hdhive_service() + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(session_id, {**session, "candidates": enriched, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": current_page, + "candidates": enriched, + }, + } + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(candidates): + return {"success": False, "message": "候选编号超出范围"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": resource_message, "data": resource_result} + preview = self._group_resource_preview(resource_result.get("data") or [], per_group=None) + self._save_session( + session_id, + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": target_path or session.get("target_path") or "", + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "session_id": session_id, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "meta": { + "total": len(preview), + "count_115": len([x for x in preview if str(x.get("pan_type") or "").lower() == "115"]), + "count_quark": len([x for x in preview if str(x.get("pan_type") or "").lower() == "quark"]), + }, + }, + } + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "page_size": page_size, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "session_id": session_id, + "stage": "resource", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(resources): + return {"success": False, "message": "资源编号超出范围"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=target_path or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return { + "success": True, + "message": route_message, + "data": { + "text": self._format_route_result(route_result), + "session_id": session_id, + "selected_resource": resource, + "result": route_result, + }, + } + + return {"success": False, "message": f"当前会话阶段不支持继续选择: {stage}"} diff --git a/AgentResourceOfficer/agenttool.py b/AgentResourceOfficer/agenttool.py new file mode 100644 index 0000000..4af8355 --- /dev/null +++ b/AgentResourceOfficer/agenttool.py @@ -0,0 +1,870 @@ +from typing import Optional, Type + +from pydantic import BaseModel + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager + +from .schemas import ( + AssistantCapabilitiesToolInput, + AssistantExecuteActionToolInput, + AssistantExecuteActionsToolInput, + AssistantExecutePlanToolInput, + AssistantHistoryToolInput, + AssistantHelpToolInput, + AssistantMaintainToolInput, + AssistantPickToolInput, + AssistantPreferencesToolInput, + AssistantPlansClearToolInput, + AssistantPlansToolInput, + AssistantPulseToolInput, + AssistantReadinessToolInput, + AssistantRecoverToolInput, + AssistantRequestTemplatesToolInput, + AssistantRouteToolInput, + AssistantSessionClearToolInput, + AssistantSessionsClearToolInput, + AssistantSessionsToolInput, + AssistantSessionStateToolInput, + AssistantSelfcheckToolInput, + AssistantStartupToolInput, + AssistantToolboxToolInput, + AssistantWorkflowToolInput, + FeishuChannelHealthToolInput, + HDHiveSearchSessionToolInput, + HDHiveSessionPickToolInput, + P115CancelPendingToolInput, + P115PendingToolInput, + P115QRCodeCheckToolInput, + P115QRCodeStartToolInput, + P115ResumePendingToolInput, + P115StatusToolInput, + ShareRouteToolInput, +) + + +def _get_plugin(): + return PluginManager().running_plugins.get("AgentResourceOfficer") + + +class HDHiveSearchSessionTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_search" + description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step." + args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + keyword = kwargs.get("keyword", "") + return f"正在通过 Agent影视助手搜索影巢候选:{keyword}" + + async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_search_session( + keyword=keyword, + media_type=media_type, + year=year, + target_path=path, + ) + + +class HDHiveSessionPickTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_pick" + description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item." + args_schema: Type[BaseModel] = HDHiveSessionPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session_id = kwargs.get("session_id", "") + choice = kwargs.get("choice", "") + return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}" + + async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_pick_session( + session_id=session_id, + index=choice, + target_path=path, + action=action, + ) + + +class ShareRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_route_share" + description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path." + args_schema: Type[BaseModel] = ShareRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 路由分享链接" + + async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_route_share( + share_url=url, + access_code=access_code, + target_path=path, + ) + + +class AssistantRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_entry" + description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links." + args_schema: Type[BaseModel] = AssistantRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or "" + return f"正在通过 Agent影视助手 统一入口处理:{text}" + + async def run( + self, + text: str = None, + session: str = "default", + session_id: str = None, + path: str = None, + mode: str = None, + keyword: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + action: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_route( + text=text, + session=session, + session_id=session_id, + target_path=path, + mode=mode, + keyword=keyword, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + action=action, + compact=compact, + ) + + +class AssistantPickTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_pick" + description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page." + args_schema: Type[BaseModel] = AssistantPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + choice = kwargs.get("choice", 0) + action = kwargs.get("action", "") + tail = f"动作 {action}" if action else f"选择 {choice}" + return f"正在继续 Agent影视助手 统一会话:{session},{tail}" + + async def run( + self, + session: str = "default", + session_id: str = None, + choice: int = 0, + action: str = None, + mode: str = None, + path: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pick( + session=session, + session_id=session_id, + index=choice, + action=action, + mode=mode, + target_path=path, + compact=compact, + ) + + +class AssistantHelpTool(MoviePilotTool): + name: str = "agent_resource_officer_help" + description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance." + args_schema: Type[BaseModel] = AssistantHelpToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 使用帮助" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_help(session=session, session_id=session_id) + + +class AssistantCapabilitiesTool(MoviePilotTool): + name: str = "agent_resource_officer_capabilities" + description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents." + args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 能力说明" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_capabilities(compact=compact) + + +class AssistantReadinessTool(MoviePilotTool): + name: str = "agent_resource_officer_readiness" + description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings." + args_schema: Type[BaseModel] = AssistantReadinessToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 启动就绪状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_readiness(compact=compact) + + +class FeishuChannelHealthTool(MoviePilotTool): + name: str = "agent_resource_officer_feishu_health" + description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured." + args_schema: Type[BaseModel] = FeishuChannelHealthToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 内置飞书入口状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_feishu_health(compact=compact) + + +class AssistantPulseTool(MoviePilotTool): + name: str = "agent_resource_officer_pulse" + description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents." + args_schema: Type[BaseModel] = AssistantPulseToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 轻量启动状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pulse() + + +class AssistantStartupTool(MoviePilotTool): + name: str = "agent_resource_officer_startup" + description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint." + args_schema: Type[BaseModel] = AssistantStartupToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 启动聚合信息" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_startup() + + +class AssistantMaintainTool(MoviePilotTool): + name: str = "agent_resource_officer_maintain" + description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans." + args_schema: Type[BaseModel] = AssistantMaintainToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 维护建议" + + async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_maintain(execute=execute, limit=limit) + + +class AssistantToolboxTool(MoviePilotTool): + name: str = "agent_resource_officer_toolbox" + description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples." + args_schema: Type[BaseModel] = AssistantToolboxToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 轻量工具清单" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_toolbox() + + +class AssistantRequestTemplatesTool(MoviePilotTool): + name: str = "agent_resource_officer_request_templates" + description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies." + args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 请求模板" + + async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_request_templates( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + + +class AssistantSelfcheckTool(MoviePilotTool): + name: str = "agent_resource_officer_selfcheck" + description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health." + args_schema: Type[BaseModel] = AssistantSelfcheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在执行 Agent影视助手 协议自检" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_selfcheck() + + +class AssistantHistoryTool(MoviePilotTool): + name: str = "agent_resource_officer_history" + description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action." + args_schema: Type[BaseModel] = AssistantHistoryToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 最近执行历史" + + async def run( + self, + session: str = None, + session_id: str = None, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_history( + session=session, + session_id=session_id, + compact=compact, + limit=limit, + ) + + +class AssistantExecuteActionTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_action" + description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step." + args_schema: Type[BaseModel] = AssistantExecuteActionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + choice: int = None, + path: str = None, + keyword: str = None, + media_type: str = None, + year: str = None, + url: str = None, + access_code: str = None, + client_type: str = None, + source: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = None, + prefer_unexecuted: bool = True, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_action( + name=name, + session=session, + session_id=session_id, + choice=choice, + target_path=path, + keyword=keyword, + media_type=media_type, + year=year, + share_url=url, + access_code=access_code, + client_type=client_type, + source=source, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + plan_id=plan_id, + prefer_unexecuted=prefer_unexecuted, + compact=compact, + ) + + +class AssistantExecuteActionsTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_actions" + description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly." + args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + actions = kwargs.get("actions") or [] + return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步" + + async def run( + self, + actions: list, + session: str = "default", + session_id: str = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_actions( + actions=actions, + session=session, + session_id=session_id, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantWorkflowTool(MoviePilotTool): + name: str = "agent_resource_officer_run_workflow" + description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs." + args_schema: Type[BaseModel] = AssistantWorkflowToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + keyword: str = None, + choice: int = None, + candidate_choice: int = None, + resource_choice: int = None, + path: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + source: str = None, + limit: int = 20, + dry_run: bool = False, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_workflow( + name=name, + session=session, + session_id=session_id, + keyword=keyword, + choice=choice, + candidate_choice=candidate_choice, + resource_choice=resource_choice, + target_path=path, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + source=source, + limit=limit, + dry_run=dry_run, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPreferencesTool(MoviePilotTool): + name: str = "agent_resource_officer_preferences" + description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions." + args_schema: Type[BaseModel] = AssistantPreferencesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + if kwargs.get("reset"): + return "正在重置 Agent影视助手 智能体偏好画像" + if kwargs.get("preferences"): + return "正在保存 Agent影视助手 智能体偏好画像" + return "正在读取 Agent影视助手 智能体偏好画像" + + async def run( + self, + session: str = "default", + session_id: str = None, + user_key: str = None, + preferences: dict = None, + reset: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_preferences( + session=session, + session_id=session_id, + user_key=user_key, + preferences=preferences, + reset=reset, + compact=compact, + ) + + +class AssistantExecutePlanTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_plan" + description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id." + args_schema: Type[BaseModel] = AssistantExecutePlanToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPlansTool(MoviePilotTool): + name: str = "agent_resource_officer_plans" + description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id." + args_schema: Type[BaseModel] = AssistantPlansToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 已保存计划" + + async def run( + self, + session: str = None, + session_id: str = None, + executed: bool = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + compact=compact, + limit=limit, + ) + + +class AssistantPlansClearTool(MoviePilotTool): + name: str = "agent_resource_officer_plans_clear" + description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans." + args_schema: Type[BaseModel] = AssistantPlansClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 已保存计划" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + executed: bool = None, + all_plans: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans_clear( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + + +class AssistantRecoverTool(MoviePilotTool): + name: str = "agent_resource_officer_recover" + description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint." + args_schema: Type[BaseModel] = AssistantRecoverToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + target = kwargs.get("session_id") or kwargs.get("session") or "全局" + action = "并直接恢复" if kwargs.get("execute") else "恢复建议" + return f"正在查看 Agent影视助手 {target} 的{action}" + + async def run( + self, + session: str = None, + session_id: str = None, + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_recover( + session=session, + session_id=session_id, + execute=execute, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + limit=limit, + ) + + +class AssistantSessionStateTool(MoviePilotTool): + name: str = "agent_resource_officer_session_state" + description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task." + args_schema: Type[BaseModel] = AssistantSessionStateToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在查看 Agent影视助手 会话状态:{session}" + + async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact) + + +class AssistantSessionClearTool(MoviePilotTool): + name: str = "agent_resource_officer_session_clear" + description: str = "Clear the current Agent影视助手 assistant session cache." + args_schema: Type[BaseModel] = AssistantSessionClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在清理 Agent影视助手 会话:{session}" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_clear(session=session, session_id=session_id) + + +class AssistantSessionsTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions" + description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow." + args_schema: Type[BaseModel] = AssistantSessionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 活跃会话列表" + + async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions( + kind=kind, + has_pending_p115=has_pending_p115, + compact=compact, + limit=limit, + ) + + +class AssistantSessionsClearTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions_clear" + description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset." + args_schema: Type[BaseModel] = AssistantSessionsClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 活跃会话" + + async def run( + self, + session: str = None, + session_id: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions_clear( + session=session, + session_id=session_id, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + ) + + +class P115QRCodeStartTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_start" + description: str = "Generate a 115 login QR code using the p115client-compatible client session flow." + args_schema: Type[BaseModel] = P115QRCodeStartToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + client_type = kwargs.get("client_type", "alipaymini") + return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}" + + async def run(self, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_start(client_type=client_type) + + +class P115QRCodeCheckTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_check" + description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds." + args_schema: Type[BaseModel] = P115QRCodeCheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 检查 115 扫码状态" + + async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_check( + uid=uid, + time_value=time, + sign=sign, + client_type=client_type, + ) + + +class P115StatusTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_status" + description: str = "Show the current 115 transfer readiness, default target path, and current session source." + args_schema: Type[BaseModel] = P115StatusToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看 115 当前状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_status() + + +class P115PendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_pending" + description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error." + args_schema: Type[BaseModel] = P115PendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看待继续的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_pending(session=session) + + +class P115ResumePendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_resume_pending" + description: str = "Retry the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115ResumePendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 继续待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_resume(session=session) + + +class P115CancelPendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_cancel_pending" + description: str = "Cancel and clear the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115CancelPendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 取消待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_cancel(session=session) diff --git a/AgentResourceOfficer/feishu_channel.py b/AgentResourceOfficer/feishu_channel.py new file mode 100644 index 0000000..44a1c32 --- /dev/null +++ b/AgentResourceOfficer/feishu_channel.py @@ -0,0 +1,1885 @@ +import asyncio +import copy +import fcntl +import importlib +import json +import re +import sqlite3 +import threading +import time +import traceback +from base64 import b64decode +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import jieba +except Exception: + jieba = None + +try: + import lark_oapi as lark +except Exception: + lark = None + +_LARK_IMPORT_LOCK = threading.Lock() + +try: + from app.chain.download import DownloadChain + from app.chain.media import MediaChain + from app.chain.search import SearchChain + from app.chain.subscribe import SubscribeChain + from app.core.event import eventmanager + from app.core.metainfo import MetaInfo + from app.db.downloadhistory_oper import DownloadHistoryOper + from app.db.models.downloadhistory import DownloadHistory + from app.db.models.transferhistory import TransferHistory + from app.db.site_oper import SiteOper + from app.db.subscribe_oper import SubscribeOper + from app.db.systemconfig_oper import SystemConfigOper + from app.helper.subscribe import SubscribeHelper + from app.core.plugin import PluginManager + from app.log import logger + from app.scheduler import Scheduler + from app.schemas.types import EventType, SystemConfigKey, TorrentStatus, media_type_to_agent + from app.utils.http import RequestUtils + from app.utils.string import StringUtils +except Exception: + DownloadChain = None + DownloadHistoryOper = None + DownloadHistory = None + TransferHistory = None + MediaChain = None + SearchChain = None + SiteOper = None + SubscribeChain = None + SubscribeHelper = None + SubscribeOper = None + SystemConfigOper = None + eventmanager = None + MetaInfo = None + PluginManager = None + Scheduler = None + EventType = None + SystemConfigKey = None + TorrentStatus = None + media_type_to_agent = None + RequestUtils = None + StringUtils = None + + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() + + +_EVENT_CACHE_FILE = Path(__file__).resolve().parent / ".feishu_event_cache.json" + + +def ensure_lark_sdk(auto_install: bool = False) -> tuple[bool, str]: + global lark + + if lark is not None: + return True, "" + + with _LARK_IMPORT_LOCK: + if lark is not None: + return True, "" + + try: + import lark_oapi as runtime_lark + + lark = runtime_lark + return True, "" + except Exception as exc: + first_error = str(exc) + + return False, f"缺少依赖 lark-oapi:{first_error}。请通过插件 requirements.txt 安装依赖后重启 MoviePilot。" + + +class _FeishuLongConnectionRuntime: + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._fingerprint = "" + self._channel: Optional["FeishuChannel"] = None + + def start(self, channel: "FeishuChannel") -> None: + ok, message = ensure_lark_sdk(auto_install=False) + if not ok: + logger.error(f"[AgentResourceOfficer][Feishu] {message}") + return + + if not channel.enabled or not channel.app_id or not channel.app_secret: + return + + fingerprint = channel.connection_fingerprint() + with self._lock: + self._channel = channel + if self._thread and self._thread.is_alive(): + if fingerprint != self._fingerprint: + logger.warning("[AgentResourceOfficer][Feishu] 长连接已在运行,飞书凭证变更需重启 MoviePilot 后生效") + return + self._fingerprint = fingerprint + self._thread = threading.Thread( + target=self._run, + name="agent-resource-officer-feishu", + daemon=True, + ) + self._thread.start() + + def _run(self) -> None: + channel = self._channel + if channel is None or lark is None: + return + + def _on_message(data) -> None: + current = self._channel + if current is not None: + current.handle_long_connection_event(data) + + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + import lark_oapi.ws.client as lark_ws_client + + lark_ws_client.loop = loop + event_handler = ( + lark.EventDispatcherHandler.builder("", "") + .register_p2_im_message_receive_v1(_on_message) + .build() + ) + ws_client = lark.ws.Client( + channel.app_id, + channel.app_secret, + log_level=lark.LogLevel.DEBUG if channel.debug else lark.LogLevel.INFO, + event_handler=event_handler, + ) + logger.info("[AgentResourceOfficer][Feishu] 正在启动飞书长连接") + ws_client.start() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 长连接退出:{exc}\n{traceback.format_exc()}") + + def is_running(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + def stop(self) -> None: + with self._lock: + self._channel = None + + +class FeishuChannel: + _LEGACY_DEFAULT_COMMANDS = { + "/p115_manual_transfer", + "/p115_inc_sync", + "/p115_full_sync", + "/p115_strm", + "/quark_save", + "/media_search", + "/media_download", + "/media_subscribe", + "/media_subscribe_search", + } + _LEGACY_DEFAULT_ALIAS_KEYS = { + "刮削", + "搜索", + "MP搜索", + "原生搜索", + "下载", + "订阅", + "订阅搜索", + "生成STRM", + "全量STRM", + "指定路径STRM", + "夸克转存", + "夸克", + "搜索资源", + "下载资源", + "订阅媒体", + "订阅并搜索", + } + + def __init__(self, plugin: Any) -> None: + self.plugin = plugin + self.runtime = _FeishuLongConnectionRuntime() + self.enabled = False + self.allow_all = False + self.reply_enabled = True + self.reply_receive_id_type = "chat_id" + self.app_id = "" + self.app_secret = "" + self.verification_token = "" + self.allowed_chat_ids: List[str] = [] + self.allowed_user_ids: List[str] = [] + self.command_whitelist: List[str] = [] + self.command_aliases = "" + self.command_mode = "resource_officer" + self.debug = False + self._token_cache: Dict[str, Any] = {} + self._token_lock = threading.Lock() + self._event_cache: Dict[str, float] = {} + self._event_lock = threading.Lock() + self._search_cache: Dict[str, Dict[str, Any]] = {} + self._search_cache_lock = threading.Lock() + self._search_cache_limit = 200 + + @classmethod + def default_command_whitelist(cls) -> List[str]: + return [ + "/pansou_search", + "/smart_entry", + "/smart_pick", + "/media_search", + "/version", + ] + + @classmethod + def default_command_aliases(cls) -> str: + return ( + "搜索=/smart_entry\n" + "找=/smart_entry\n" + "云盘搜索=/smart_entry\n" + "MP搜索=/smart_entry\n" + "PT搜索=/smart_entry\n" + "原生搜索=/smart_entry\n" + "盘搜搜索=/pansou_search\n" + "盘搜=/pansou_search\n" + "ps=/pansou_search\n" + "1=/pansou_search\n" + "影巢搜索=/smart_entry\n" + "影巢=/smart_entry\n" + "yc=/smart_entry\n" + "2=/smart_entry\n" + "转存=/smart_entry\n" + "115转存=/smart_entry\n" + "夸克转存=/smart_entry\n" + "夸克=/smart_entry\n" + "下载=/smart_entry\n" + "订阅=/smart_entry\n" + "订阅搜索=/smart_entry\n" + "链接=/smart_entry\n" + "处理=/smart_entry\n" + "115登录=/smart_entry\n" + "115扫码=/smart_entry\n" + "检查115登录=/smart_entry\n" + "115登录状态=/smart_entry\n" + "115状态=/smart_entry\n" + "115帮助=/smart_entry\n" + "115任务=/smart_entry\n" + "继续115任务=/smart_entry\n" + "取消115任务=/smart_entry\n" + "影巢签到=/smart_entry\n" + "影巢普通签到=/smart_entry\n" + "普通签到=/smart_entry\n" + "签到=/smart_entry\n" + "赌狗签到=/smart_entry\n" + "签到日志=/smart_entry\n" + "影巢签到日志=/smart_entry\n" + "选择=/smart_pick\n" + "详情=/smart_pick\n" + "审查=/smart_pick\n" + "选=/smart_pick\n" + "继续=/smart_pick\n" + "搜索资源=/smart_entry\n" + "下载资源=/smart_entry\n" + "订阅媒体=/smart_entry\n" + "订阅并搜索=/smart_entry\n" + "版本=/version" + ) + + @staticmethod + def clean(value: Any) -> str: + if value is None: + return "" + text = str(value) + for ch in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060", "\ufffc"): + text = text.replace(ch, "") + return text.strip() + + @staticmethod + def split_lines(value: Any) -> List[str]: + return [line.strip() for line in str(value or "").splitlines() if line.strip()] + + @staticmethod + def split_commands(value: Any) -> List[str]: + raw = str(value or "").replace("\n", ",") + return [item.strip() for item in raw.split(",") if item.strip()] + + @classmethod + def parse_alias_text(cls, text: str) -> Dict[str, str]: + result: Dict[str, str] = {} + for line in str(text or "").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key and value.startswith("/"): + result[key] = value + return result + + @classmethod + def merge_command_aliases(cls, configured_text: str) -> str: + merged = cls.parse_alias_text(cls.default_command_aliases()) + for key, value in cls.parse_alias_text(configured_text).items(): + if key in cls._LEGACY_DEFAULT_ALIAS_KEYS and value in cls._LEGACY_DEFAULT_COMMANDS: + continue + merged[key] = value + return "\n".join(f"{key}={value}" for key, value in merged.items()) + + @classmethod + def merge_command_whitelist(cls, configured: List[str]) -> List[str]: + merged: List[str] = [] + seen = set() + for cmd in configured or []: + if cmd in cls._LEGACY_DEFAULT_COMMANDS: + continue + if cmd and cmd not in seen: + merged.append(cmd) + seen.add(cmd) + for cmd in cls.default_command_whitelist(): + if cmd not in seen: + merged.append(cmd) + seen.add(cmd) + return merged + + def configure(self, config: Dict[str, Any]) -> None: + self.enabled = bool(config.get("feishu_enabled", False)) + self.allow_all = bool(config.get("feishu_allow_all", False)) + self.reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self.reply_receive_id_type = self.clean(config.get("feishu_reply_receive_id_type") or "chat_id") + self.app_id = self.clean(config.get("feishu_app_id")) + self.app_secret = self.clean(config.get("feishu_app_secret")) + self.verification_token = self.clean(config.get("feishu_verification_token")) + self.allowed_chat_ids = self.split_lines(config.get("feishu_allowed_chat_ids")) + self.allowed_user_ids = self.split_lines(config.get("feishu_allowed_user_ids")) + self.command_whitelist = self.merge_command_whitelist(self.split_commands(config.get("feishu_command_whitelist"))) + self.command_aliases = self.merge_command_aliases(self.clean(config.get("feishu_command_aliases"))) + self.command_mode = self.clean(config.get("feishu_command_mode") or "resource_officer") + self.debug = bool(config.get("debug", False)) + + def start(self) -> None: + if self.enabled: + self.runtime.start(self) + + def stop(self) -> None: + self.runtime.stop() + + def is_running(self) -> bool: + return self.runtime.is_running() + + @staticmethod + def is_legacy_bridge_running() -> bool: + if PluginManager is None: + return False + try: + running_plugins = PluginManager().running_plugins or {} + plugin = ( + running_plugins.get("FeishuCommandBridgeLong") + or running_plugins.get("feishucommandbridgelong") + ) + if not plugin: + return False + config_db = Path("/config/user.db") + if config_db.exists(): + try: + with sqlite3.connect(str(config_db)) as conn: + row = conn.execute( + "select value from systemconfig where key=?", + ("plugin.FeishuCommandBridgeLong",), + ).fetchone() + if row and row[0]: + config = json.loads(row[0]) + if not bool(config.get("enabled")): + return False + except Exception: + pass + # MoviePilot may keep disabled plugins in running_plugins after loading. + # Treat the legacy bridge as a conflict only when it is actually enabled. + if hasattr(plugin, "health"): + try: + health = plugin.health() + if isinstance(health, dict): + return bool(health.get("enabled") and health.get("running")) + except Exception: + pass + if hasattr(plugin, "_enabled"): + return bool(getattr(plugin, "_enabled", False)) + if hasattr(plugin, "get_state"): + try: + return bool(plugin.get_state()) + except Exception: + return False + return False + except Exception: + return False + + def connection_fingerprint(self) -> str: + return "|".join([self.app_id, self.app_secret, self.verification_token]) + + def health(self) -> Dict[str, Any]: + sdk_available, sdk_message = ensure_lark_sdk(auto_install=False) + legacy_bridge_running = self.is_legacy_bridge_running() + app_id_configured = bool(self.app_id) + app_secret_configured = bool(self.app_secret) + verification_token_configured = bool(self.verification_token) + missing_requirements = [] + if not sdk_available: + missing_requirements.append("lark-oapi") + if not app_id_configured: + missing_requirements.append("feishu_app_id") + if not app_secret_configured: + missing_requirements.append("feishu_app_secret") + conflict_warning = bool(self.enabled and legacy_bridge_running) + ready_to_start = bool(self.enabled and sdk_available and app_id_configured and app_secret_configured and not conflict_warning) + safe_to_enable = bool((not legacy_bridge_running) and sdk_available and app_id_configured and app_secret_configured) + if conflict_warning: + recommended_action = "disable_legacy_bridge_or_use_different_app" + migration_hint = "内置飞书入口和旧飞书桥接同时运行,建议关闭旧桥接或使用不同飞书 App。" + elif not self.enabled and legacy_bridge_running: + recommended_action = "keep_legacy_or_disable_it_before_migration" + migration_hint = "内置飞书入口关闭,旧飞书桥接运行中;迁移前先关闭旧桥接。" + elif not self.enabled: + recommended_action = "configure_and_enable_feishu_channel" + migration_hint = "内置飞书入口关闭;配置飞书凭证后可开启。" + elif missing_requirements: + recommended_action = "complete_feishu_requirements" + migration_hint = "内置飞书入口已启用,但依赖或飞书凭证不完整。" + elif not self.is_running(): + recommended_action = "restart_moviepilot_or_resave_config" + migration_hint = "内置飞书入口已启用但长连接未运行,建议保存配置或重启 MoviePilot。" + else: + recommended_action = "none" + migration_hint = "内置飞书入口运行正常。" + return { + "enabled": self.enabled, + "running": self.is_running(), + "sdk_available": sdk_available, + "app_id_configured": app_id_configured, + "app_secret_configured": app_secret_configured, + "verification_token_configured": verification_token_configured, + "allow_all": self.allow_all, + "reply_enabled": self.reply_enabled, + "allowed_chat_count": len(self.allowed_chat_ids), + "allowed_user_count": len(self.allowed_user_ids), + "command_mode": self.command_mode, + "command_whitelist": self.command_whitelist, + "alias_count": len(self.parse_alias_text(self.command_aliases)), + "legacy_bridge_running": legacy_bridge_running, + "conflict_warning": conflict_warning, + "ready_to_start": ready_to_start, + "safe_to_enable": safe_to_enable, + "missing_requirements": missing_requirements, + "sdk_message": sdk_message, + "recommended_action": recommended_action, + "migration_hint": migration_hint, + } + + def handle_long_connection_event(self, data: Any) -> None: + if not self.enabled: + return + event = getattr(data, "event", None) + header = getattr(data, "header", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + sender_id = getattr(sender, "sender_id", None) + + event_id = str(getattr(header, "event_id", "") or "").strip() + if event_id and self._is_duplicate_event(event_id): + return + if not message or str(getattr(message, "message_type", "")).strip() != "text": + return + + raw_text = self._extract_text(getattr(message, "content", None)) + if not raw_text: + return + sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip() + chat_id = str(getattr(message, "chat_id", "") or "").strip() + if self.debug: + logger.info(f"[AgentResourceOfficer][Feishu] event_id={event_id} chat_id={chat_id}") + + if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): + self.reply_text(chat_id, sender_open_id, "该会话未在白名单中,命令已拒绝。") + return + if self._is_help_request(raw_text): + self.reply_text(chat_id, sender_open_id, self._build_help_text()) + return + if self._is_menu_request(raw_text): + self.reply_text(chat_id, sender_open_id, self._build_menu_text()) + return + + command_text = self._map_text_to_command(raw_text) + if not command_text: + return + cmd = command_text.split()[0] + if cmd not in self.command_whitelist: + self.reply_text(chat_id, sender_open_id, f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}") + return + if not self._handle_builtin_command(command_text, chat_id, sender_open_id): + self._submit_moviepilot_command(command_text, chat_id, sender_open_id) + + def _handle_builtin_command(self, command_text: str, chat_id: str, open_id: str) -> bool: + parts = command_text.split(maxsplit=1) + cmd = parts[0].strip() + arg = parts[1].strip() if len(parts) > 1 else "" + cache_key = self._cache_key(chat_id, open_id) + + if cmd == "/version": + self.reply_text(chat_id, open_id, f"Agent影视助手 {getattr(self.plugin, 'plugin_version', '')}\n飞书入口:{'运行中' if self.is_running() else '未运行'}") + return True + + if cmd == "/media_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:MP搜索 片名") + return True + self.reply_text(chat_id, open_id, f"正在使用 MP 原生搜索:{arg}") + self._run_thread("feishu-media-search", self._run_media_search, arg, chat_id, open_id) + return True + + if cmd == "/media_download": + if not arg or not arg.isdigit(): + self.reply_text(chat_id, open_id, "用法:下载资源 序号\n示例:下载资源 1") + return True + self.reply_text(chat_id, open_id, f"正在生成第 {arg} 条资源的下载计划,请稍候。") + self._run_thread("feishu-media-download", self._run_media_download, int(arg), chat_id, open_id) + return True + + if cmd in {"/media_subscribe", "/media_subscribe_search"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:订阅媒体 片名\n示例:订阅媒体 流浪地球2") + return True + immediate = cmd == "/media_subscribe_search" + self.reply_text(chat_id, open_id, f"正在{'订阅并搜索' if immediate else '订阅'}:{arg}") + self._run_thread("feishu-media-subscribe", self._run_media_subscribe, arg, immediate, chat_id, open_id) + return True + + if cmd == "/pansou_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2") + return True + self.reply_text(chat_id, open_id, f"正在使用盘搜搜索:{arg}") + self._run_thread("feishu-pansou-search", self._run_assistant_route, f"盘搜搜索 {arg}", cache_key, chat_id, open_id) + return True + + if cmd in {"/smart_entry", "/quark_save"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:处理 片名 或 处理 分享链接") + return True + self.reply_text(chat_id, open_id, f"正在智能处理:{arg}") + self._run_thread("feishu-smart-entry", self._run_assistant_route, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/smart_pick": + if not arg: + self.reply_text(chat_id, open_id, "用法:选择 序号\n示例:选择 1\n也支持:详情、审查、n 下一页") + return True + self.reply_text(chat_id, open_id, f"正在继续执行:{arg}") + self._run_thread("feishu-smart-pick", self._run_assistant_pick, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/p115_manual_transfer": + if not arg: + paths = self._get_p115_manual_transfer_paths() + if not paths: + self.reply_text(chat_id, open_id, "未配置待整理目录。请先在 P115StrmHelper 中配置 pan_transfer_paths,或发送:刮削 /待整理/") + return True + self.reply_text(chat_id, open_id, f"已开始刮削 {len(paths)} 个目录:\n" + "\n".join(f"- {path}" for path in paths)) + self._run_thread("feishu-p115-manual-transfer-batch", self._run_p115_manual_transfer_batch, paths, chat_id, open_id) + return True + self.reply_text(chat_id, open_id, f"已开始刮削:{arg}") + self._run_thread("feishu-p115-manual-transfer", self._run_p115_manual_transfer, arg, chat_id, open_id) + return True + + if cmd in {"/p115_inc_sync", "/p115_full_sync", "/p115_strm"}: + final_command = "/p115_full_sync" if cmd == "/p115_strm" and not arg else command_text + self._submit_p115_command(final_command, chat_id, open_id) + return True + + return False + + @staticmethod + def _run_thread(name: str, target: Any, *args: Any) -> None: + threading.Thread(target=target, args=args, name=name, daemon=True).start() + + def _run_assistant_route(self, text: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route(text=text, session=session) + self._reply_result(chat_id, open_id, result) + + def _run_assistant_pick(self, arg: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_pick(arg=arg, session=session) + self._reply_result(chat_id, open_id, result) + + def _reply_result(self, chat_id: str, open_id: str, result: Dict[str, Any]) -> None: + message = str(result.get("message") or "处理完成").strip() + self.reply_text(chat_id, open_id, message) + qrcode = self._find_nested_value(result.get("data"), "qrcode") + if isinstance(qrcode, str): + self.reply_qrcode_data_url(chat_id, open_id, qrcode) + + @classmethod + def _find_nested_value(cls, payload: Any, key: str) -> Any: + if isinstance(payload, dict): + if key in payload: + return payload.get(key) + for value in payload.values(): + found = cls._find_nested_value(value, key) + if found: + return found + elif isinstance(payload, list): + for value in payload: + found = cls._find_nested_value(value, key) + if found: + return found + return None + + def _run_media_search(self, keyword: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_search(keyword, self._cache_key(chat_id, open_id))) + + def _run_media_download(self, index: int, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route( + text=f"下载资源 {index}", + session=self._cache_key(chat_id, open_id), + ) + self._reply_result(chat_id, open_id, result) + + def _run_media_subscribe(self, keyword: str, immediate: bool, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_subscribe(keyword, immediate)) + + def _execute_media_search(self, keyword: str, cache_key: str) -> str: + if not all([MetaInfo, MediaChain, SearchChain, StringUtils]): + return "MP 原生搜索失败:当前环境缺少 MoviePilot 搜索依赖。" + try: + meta = MetaInfo(keyword) + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return f"未识别到媒体信息:{keyword}" + season = meta.begin_season if meta.begin_season else mediainfo.season + results = SearchChain().search_by_id( + tmdbid=mediainfo.tmdb_id, + doubanid=mediainfo.douban_id, + mtype=mediainfo.type, + season=season, + cache_local=False, + ) or [] + if not results: + return f"已识别 {self._format_media_label(mediainfo, season)},但暂未搜索到资源。" + self._set_search_cache(cache_key, keyword, mediainfo, results) + preview_limit = 20 + preview_results = results[:preview_limit] + lines = [ + f"已识别:{self._format_media_label(mediainfo, season)}", + f"共找到 {len(results)} 条资源,展示前 {len(preview_results)} 条:", + ] + for idx, context in enumerate(preview_results, start=1): + torrent = context.torrent_info + title = str(torrent.title or "").strip() + size = StringUtils.str_filesize(torrent.size) if torrent.size else "未知" + seeders = torrent.seeders if torrent.seeders is not None else "?" + site = torrent.site_name or "未知站点" + volume = torrent.volume_factor if getattr(torrent, "volume_factor", None) else "未知" + lines.append(f"{idx}. [{site}] {title}") + lines.append(f" 大小:{size} | 做种:{seeders} | 促销:{volume}") + lines.append("下一步:回复“下载资源 序号”会先生成下载计划,不会静默下载。") + lines.append("如需长期跟踪,回复“订阅媒体 片名”或“订阅并搜索 片名”。") + return "\n".join(lines) + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"搜索资源失败:{keyword}\n错误:{exc}" + + def _query_media_detail(self, keyword: str, media_type: str = "", year: str = "") -> Dict[str, Any]: + if not all([MetaInfo, MediaChain]): + return {"success": False, "message": "媒体识别失败:当前环境缺少 MoviePilot 媒体识别依赖。", "item": {}} + title_text = str(keyword or "").strip() + if not title_text: + return {"success": False, "message": "媒体识别失败:缺少片名。", "item": {}} + try: + meta = MetaInfo(title_text) + if year: + try: + meta.year = str(year) + except Exception: + pass + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return {"success": False, "message": f"未识别到媒体信息:{title_text}", "item": {"keyword": title_text}} + season = meta.begin_season if meta.begin_season else getattr(mediainfo, "season", None) + media_type_value = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type_value, "name", "") or str(media_type_value or "") + item = { + "keyword": title_text, + "title": str(getattr(mediainfo, "title", "") or ""), + "original_title": str(getattr(mediainfo, "original_title", "") or ""), + "year": str(getattr(mediainfo, "year", "") or ""), + "type": media_type_name, + "tmdb_id": getattr(mediainfo, "tmdb_id", None), + "douban_id": getattr(mediainfo, "douban_id", None), + "imdb_id": str(getattr(mediainfo, "imdb_id", "") or ""), + "season": season, + "category": str(getattr(mediainfo, "category", "") or ""), + "overview": str(getattr(mediainfo, "overview", "") or "")[:300], + } + lines = [ + f"媒体识别:{title_text}", + f"结果:{item.get('title') or '-'} ({item.get('year') or '-'})", + f"类型:{item.get('type') or '-'} | TMDB:{item.get('tmdb_id') or '-'} | 豆瓣:{item.get('douban_id') or '-'}", + ] + if item.get("original_title") and item.get("original_title") != item.get("title"): + lines.append(f"原标题:{item.get('original_title')}") + if season: + lines.append(f"季:S{int(season):02d}" if isinstance(season, int) else f"季:{season}") + if item.get("overview"): + lines.append(f"简介:{item.get('overview')}") + lines.append("说明:这是 MoviePilot 原生识别结果,后续 MP 搜索、订阅和 PT 评分会以它为准。") + return {"success": True, "message": "\n".join(lines), "item": item} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 媒体识别失败:{title_text} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"媒体识别失败:{exc}", "item": {"keyword": title_text}} + + def _execute_media_download(self, index: int, cache_key: str) -> str: + if DownloadChain is None: + return "下载资源失败:当前环境缺少 MoviePilot 下载依赖。" + cache = self._get_search_cache(cache_key) + if not cache: + return "没有可用的搜索缓存,请先发送:MP搜索 片名" + results = cache.get("results") or [] + if index < 1 or index > len(results): + return f"序号超出范围,请输入 1 到 {len(results)} 之间的数字。" + context = copy.deepcopy(results[index - 1]) + torrent = context.torrent_info + try: + save_path = "" + if self.plugin is not None: + save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip() + download_id = DownloadChain().download_single( + context=context, + username="agentresourceofficer-feishu", + source="AgentResourceOfficer", + save_path=save_path or None, + ) + if not download_id: + return f"下载提交失败:{torrent.title}" + path_line = f"\n保存路径:{save_path}" if save_path else "" + return f"已提交下载:{torrent.title}\n站点:{torrent.site_name or '未知站点'}{path_line}\n任务ID:{download_id}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}") + return f"下载资源失败:{torrent.title}\n错误:{exc}" + + def _query_download_tasks( + self, + *, + downloader: str = "", + status: str = "downloading", + title: str = "", + hash_value: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "查询下载任务失败:当前环境缺少 MoviePilot 下载依赖。", "items": []} + try: + chain = DownloadChain() + status_name = str(status or "downloading").strip().lower() + downloader_name = str(downloader or "").strip() or None + tasks: List[Any] = [] + if hash_value: + tasks = chain.list_torrents(downloader=downloader_name, hashs=[hash_value]) or [] + elif status_name == "downloading": + tasks = chain.downloading(name=downloader_name) or [] + else: + for torrent_status in [TorrentStatus.DOWNLOADING, TorrentStatus.TRANSFER] if TorrentStatus else []: + tasks.extend(chain.list_torrents(downloader=downloader_name, status=torrent_status) or []) + if status_name == "completed": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() in {"seeding", "completed"}] + elif status_name == "paused": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() == "paused"] + if title: + title_lower = title.lower() + tasks = [ + task for task in tasks + if title_lower in str(getattr(task, "title", "") or getattr(task, "name", "") or "").lower() + ] + items: List[Dict[str, Any]] = [] + for index, task in enumerate(tasks[:max(1, min(30, int(limit or 10)))], 1): + task_hash = str(getattr(task, "hash", "") or "") + history = DownloadHistoryOper().get_by_hash(task_hash) if DownloadHistoryOper and task_hash else None + title_text = str(getattr(task, "title", "") or getattr(task, "name", "") or "").strip() + if history and getattr(history, "title", None): + title_text = title_text or str(history.title) + size_value = getattr(task, "size", None) + size_text = StringUtils.str_filesize(size_value) if StringUtils and size_value else "" + progress = getattr(task, "progress", None) + try: + progress_text = f"{float(progress):.1f}%" if progress is not None else "" + except Exception: + progress_text = str(progress or "") + items.append({ + "index": index, + "hash": task_hash, + "hash_short": task_hash[:8], + "downloader": str(getattr(task, "downloader", "") or ""), + "title": title_text or "未命名任务", + "name": str(getattr(task, "name", "") or ""), + "size": size_text, + "progress": progress_text, + "state": str(getattr(task, "state", "") or ""), + "dlspeed": getattr(task, "dlspeed", None), + "upspeed": getattr(task, "upspeed", None), + "left_time": getattr(task, "left_time", None), + "tags": str(getattr(task, "tags", "") or ""), + "media_title": str(getattr(history, "title", "") or "") if history else "", + }) + status_label = { + "downloading": "下载中", + "completed": "已完成", + "paused": "已暂停", + "all": "全部", + }.get(status_name, status_name) + if not items: + return { + "success": True, + "message": f"未找到{status_label}下载任务。", + "items": [], + "total": len(tasks), + "status": status_name, + } + lines = [f"下载任务:{status_label},共 {len(tasks)} 条,展示前 {len(items)} 条:"] + for item in items: + details = [ + item.get("progress") or "进度未知", + item.get("size") or "大小未知", + item.get("state") or "状态未知", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('hash_short')}", + ] + lines.append(f"{item.get('index')}. {item.get('title')}") + lines.append(" " + " | ".join(details)) + lines.append("写入操作需确认:可发“暂停下载 1”“恢复下载 1”“删除下载 1”。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": len(tasks), + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载任务失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载任务失败:{exc}", "items": []} + + def _control_download_task( + self, + *, + action: str, + hash_value: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "操作下载任务失败:当前环境缺少 MoviePilot 下载依赖。"} + task_hash = str(hash_value or "").strip() + if len(task_hash) != 40 or not all(ch in "0123456789abcdefABCDEF" for ch in task_hash): + return {"success": False, "message": "操作下载任务失败:hash 格式无效,请先查询下载任务后按编号操作。"} + downloader_name = str(downloader or "").strip() or None + action_name = str(action or "").strip().lower() + try: + chain = DownloadChain() + if action_name in {"pause", "stop"}: + ok = chain.set_downloading(task_hash, "stop", name=downloader_name) + label = "暂停" + elif action_name in {"resume", "start"}: + ok = chain.set_downloading(task_hash, "start", name=downloader_name) + label = "恢复" + elif action_name in {"delete", "remove"}: + ok = chain.remove_torrents(hashs=[task_hash], downloader=downloader_name, delete_file=bool(delete_files)) + label = "删除" + else: + return {"success": False, "message": f"操作下载任务失败:不支持的动作 {action}"} + suffix = "(包含文件)" if action_name in {"delete", "remove"} and delete_files else "" + return { + "success": bool(ok), + "message": f"{label}下载任务{'成功' if ok else '失败'}:{task_hash[:8]}{suffix}", + "hash": task_hash, + "downloader": downloader_name or "", + "action": action_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作下载任务失败:{task_hash} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作下载任务失败:{exc}"} + + def _query_downloaders(self) -> Dict[str, Any]: + if SystemConfigOper is None or SystemConfigKey is None: + return {"success": False, "message": "查询下载器失败:当前环境缺少 MoviePilot 配置依赖。", "items": []} + try: + raw_items = SystemConfigOper().get(SystemConfigKey.Downloaders) or [] + items: List[Dict[str, Any]] = [] + for index, item in enumerate(raw_items, 1): + if not isinstance(item, dict): + continue + items.append({ + "index": index, + "name": str(item.get("name") or ""), + "type": str(item.get("type") or ""), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + }) + enabled = [item for item in items if item.get("enabled")] + if not items: + return {"success": True, "message": "未配置下载器。", "items": [], "enabled_count": 0} + lines = [f"下载器配置:共 {len(items)} 个,启用 {len(enabled)} 个"] + for item in items: + status = "启用" if item.get("enabled") else "停用" + default = ",默认" if item.get("default") else "" + lines.append(f"{item.get('index')}. {item.get('name') or '-'} | {item.get('type') or '-'} | {status}{default}") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "enabled_count": len(enabled), + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载器失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载器失败:{exc}", "items": []} + + def _query_sites(self, *, status: str = "active", name: str = "", limit: int = 30) -> Dict[str, Any]: + if SiteOper is None: + return {"success": False, "message": "查询站点失败:当前环境缺少 MoviePilot 站点依赖。", "items": []} + try: + status_name = str(status or "active").strip().lower() + name_filter = str(name or "").strip().lower() + sites = SiteOper().list_order_by_pri() or [] + items: List[Dict[str, Any]] = [] + for site in sites: + is_active = bool(getattr(site, "is_active", False)) + if status_name == "active" and not is_active: + continue + if status_name == "inactive" and is_active: + continue + site_name = str(getattr(site, "name", "") or "") + if name_filter and name_filter not in site_name.lower(): + continue + cookie = str(getattr(site, "cookie", "") or "") + items.append({ + "index": len(items) + 1, + "id": getattr(site, "id", None), + "name": site_name, + "domain": str(getattr(site, "domain", "") or ""), + "url": str(getattr(site, "url", "") or ""), + "pri": getattr(site, "pri", None), + "is_active": is_active, + "has_cookie": bool(cookie), + "downloader": str(getattr(site, "downloader", "") or ""), + "proxy": bool(getattr(site, "proxy", False)), + "timeout": getattr(site, "timeout", None), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 30)))] + label = {"active": "已启用", "inactive": "已停用", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{label}站点。", "items": [], "total": total} + lines = [f"PT 站点:{label},共 {total} 个,展示前 {len(items)} 个:"] + for item in items: + cookie_state = "有Cookie" if item.get("has_cookie") else "无Cookie" + active_state = "启用" if item.get("is_active") else "停用" + lines.append( + f"{item.get('index')}. {item.get('name') or '-'} | {item.get('domain') or '-'} | " + f"{active_state} | {cookie_state} | 优先级:{item.get('pri')} | 下载器:{item.get('downloader') or '默认'}" + ) + lines.append("说明:这里不会返回 Cookie 明文;如站点搜索失败,优先检查是否启用、Cookie 是否存在、站点绑定下载器是否可用。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询站点失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询站点失败:{exc}", "items": []} + + def _query_subscribes( + self, + *, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "查询订阅失败:当前环境缺少 MoviePilot 订阅依赖。", "items": []} + try: + status_name = str(status or "all").strip() + media_type_name = str(media_type or "all").strip().lower() + name_filter = str(name or "").strip().lower() + subscribes = SubscribeOper().list() or [] + items: List[Dict[str, Any]] = [] + for sub in subscribes: + state = str(getattr(sub, "state", "") or "") + if status_name != "all" and state != status_name: + continue + sub_type = str(getattr(sub, "type", "") or "").lower() + if media_type_name != "all" and media_type_name not in {sub_type, "movie" if sub_type == "电影" else sub_type, "tv" if sub_type == "电视剧" else sub_type}: + continue + title = str(getattr(sub, "name", "") or "") + if name_filter and name_filter not in title.lower(): + continue + items.append({ + "index": len(items) + 1, + "id": getattr(sub, "id", None), + "name": title or "未命名订阅", + "year": str(getattr(sub, "year", "") or ""), + "type": str(getattr(sub, "type", "") or ""), + "season": getattr(sub, "season", None), + "state": state, + "total_episode": getattr(sub, "total_episode", None), + "lack_episode": getattr(sub, "lack_episode", None), + "start_episode": getattr(sub, "start_episode", None), + "quality": str(getattr(sub, "quality", "") or ""), + "resolution": str(getattr(sub, "resolution", "") or ""), + "effect": str(getattr(sub, "effect", "") or ""), + "include": str(getattr(sub, "include", "") or ""), + "exclude": str(getattr(sub, "exclude", "") or ""), + "sites": getattr(sub, "sites", None), + "downloader": str(getattr(sub, "downloader", "") or ""), + "save_path": str(getattr(sub, "save_path", "") or ""), + "best_version": getattr(sub, "best_version", None), + "tmdbid": getattr(sub, "tmdbid", None), + "doubanid": str(getattr(sub, "doubanid", "") or ""), + "last_update": str(getattr(sub, "last_update", "") or ""), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 20)))] + status_label = {"R": "启用", "S": "暂停", "P": "待处理", "N": "完成", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{status_label}订阅。", "items": [], "total": total} + lines = [f"MP 订阅:{status_label},共 {total} 条,展示前 {len(items)} 条:"] + for item in items: + season = f" S{int(item.get('season')):02d}" if item.get("season") else "" + lack = item.get("lack_episode") + lack_text = f"缺 {lack} 集" if lack not in (None, "", 0) else "无缺集" + filters = " / ".join(value for value in [item.get("resolution"), item.get("effect"), item.get("quality")] if value) or "默认规则" + lines.append(f"{item.get('index')}. #{item.get('id')} {item.get('name')} ({item.get('year') or '-'}){season}") + lines.append(f" 状态:{item.get('state') or '-'} | {lack_text} | 规则:{filters} | 下载器:{item.get('downloader') or '默认'}") + lines.append("写入操作需确认:可发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”“删除订阅 1”。") + return {"success": True, "message": "\n".join(lines), "items": items, "total": total, "status": status_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询订阅失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询订阅失败:{exc}", "items": []} + + def _control_subscribe(self, *, action: str, subscribe_id: int) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "操作订阅失败:当前环境缺少 MoviePilot 订阅依赖。"} + sid = int(subscribe_id or 0) + if sid <= 0: + return {"success": False, "message": "操作订阅失败:订阅 ID 无效。"} + action_name = str(action or "").strip().lower() + try: + oper = SubscribeOper() + sub = oper.get(sid) + if not sub: + return {"success": False, "message": f"操作订阅失败:订阅 #{sid} 不存在。"} + old_info = sub.to_dict() if hasattr(sub, "to_dict") else {} + if action_name in {"search", "run"}: + if Scheduler is None: + return {"success": False, "message": "搜索订阅失败:当前环境缺少调度器。"} + Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}) + return {"success": True, "message": f"已触发订阅搜索:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + if action_name in {"pause", "stop"}: + updated = oper.update(sid, {"state": "S"}) + label = "暂停" + elif action_name in {"resume", "start"}: + updated = oper.update(sid, {"state": "R"}) + label = "恢复" + elif action_name in {"delete", "remove"}: + sub_name = str(getattr(sub, "name", "") or "") + sub_year = str(getattr(sub, "year", "") or "") + oper.delete(sid) + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeDeleted, {"subscribe_id": sid, "subscribe_info": old_info}) + if SubscribeHelper: + SubscribeHelper().sub_done_async({"tmdbid": getattr(sub, "tmdbid", None), "doubanid": getattr(sub, "doubanid", None)}) + return {"success": True, "message": f"成功删除订阅:#{sid} {sub_name} ({sub_year})", "subscribe_id": sid, "action": action_name} + else: + return {"success": False, "message": f"操作订阅失败:不支持的动作 {action}"} + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeModified, { + "subscribe_id": sid, + "old_subscribe_info": old_info, + "subscribe_info": updated.to_dict() if updated and hasattr(updated, "to_dict") else {}, + }) + return {"success": True, "message": f"{label}订阅成功:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作订阅失败:{sid} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作订阅失败:{exc}", "subscribe_id": sid} + + @staticmethod + def _path_preview(value: Any, max_parts: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = text.replace("\\", "/") + parts = [part for part in normalized.split("/") if part] + if len(parts) <= max_parts: + return normalized + prefix = "/" if normalized.startswith("/") else "" + return f"{prefix}.../" + "/".join(parts[-max_parts:]) + + @staticmethod + def _transfer_status_bool(status: str) -> Optional[bool]: + name = str(status or "all").strip().lower() + if name in {"success", "succeeded", "ok", "true", "成功", "已成功"}: + return True + if name in {"failed", "fail", "error", "false", "失败", "错误"}: + return False + return None + + def _query_download_history( + self, + *, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if DownloadHistory is None or DownloadHistoryOper is None: + return {"success": False, "message": "查询下载历史失败:当前环境缺少 MoviePilot 下载历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + title_text = str(title or "").strip() + hash_text = str(hash_value or "").strip() + oper = DownloadHistoryOper() + db = getattr(oper, "_db", None) + if db is None: + records = oper.list_by_page(page=1, count=500) or [] + if title_text: + title_lower = title_text.lower() + records = [ + item for item in records + if title_lower in str(getattr(item, "title", "") or "").lower() + or title_lower in str(getattr(item, "torrent_name", "") or "").lower() + or title_lower in str(getattr(item, "path", "") or "").lower() + ] + if hash_text: + records = [ + item for item in records + if str(getattr(item, "download_hash", "") or "").lower().startswith(hash_text.lower()) + ] + total = len(records) + selected_records = records[(page_num - 1) * page_size:(page_num - 1) * page_size + page_size] + else: + query = db.query(DownloadHistory) + if title_text: + like = f"%{title_text}%" + query = query.filter( + DownloadHistory.title.like(like) + | DownloadHistory.torrent_name.like(like) + | DownloadHistory.path.like(like) + ) + if hash_text: + query = query.filter(DownloadHistory.download_hash.like(f"{hash_text}%")) + query = query.order_by(DownloadHistory.date.desc(), DownloadHistory.id.desc()) + total = query.count() + selected_records = query.offset((page_num - 1) * page_size).limit(page_size).all() + + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=(page_num - 1) * page_size + 1): + task_hash = str(getattr(record, "download_hash", "") or "") + transfer_records = TransferHistory.list_by_hash(download_hash=task_hash) if TransferHistory is not None and task_hash else [] + transfer_success = any(bool(getattr(item, "status", False)) for item in transfer_records or []) + transfer_failed = any(not bool(getattr(item, "status", False)) for item in transfer_records or []) + if transfer_success: + transfer_status = "success" + transfer_status_text = "已入库" + elif transfer_failed: + transfer_status = "failed" + transfer_status_text = "整理失败" + else: + transfer_status = "none" + transfer_status_text = "未见整理记录" + transfer_dest = "" + transfer_error = "" + if transfer_records: + first_transfer = transfer_records[0] + transfer_dest = self._path_preview(getattr(first_transfer, "dest", "")) + transfer_error = str(getattr(first_transfer, "errmsg", "") or "")[:300] + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": str(getattr(record, "type", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash": task_hash, + "download_hash_short": task_hash[:8], + "torrent_name": str(getattr(record, "torrent_name", "") or ""), + "torrent_site": str(getattr(record, "torrent_site", "") or ""), + "username": str(getattr(record, "username", "") or ""), + "channel": str(getattr(record, "channel", "") or ""), + "path_preview": self._path_preview(getattr(record, "path", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + "transfer_status": transfer_status, + "transfer_status_text": transfer_status_text, + "transfer_count": len(transfer_records or []), + "transfer_dest_preview": transfer_dest, + } + if transfer_error and transfer_status == "failed": + item["transfer_error"] = transfer_error + items.append(item) + + title_label = f":{title_text or hash_text}" if title_text or hash_text else "" + if not items: + return { + "success": True, + "message": f"未找到下载历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + } + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"下载历史{title_label}:第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + details = [ + item.get("date") or "-", + f"站点:{item.get('torrent_site') or '-'}", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('download_hash_short') or '-'}", + f"整理:{item.get('transfer_status_text')}", + ] + lines.append(" " + " | ".join(details)) + if item.get("path_preview"): + lines.append(f" 保存:{item.get('path_preview')}") + if item.get("transfer_dest_preview"): + lines.append(f" 入库:{item.get('transfer_dest_preview')}") + if item.get("transfer_error"): + lines.append(f" 整理错误:{item.get('transfer_error')}") + lines.append("说明:这是只读查询,用于追踪下载提交后是否进入整理流程。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载历史失败:{exc}", "items": []} + + def _query_transfer_history( + self, + *, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if TransferHistory is None: + return {"success": False, "message": "查询整理历史失败:当前环境缺少 MoviePilot 整理历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + status_bool = self._transfer_status_bool(status) + title_text = str(title or "").strip() + search_text = title_text + if title_text and jieba is not None: + try: + search_text = "%".join(jieba.cut(title_text, HMM=False)) + except Exception: + search_text = title_text + + if search_text: + records = TransferHistory.list_by_title(title=search_text, page=1, count=-1, status=None) or [] + if status_bool is not None: + records = [item for item in records if bool(getattr(item, "status", False)) is status_bool] + else: + records = TransferHistory.list_by_page(page=1, count=-1, status=status_bool) or [] + + total = len(records) + start = (page_num - 1) * page_size + selected_records = records[start:start + page_size] + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=start + 1): + media_type = str(getattr(record, "type", "") or "") + if media_type_to_agent is not None: + try: + media_type = media_type_to_agent(media_type) + except Exception: + pass + status_ok = bool(getattr(record, "status", False)) + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": media_type, + "category": str(getattr(record, "category", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "mode": str(getattr(record, "mode", "") or ""), + "status": "success" if status_ok else "failed", + "status_text": "成功" if status_ok else "失败", + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash_short": str(getattr(record, "download_hash", "") or "")[:8], + "src_preview": self._path_preview(getattr(record, "src", "")), + "dest_preview": self._path_preview(getattr(record, "dest", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + } + errmsg = str(getattr(record, "errmsg", "") or "").strip() + if errmsg and not status_ok: + item["errmsg"] = errmsg[:300] + items.append(item) + + status_name = str(status or "all").strip().lower() + status_label = "成功" if status_bool is True else "失败" if status_bool is False else "全部" + title_label = f":{title_text}" if title_text else "" + if not items: + return { + "success": True, + "message": f"未找到{status_label}整理历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"整理历史{title_label}:{status_label},第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + label_parts = [ + item.get("status_text") or "-", + item.get("type") or "-", + item.get("mode") or "-", + item.get("date") or "-", + ] + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + lines.append(" " + " | ".join(label_parts)) + if item.get("dest_preview"): + lines.append(f" 目标:{item.get('dest_preview')}") + if item.get("errmsg"): + lines.append(f" 错误:{item.get('errmsg')}") + lines.append("说明:这是只读查询,用于判断下载后是否已经整理入库。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询整理历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询整理历史失败:{exc}", "items": []} + + def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: + if not all([MetaInfo, SubscribeChain]): + return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。" + meta = MetaInfo(keyword) + try: + sid, message = SubscribeChain().add( + title=keyword, + year=meta.year, + mtype=meta.type, + season=meta.begin_season, + username="agentresourceofficer-feishu", + exist_ok=True, + message=False, + ) + if not sid: + return f"订阅失败:{keyword}\n原因:{message}" + lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] + if immediate_search and Scheduler is not None: + Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}) + lines.append("已触发一次订阅搜索。") + return "\n".join(lines) + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"订阅失败:{keyword}\n错误:{exc}" + + @staticmethod + def _format_media_label(mediainfo: Any, season: Optional[int] = None) -> str: + title = getattr(mediainfo, "title", "") or "未知媒体" + year = getattr(mediainfo, "year", None) + label = f"{title} ({year})" if year else title + media_type = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type, "name", "") + if media_type_name == "TV" and season: + return f"{label} 第{season}季" + return label + + def _set_search_cache(self, cache_key: str, keyword: str, mediainfo: Any, results: List[Any]) -> None: + with self._search_cache_lock: + now = time.time() + expired_keys = [ + key + for key, item in self._search_cache.items() + if now - float((item or {}).get("ts") or 0) > 1800 + ] + for key in expired_keys: + self._search_cache.pop(key, None) + while len(self._search_cache) >= self._search_cache_limit: + oldest_key = min( + self._search_cache, + key=lambda key: float((self._search_cache.get(key) or {}).get("ts") or 0), + ) + self._search_cache.pop(oldest_key, None) + self._search_cache[cache_key] = { + "ts": now, + "keyword": keyword, + "mediainfo": mediainfo, + "results": list(results or []), + } + + def _get_search_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: + with self._search_cache_lock: + item = self._search_cache.get(cache_key) + if not item: + return None + if time.time() - float(item.get("ts") or 0) > 1800: + self._search_cache.pop(cache_key, None) + return None + return item + + def _run_p115_manual_transfer_batch(self, paths: List[str], chat_id: str, open_id: str) -> None: + summaries = [self._execute_p115_manual_transfer(path) for path in paths] + self.reply_text(chat_id, open_id, "\n\n".join(item for item in summaries if item)) + + def _run_p115_manual_transfer(self, path: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_p115_manual_transfer(path)) + + def _get_p115_manual_transfer_paths(self) -> List[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + raw = str(config.get("pan_transfer_paths") or "").strip() + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + logger.warning(f"[AgentResourceOfficer][Feishu] 获取待整理目录失败:{exc}") + return [] + + def _execute_p115_manual_transfer(self, path: str) -> str: + log_path = Path("/config/logs/plugins/P115StrmHelper.log") + log_offset = self._safe_log_offset(log_path) + try: + service_module = importlib.import_module("app.plugins.p115strmhelper.service") + servicer = getattr(service_module, "servicer", None) + if not servicer or not getattr(servicer, "monitorlife", None): + return "刮削失败:P115StrmHelper 未初始化或未启用。" + result = servicer.monitorlife.once_transfer(path) + summary = self._format_p115_manual_transfer_result(result) + return summary or self._build_p115_manual_transfer_summary(log_path, log_offset, path) or f"刮削完成:{path}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 手动刮削失败:{path} {exc}\n{traceback.format_exc()}") + return f"刮削失败:{path}\n错误:{exc}" + + def _format_p115_manual_transfer_result(self, result: Any) -> Optional[str]: + if not isinstance(result, dict): + return None + path = result.get("path") or "" + failed_items = result.get("failed_items") or [] + lines = [ + f"刮削完成:{path}", + f"总计:{result.get('total', 0)} 个项目(文件 {result.get('files', 0)},文件夹 {result.get('dirs', 0)})", + f"成功:{result.get('success', 0)} 个", + f"失败:{result.get('failed', 0)} 个", + f"跳过:{result.get('skipped', 0)} 个", + ] + if result.get("error"): + lines.append(f"错误:{result.get('error')}") + if failed_items: + lines.append("失败示例:") + lines.extend(f"- {item}" for item in failed_items[:3]) + if len(failed_items) > 3: + lines.append(f"- 还有 {len(failed_items) - 3} 项未展示") + lines.extend(self._p115_strm_followup_lines(path)) + return "\n".join(lines) + + def _p115_strm_followup_lines(self, path: str) -> List[str]: + hint = self._get_p115_strm_hint_path() or path + return [ + "如需增量生成 STRM,请再发送:生成STRM", + "如需按全部媒体库全量生成,请再发送:全量STRM", + f"如需指定路径全量生成,请再发送:指定路径STRM {hint}", + ] + + def _get_p115_strm_hint_path(self) -> Optional[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + paths = str(config.get("full_sync_strm_paths") or "").strip() + first_line = next((line.strip() for line in paths.splitlines() if line.strip()), "") + if not first_line: + return None + parts = first_line.split("#") + return parts[1].strip() if len(parts) >= 2 and parts[1].strip() else None + except Exception: + return None + + @staticmethod + def _safe_log_offset(log_path: Path) -> int: + try: + return log_path.stat().st_size if log_path.exists() else 0 + except Exception: + return 0 + + def _build_p115_manual_transfer_summary(self, log_path: Path, start_offset: int, path: str) -> Optional[str]: + try: + if not log_path.exists(): + return None + with log_path.open("r", encoding="utf-8", errors="ignore") as f: + f.seek(start_offset) + chunk = f.read() + if not chunk: + return None + path_re = re.escape(path) + pattern = re.compile( + rf"手动网盘整理完成 - 路径: {path_re}\n" + rf"\s*总计: (?P\d+) 个项目 \(文件: (?P\d+), 文件夹: (?P\d+)\)\n" + rf"\s*成功: (?P\d+) 个\n" + rf"\s*失败: (?P\d+) 个\n" + rf"\s*跳过: (?P\d+) 个", + re.S, + ) + match = pattern.search(chunk) + if not match: + return None + summary = ( + f"刮削完成:{path}\n" + f"总计:{match.group('total')} 个项目(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" + f"成功:{match.group('success')} 个\n" + f"失败:{match.group('failed')} 个\n" + f"跳过:{match.group('skipped')} 个" + ) + return summary + "\n" + "\n".join(self._p115_strm_followup_lines(path)) + except Exception: + return None + + def _submit_p115_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if PluginManager is not None: + try: + if not PluginManager().running_plugins.get("P115StrmHelper"): + self.reply_text(chat_id, open_id, "P115StrmHelper 未加载或未启用,无法执行 STRM 命令。") + return + except Exception: + pass + self._submit_moviepilot_command(command_text, chat_id, open_id) + + def _submit_moviepilot_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if eventmanager is None or EventType is None: + self.reply_text(chat_id, open_id, "当前环境缺少 MoviePilot 事件总线,无法转发该命令。") + return + eventmanager.send_event( + EventType.CommandExcute, + {"cmd": command_text, "source": None, "user": open_id or chat_id or "feishu"}, + ) + self.reply_text(chat_id, open_id, f"已接收命令:{command_text}\n任务已提交给 MoviePilot。") + + def _map_text_to_command(self, text: str) -> Optional[str]: + text = self._sanitize_text(text) + if not text: + return None + if text.startswith("/"): + return text + normalized = text.strip().lower() + if normalized in {"n", "next", "下一页", "下页"} or normalized.startswith("n "): + return f"/smart_pick {text}".strip() + shortcut_match = re.fullmatch(r"(\d+)(?:\s+(.+))?", text) + if shortcut_match: + rest = str(shortcut_match.group(2) or "").strip() + if not rest or "=" in rest or rest.startswith("/"): + return f"/smart_pick {text}".strip() + first_url = self.plugin._extract_first_url(text) + if first_url and (self.plugin._is_115_url(first_url) or self.plugin._is_quark_url(first_url)): + return f"/smart_entry {text}".strip() + + alias_map = self.parse_alias_text(self.command_aliases) + parts = text.split(maxsplit=1) + alias = parts[0] + rest = parts[1] if len(parts) > 1 else "" + target = alias_map.get(alias) + if not target: + for alias_key in sorted(alias_map.keys(), key=len, reverse=True): + if not text.startswith(alias_key): + continue + remain = text[len(alias_key):].strip() + target = alias_map.get(alias_key) + if target: + if target == "/smart_pick" and alias_key in {"详情", "审查"}: + return f"{target} {alias_key} {remain}".strip() + return f"{target} {remain}".strip() + return None + if target == "/smart_pick" and alias in {"详情", "审查"}: + return f"{target} {alias} {rest}".strip() + return f"{target} {rest}".strip() + + def _is_duplicate_event(self, event_id: str) -> bool: + now = time.time() + with self._event_lock: + expired = [key for key, ts in self._event_cache.items() if now - ts > 600] + for key in expired: + self._event_cache.pop(key, None) + if event_id in self._event_cache: + return True + self._event_cache[event_id] = now + return self._is_duplicate_event_cross_instance(event_id, now) + + @staticmethod + def _is_duplicate_event_cross_instance(event_id: str, now: float) -> bool: + try: + _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _EVENT_CACHE_FILE.touch(exist_ok=True) + with _EVENT_CACHE_FILE.open("r+", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + f.seek(0) + raw = f.read().strip() + cache = json.loads(raw) if raw else {} + cache = {key: ts for key, ts in cache.items() if isinstance(ts, (int, float)) and now - float(ts) <= 600} + if event_id in cache: + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + return True + cache[event_id] = now + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except Exception as exc: + logger.warning(f"[AgentResourceOfficer][Feishu] 跨实例事件去重失败:{exc}") + return False + + def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: + return bool( + self.allow_all + or (chat_id and chat_id in self.allowed_chat_ids) + or (user_open_id and user_open_id in self.allowed_user_ids) + ) + + @staticmethod + def _extract_text(content: Any) -> str: + if isinstance(content, dict): + return str(content.get("text") or "").strip() + if isinstance(content, str): + try: + payload = json.loads(content) + except json.JSONDecodeError: + return content.strip() + return str(payload.get("text") or "").strip() + return "" + + @staticmethod + def _sanitize_text(text: str) -> str: + text = re.sub(r"]*>.*?", " ", text or "", flags=re.IGNORECASE) + return re.sub(r"\s+", " ", text).strip() + + @staticmethod + def _is_help_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"帮助", "/help", "help"} + + @staticmethod + def _is_menu_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"菜单", "/menu", "menu", "面板", "控制面板"} + + def _build_help_text(self) -> str: + aliases = self.parse_alias_text(self.command_aliases) + alias_text = "\n".join(f"{key} -> {value}" for key, value in aliases.items()) or "未配置别名" + return ( + "可用命令:\n" + f"{', '.join(self.command_whitelist)}\n\n" + "别名:\n" + f"{alias_text}\n\n" + "快捷入口:发送“菜单”可查看可复制的快捷命令。" + ) + + @staticmethod + def _build_menu_text() -> str: + return ( + "快捷菜单\n" + "1. 云盘搜索 片名\n" + "2. 盘搜搜索 片名\n" + "3. 影巢搜索 片名\n" + "4. MP搜索 片名 / PT搜索 片名\n" + "5. 转存 片名(默认 115)\n" + "6. 夸克转存 片名\n" + "7. 下载 片名\n" + "8. 更新检查 片名\n" + "9. 选择 序号 / 详情 序号 / n\n" + "10. 115登录 / 115状态 / 115任务\n" + "11. 影巢签到 / 影巢签到日志" + ) + + @staticmethod + def _cache_key(chat_id: str, open_id: str) -> str: + return f"feishu::{chat_id or ''}::{open_id or ''}" + + @staticmethod + def _brief_response_error(data: Any) -> str: + if not isinstance(data, dict): + return "body=" + code = str(data.get("code") or "").strip() + msg = str(data.get("msg") or data.get("message") or "").strip() + parts: List[str] = [] + if code: + parts.append(f"code={code}") + if msg: + parts.append(f"msg={msg}") + return " ".join(parts) if parts else "body=" + + def reply_text(self, chat_id: str, open_id: str, text: str) -> None: + if not self.reply_enabled or not self.app_id or not self.app_secret: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token or RequestUtils is None: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.reply_receive_id_type}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + } + payload = { + "receive_id": receive_id, + "msg_type": "text", + "content": json.dumps({"text": text}, ensure_ascii=False), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[AgentResourceOfficer][Feishu] 发送文本失败:无响应") + return + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[AgentResourceOfficer][Feishu] 发送文本失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def reply_qrcode_data_url(self, chat_id: str, open_id: str, data_url: str) -> None: + text = str(data_url or "").strip() + if not text.startswith("data:image/") or ";base64," not in text: + return + _, _, payload = text.partition(";base64,") + try: + image_bytes = b64decode(payload) + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 解码二维码失败:{exc}") + return + image_key = self._upload_image(image_bytes=image_bytes, file_name="p115-qrcode.png") + if image_key: + self._reply_image(chat_id, open_id, image_key) + + def _upload_image(self, image_bytes: bytes, file_name: str) -> Optional[str]: + if not image_bytes or RequestUtils is None: + return None + access_token = self._get_tenant_access_token() + if not access_token: + return None + response = RequestUtils(headers={"Authorization": f"Bearer {access_token}"}).post( + url="https://open.feishu.cn/open-apis/im/v1/images", + data={"image_type": "message"}, + files={"image": (file_name, image_bytes, "image/png")}, + ) + if response is None: + logger.error("[AgentResourceOfficer][Feishu] 上传图片失败:无响应") + return None + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[AgentResourceOfficer][Feishu] 上传图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + return None + return str(((data.get("data") or {}).get("image_key")) or "").strip() or None + + def _reply_image(self, chat_id: str, open_id: str, image_key: str) -> None: + if not image_key or RequestUtils is None: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.reply_receive_id_type}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + } + payload = { + "receive_id": receive_id, + "msg_type": "image", + "content": json.dumps({"image_key": image_key}, ensure_ascii=False), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[AgentResourceOfficer][Feishu] 发送图片失败:无响应") + return + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[AgentResourceOfficer][Feishu] 发送图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def _get_tenant_access_token(self) -> Optional[str]: + if RequestUtils is None: + return None + now = time.time() + with self._token_lock: + token = self._token_cache.get("token") + expires_at = float(self._token_cache.get("expires_at") or 0) + if token and now < expires_at - 60: + return token + response = RequestUtils(content_type="application/json").post( + url="https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/", + json={"app_id": self.app_id, "app_secret": self.app_secret}, + ) + if response is None: + logger.error("[AgentResourceOfficer][Feishu] 获取 tenant_access_token 失败:无响应") + return None + try: + data = response.json() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] token 响应解析失败:{exc}") + return None + token = data.get("tenant_access_token") + expire = int(data.get("expire") or 0) + if not token: + logger.error( + f"[AgentResourceOfficer][Feishu] token 缺失:{self._brief_response_error(data)}" + ) + return None + self._token_cache = {"token": token, "expires_at": now + expire} + return token diff --git a/AgentResourceOfficer/requirements.txt b/AgentResourceOfficer/requirements.txt new file mode 100644 index 0000000..e892782 --- /dev/null +++ b/AgentResourceOfficer/requirements.txt @@ -0,0 +1,3 @@ +requests +cloudscraper +lark-oapi==1.5.3 diff --git a/AgentResourceOfficer/schemas.py b/AgentResourceOfficer/schemas.py new file mode 100644 index 0000000..ace4d1b --- /dev/null +++ b/AgentResourceOfficer/schemas.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class HDHiveSearchSessionToolInput(BaseModel): + keyword: str = Field(..., description="要搜索的影片或剧集名称") + media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto") + year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录") + + +class HDHiveSessionPickToolInput(BaseModel): + session_id: str = Field(..., description="上一步搜索返回的会话 ID") + choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + + +class ShareRouteToolInput(BaseModel): + url: str = Field(..., description="115 或夸克分享链接") + path: Optional[str] = Field(default=None, description="目标目录") + access_code: Optional[str] = Field(default=None, description="提取码,可选") + + +class AssistantRouteToolInput(BaseModel): + text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接") + session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录") + mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive") + keyword: Optional[str] = Field(default=None, description="结构化搜索关键词") + url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克") + access_code: Optional[str] = Field(default=None, description="结构化提取码") + media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv") + year: Optional[str] = Field(default=None, description="结构化年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPickToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantHelpToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantSessionStateToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantSessionClearToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantCapabilitiesToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantReadinessToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class FeishuChannelHealthToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPulseToolInput(BaseModel): + pass + + +class AssistantStartupToolInput(BaseModel): + pass + + +class AssistantMaintainToolInput(BaseModel): + execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议") + limit: Optional[int] = Field(default=100, description="单次最多清理多少条") + + +class AssistantToolboxToolInput(BaseModel): + pass + + +class AssistantRequestTemplatesToolInput(BaseModel): + limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制") + names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run") + recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap") + include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略") + + +class AssistantSelfcheckToolInput(BaseModel): + pass + + +class AssistantHistoryToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录") + + +class AssistantExecuteActionToolInput(BaseModel): + name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115") + session: Optional[str] = Field(default="default", description="可选会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: Optional[int] = Field(default=None, description="需要选择编号时传入") + path: Optional[str] = Field(default=None, description="可选目标目录") + keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词") + media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型") + year: Optional[str] = Field(default=None, description="搜索类动作使用的年份") + url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接") + access_code: Optional[str] = Field(default=None, description="可选提取码") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤") + has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话") + stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话") + all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话") + limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数") + plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id") + prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecuteActionsToolInput(BaseModel): + actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body") + session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantWorkflowToolInput(BaseModel): + name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status") + session: Optional[str] = Field(default="default", description="工作流会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + keyword: Optional[str] = Field(default=None, description="搜索关键词") + choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1") + candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号") + resource_choice: Optional[int] = Field(default=None, description="影巢资源编号") + path: Optional[str] = Field(default=None, description="可选目标目录") + url: Optional[str] = Field(default=None, description="分享链接") + access_code: Optional[str] = Field(default=None, description="提取码") + media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv") + mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou") + year: Optional[str] = Field(default=None, description="年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + limit: Optional[int] = Field(default=20, description="推荐数量上限") + dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPreferencesToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好") + preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取") + reset: Optional[bool] = Field(default=False, description="是否重置偏好画像") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecutePlanToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划") + session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPlansToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行") + include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条计划") + + +class AssistantPlansClearToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条") + session: Optional[str] = Field(default=None, description="可选会话名;按会话清理") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行") + all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class AssistantRecoverToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议") + prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段") + limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话") + + +class AssistantSessionsToolInput(BaseModel): + kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要") + + +class AssistantSessionsClearToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话") + kind: Optional[str] = Field(default=None, description="按会话类型批量清理") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话") + stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话") + all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class P115QRCodeStartToolInput(BaseModel): + client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini") + + +class P115QRCodeCheckToolInput(BaseModel): + uid: str = Field(..., description="上一步二维码返回的 uid") + time: str = Field(..., description="上一步二维码返回的 time") + sign: str = Field(..., description="上一步二维码返回的 sign") + client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致") + + +class P115StatusToolInput(BaseModel): + pass + + +class P115PendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话") + + +class P115ResumePendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务") + + +class P115CancelPendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务") diff --git a/AgentResourceOfficer/services/__init__.py b/AgentResourceOfficer/services/__init__.py new file mode 100644 index 0000000..4c1538f --- /dev/null +++ b/AgentResourceOfficer/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for Agent影视助手.""" diff --git a/AgentResourceOfficer/services/hdhive_openapi.py b/AgentResourceOfficer/services/hdhive_openapi.py new file mode 100644 index 0000000..970c5ff --- /dev/null +++ b/AgentResourceOfficer/services/hdhive_openapi.py @@ -0,0 +1,1113 @@ +from datetime import datetime +import base64 +import json +import re +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote +from zoneinfo import ZoneInfo + +import requests + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +try: + from app.core.config import settings +except Exception: + settings = None + + +class HDHiveOpenApiService: + """Reusable HDHive execution layer for Agent影视助手.""" + + _signin_action_name = "checkIn" + _signin_router_tree = ["", {"children": ["(app)", {"children": ["__PAGE__", {}, None, None]}, None, None]}, None, None, True] + _login_api_candidates = [ + "/api/customer/user/login", + "/api/customer/auth/login", + ] + _login_page = "/login" + _login_action_router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + _login_action_fallback = "602b5a3af7ab2e93be6a14001ca83c1be491ccecea" + + def __init__( + self, + *, + api_key: str = "", + base_url: str = "https://hdhive.com", + timeout: int = 30, + ) -> None: + self.api_key = self.normalize_text(api_key) + self.base_url = (self.normalize_text(base_url) or "https://hdhive.com").rstrip("/") + self.timeout = self.safe_int(timeout, 30) + self._login_action_id = "" + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + def api_url(self, path: str) -> str: + return f"{self.base_url.rstrip('/')}{path}" + + def tmdb_web_search_url(self, media_type: str, keyword: str) -> str: + query = quote(keyword) + if media_type == "movie": + return f"https://www.themoviedb.org/search/movie?query={query}" + if media_type == "tv": + return f"https://www.themoviedb.org/search/tv?query={query}" + return f"https://www.themoviedb.org/search?query={query}" + + def tmdb_web_search_headers(self) -> Dict[str, str]: + return { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + @staticmethod + def extract_year_from_release(value: Any) -> str: + match = re.search(r"(19|20)\d{2}", str(value or "")) + return match.group(0) if match else "" + + def tmdb_web_search_candidates( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[List[Dict[str, Any]], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + search_order = [media_type] if media_type in {"movie", "tv"} else ["tv", "movie"] + pattern = re.compile( + r'href="/(?Ptv|movie)/(?P\d+)"[^>]*>\s*' + r']*>\s*' + r'(?P<title>[^]*srcset="(?P[^"]*)"[^>]*src="(?P[^"]+)"[^>]*>' + r'.*?(?P[^<]+)', + re.S, + ) + candidates: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + errors: List[str] = [] + for search_type in search_order: + try: + response = requests.get( + self.tmdb_web_search_url(search_type, keyword), + headers=self.tmdb_web_search_headers(), + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + response.raise_for_status() + except Exception as exc: + errors.append(f"{search_type}:{exc}") + continue + html = response.text or "" + for match in pattern.finditer(html): + item_type = self.normalize_text(match.group("media_type")).lower() + tmdb_id = self.normalize_text(match.group("tmdb_id")) + if not tmdb_id or tmdb_id in seen_ids: + continue + item_year = self.extract_year_from_release(match.group("release")) + if year and item_year and item_year != year: + continue + seen_ids.add(tmdb_id) + candidates.append( + { + "title": self.normalize_text(match.group("title")), + "year": item_year, + "media_type": item_type or search_type, + "tmdb_id": tmdb_id, + "poster_path": self.normalize_text(match.group("src")), + } + ) + if len(candidates) >= candidate_limit: + return candidates, "" + return candidates, ";".join(errors) + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self.api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self.api_url(path), + headers=self.base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 if pan == "quark" else 2 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def resolve_candidates_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + type_filter = "" if media_type in {"auto", "all", "*"} else media_type + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if type_filter and type_filter not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie、tv 或 auto", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie、tv 或 auto" + chain_error = "" + medias = [] + if MediaChain is None: + chain_error = "MoviePilot MediaChain 不可用" + else: + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + chain_error = f"TMDB 解析失败: {exc}" + try: + medias = list(medias or []) + except Exception: + medias = [] + + candidates: List[Dict[str, Any]] = [] + for media in medias: + item_type = self.media_type_text(getattr(media, "type", "")) + item_year = self.normalize_text(getattr(media, "year", "")) + if type_filter and item_type and item_type != type_filter: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or type_filter or "movie", + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + fallback_used = False + fallback_message = "" + if not candidates: + web_candidates, web_error = self.tmdb_web_search_candidates( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if web_candidates: + candidates = web_candidates + fallback_used = True + else: + fallback_message = web_error + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(candidates), + "status_code": 200 if candidates else 404, + "message": "success" if candidates else "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "meta": { + "total": len(candidates), + "candidate_source": "tmdb_web_search" if fallback_used else "mediainfo_chain", + }, + } + if fallback_used: + result["fallback_reason"] = chain_error or "MediaChain 未返回候选" + elif chain_error: + result["chain_warning"] = chain_error + if not candidates and fallback_message: + result["fallback_error"] = fallback_message + if chain_error: + result["message"] = f"{chain_error};TMDB 网页搜索兜底也未命中" + elif not candidates and chain_error: + result["message"] = chain_error + return bool(candidates), result, result["message"] + + def search_resources(self, media_type: str, tmdb_id: str) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self.normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self.request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + return ok, result, message + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + result_limit: int = 12, + ) -> Tuple[bool, Dict[str, Any], str]: + result_limit = min(50, max(1, self.safe_int(result_limit, 12))) + ok, candidate_result, candidate_message = await self.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if not ok: + result = dict(candidate_result) + result["data"] = [] + return False, result, candidate_message + candidates = candidate_result.get("candidates") or [] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self.normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self.resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + return bool(merged_items), result, result["message"] + + def unlock_resource(self, slug: str) -> Tuple[bool, Dict[str, Any], str]: + slug = self.normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self.request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_me(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/me") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_usage_today(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/usage/today") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_weekly_free_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = bool(is_gambler) + payload = {"is_gambler": True} if gambler_mode else None + ok, result_payload, message, status_code = self.request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + if ok: + status_text = "签到成功" if checked_in else "今日已签到" + else: + status_text = "签到失败" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + return ok, result, message + + @staticmethod + def parse_cookie_string(cookie_str: Optional[str]) -> Dict[str, str]: + cookies: Dict[str, str] = {} + if not cookie_str: + return cookies + for cookie_item in str(cookie_str).split(";"): + if "=" in cookie_item: + name, value = cookie_item.strip().split("=", 1) + cookies[name] = value + return cookies + + @staticmethod + def _decode_token_user_id(token: str) -> str: + if not token or "." not in token: + return "" + try: + payload = token.split(".", 2)[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8", "ignore") + data = json.loads(decoded) + return str(data.get("user_id") or data.get("sub") or data.get("id") or "").strip() + except Exception: + return "" + + @staticmethod + def _cookie_string_from_mapping(cookies: Dict[str, str]) -> str: + token_cookie = str((cookies or {}).get("token") or "").strip() + csrf_cookie = str((cookies or {}).get("csrf_access_token") or "").strip() + if not token_cookie: + return "" + cookie_items = [f"token={token_cookie}"] + if csrf_cookie: + cookie_items.append(f"csrf_access_token={csrf_cookie}") + return "; ".join(cookie_items) + + @classmethod + def _extract_login_action_id_from_text(cls, text: str) -> str: + patterns = [ + r'next-action"\s*:\s*"([a-fA-F0-9]{16,64})"', + r'name="next-action"\s+value="([a-fA-F0-9]{16,64})"', + r'createServerReference\("([a-f0-9]{40,})"[^\\n]+?"login"\)', + ] + for pattern in patterns: + match = re.search(pattern, text or "") + if match: + return str(match.group(1) or "").strip() + return "" + + def _discover_login_action_id(self, warm_text: str, scraper: Any) -> str: + if self._login_action_id: + return self._login_action_id + + action_id = self._extract_login_action_id_from_text(warm_text) + if action_id: + self._login_action_id = action_id + return action_id + + script_paths = re.findall( + r']+src="([^"]+/app/\(auth\)/login/page-[^"]+\.js)"', + warm_text or "", + ) + for script_path in script_paths: + script_url = script_path if script_path.startswith("http") else f"{self.base_url}{script_path}" + try: + resp = scraper.get( + script_url, + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Referer": f"{self.base_url}{self._login_page}", + "Accept": "*/*", + }, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + action_id = self._extract_login_action_id_from_text(getattr(resp, "text", "") or "") + if action_id: + self._login_action_id = action_id + return action_id + + self._login_action_id = self._login_action_fallback + return self._login_action_id + + @staticmethod + def _parse_server_action_error(response_text: str) -> str: + if not response_text: + return "" + try: + for line in response_text.splitlines(): + line = line.strip() + if not line.startswith("1:"): + continue + payload = json.loads(line[2:]) + error = payload.get("error") or {} + message = str(error.get("message") or "").strip() + description = str(error.get("description") or "").strip() + if message or description: + return f"{message} ({description})" if description and description != message else (message or description) + except Exception: + return "" + return "" + + def login_for_cookie(self, *, username: str, password: str) -> Tuple[bool, str, str]: + username = self.normalize_text(username) + password = self.normalize_text(password) + if not username or not password: + return False, "", "未配置影巢用户名或密码,无法自动刷新 Cookie" + + try: + import cloudscraper + scraper = cloudscraper.create_scraper() + except Exception: + scraper = requests + + login_url = f"{self.base_url}{self._login_page}" + warm_text = "" + try: + resp_warm = scraper.get( + login_url, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + warm_text = getattr(resp_warm, "text", "") or "" + except Exception: + pass + if "系统维护中" in warm_text or "maintenance" in warm_text.lower(): + return False, "", "影巢站点当前处于维护页,暂时无法自动登录刷新 Cookie" + + for path in self._login_api_candidates: + url = f"{self.base_url}{path}" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "application/json", + } + payload = {"username": username, "password": password} + try: + resp = scraper.post( + url, + headers=headers, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + + cookies_dict: Dict[str, str] = {} + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "API 登录成功" + + try: + data = resp.json() + except Exception: + data = {} + meta = (data.get("meta") or {}) if isinstance(data, dict) else {} + access_token = str(meta.get("access_token") or "").strip() + refresh_token = str(meta.get("refresh_token") or "").strip() + if access_token: + cookie_items = [f"token={access_token}"] + if refresh_token: + cookie_items.append(f"refresh_token={refresh_token}") + return True, "; ".join(cookie_items), "API 登录成功" + + action_id = self._discover_login_action_id(warm_text, scraper) + if action_id: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "text/plain;charset=UTF-8", + "next-action": action_id, + "next-router-state-tree": self._login_action_router_state, + } + body = json.dumps([{"username": username, "password": password}, "/"], separators=(",", ":")) + try: + resp = scraper.post( + login_url, + headers=headers, + data=body, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + resp = None + server_action_message = f"Server Action 登录请求异常: {exc}" + else: + server_action_message = "" + if resp is not None: + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "Server Action 登录成功" + action_error = self._parse_server_action_error(getattr(resp, "text", "") or "") + if action_error: + server_action_message = action_error + else: + server_action_message = "未解析到登录 Action" + + try: + from playwright.sync_api import sync_playwright + except Exception: + return False, "", server_action_message or "自动登录失败,且 Playwright 不可用" + + try: + proxy = None + try: + proxy_config = getattr(settings, "PROXY", None) if settings is not None else None + server = (proxy_config or {}).get("http") or (proxy_config or {}).get("https") + if server: + proxy = {"server": server} + except Exception: + proxy = None + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True, proxy=proxy) if proxy else pw.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout * 1000) + for selector in [ + "input[name='username']", + "input[name='email']", + "input[type='email']", + "input[placeholder*='邮箱']", + "input[placeholder*='email']", + "input[placeholder*='用户名']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, username) + break + except Exception: + continue + for selector in [ + "input[name='password']", + "input[type='password']", + "input[placeholder*='密码']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, password) + break + except Exception: + continue + try: + button = ( + page.query_selector("button[type='submit']") + or page.query_selector("button:has-text('登录')") + or page.query_selector("button:has-text('Login')") + ) + if button: + button.click() + else: + page.keyboard.press("Enter") + except Exception: + page.keyboard.press("Enter") + try: + page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + cookies = context.cookies() + context.close() + browser.close() + except Exception as exc: + return False, "", f"Playwright 自动登录失败: {exc}" + + cookie_map = {str(item.get("name") or ""): str(item.get("value") or "") for item in cookies or []} + cookie_string = self._cookie_string_from_mapping(cookie_map) + if cookie_string: + return True, cookie_string, "Playwright 登录成功" + return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie" + + @classmethod + def _build_signin_tree_header(cls) -> str: + return quote(json.dumps(cls._signin_router_tree, separators=(",", ":"))) + + @staticmethod + def _build_signin_action_body(is_gambler: bool) -> str: + return json.dumps([bool(is_gambler)], separators=(",", ":")) + + @staticmethod + def _normalize_response_text(text: str) -> str: + if not text: + return "" + if "ä½" in text or "å·²" in text or "签到" in text: + try: + return text.encode("latin1", errors="ignore").decode("utf-8", errors="ignore") + except Exception: + return text + return text + + @classmethod + def _extract_signin_action_id_from_chunk(cls, chunk_text: str) -> str: + if not chunk_text: + return "" + patterns = [ + rf'createServerReference[\s\S]{{0,120}}?\("([a-f0-9]{{32,}})"[\s\S]{{0,1200}}?"{re.escape(cls._signin_action_name)}"', + rf'([a-f0-9]{{32,}}).{{0,240}}?"{re.escape(cls._signin_action_name)}"', + ] + for pattern in patterns: + match = re.search(pattern, chunk_text, re.S) + if match: + return match.group(1) + return "" + + @classmethod + def _parse_signin_action_response(cls, text: str) -> Tuple[bool, str]: + text = cls._normalize_response_text(text) + if not text: + return False, "签到响应为空" + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + _, payload = line.split(":", 1) + try: + data = json.loads(payload) + except Exception: + continue + if not isinstance(data, dict): + continue + if isinstance(data.get("response"), dict): + data = data["response"] + error = data.get("error") + if isinstance(error, dict): + message = cls._normalize_response_text(error.get("description") or error.get("message") or "签到失败") + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return False, message + message = cls._normalize_response_text(data.get("message") or data.get("description")) + success = data.get("success") + if message: + if success is False: + return False, message + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return True, message + return False, "签到响应格式异常" + + def _discover_signin_action_id(self, cookies: Dict[str, str], token: str, referer: str) -> str: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + try: + home_resp = requests.get( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception: + return "" + if home_resp.status_code != 200: + return "" + html = home_resp.text or "" + chunk_paths = list(dict.fromkeys(re.findall(r'/_next/static/chunks/[A-Za-z0-9._-]+\.js', html))) + for chunk_path in chunk_paths: + try: + chunk_resp = requests.get( + url=f"{self.base_url}{chunk_path}", + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/javascript,text/javascript,*/*;q=0.1", + "Connection": "close", + }, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=min(self.timeout, 20), + verify=False, + ) + except Exception: + continue + if chunk_resp.status_code != 200: + continue + action_id = self._extract_signin_action_id_from_chunk(chunk_resp.text or "") + if action_id: + return action_id + return "" + + def perform_legacy_web_checkin( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 400, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": "缺少可用的影巢网页 Cookie", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + if csrf_token: + headers["X-CSRF-TOKEN"] = csrf_token + + payload = {"is_gambler": True} if is_gambler else {} + try: + response = requests.post( + url=f"{self.base_url}/api/customer/user/checkin", + headers=headers, + cookies=cookies, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + verify=False, + ) + except Exception as exc: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"网页签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + try: + body = response.json() + except Exception: + body = {} + + message = "" + if isinstance(body, dict): + message = str(body.get("description") or body.get("message") or body.get("code") or "").strip() + if not message: + message = str(response.text or f"HTTP {response.status_code}").strip()[:200] + + lowered = message.lower() + already_signed = "已经签到" in message or "签到过" in message or "明天再来" in message + success = bool(response.status_code < 400 and (not isinstance(body, dict) or body.get("success") is not False)) + if already_signed: + success = True + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if already_signed else "签到成功" if success else "签到失败", + "message": message or ("签到成功" if success else f"HTTP {response.status_code}"), + "data": body if isinstance(body, dict) else {}, + "source": "hdhive_web_legacy", + } + return success, result, result["message"] + + def perform_web_checkin_with_fallback( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + legacy_ok, legacy_result, legacy_message = self.perform_legacy_web_checkin( + cookie_string=cookie_string, + is_gambler=is_gambler, + trigger=trigger, + ) + if legacy_ok: + return legacy_ok, legacy_result, legacy_message + + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + return legacy_ok, legacy_result, legacy_message + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + action_id = self._discover_signin_action_id(cookies, token, referer) + if not action_id: + message = "旧版网页签到接口不可用,且未能解析当前站点签到 Action;请更新影巢网页 Cookie 后重试" + legacy_result["message"] = message + legacy_result["status"] = "签到失败" + legacy_result["source"] = "hdhive_web_next_action" + return False, legacy_result, message + + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Content-Type": "text/plain;charset=UTF-8", + "Origin": self.base_url, + "Referer": f"{self.base_url}/", + "Authorization": f"Bearer {token}", + "next-action": action_id, + "next-router-state-tree": self._build_signin_tree_header(), + } + if csrf_token: + headers["x-csrf-token"] = csrf_token + + try: + response = requests.post( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + data=self._build_signin_action_body(is_gambler), + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception as exc: + return False, { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"Next Action 签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_next_action", + }, f"Next Action 签到请求异常: {exc}" + + redirect_target = str(response.headers.get("x-action-redirect") or response.headers.get("Location") or "").strip() + if "/login" in redirect_target: + message = "影巢网页 Cookie 已失效,请先在 HDHiveDailySign 中更新 Cookie 或重新自动登录" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {"redirect": redirect_target}, + "source": "hdhive_web_next_action", + } + return False, result, message + if response.status_code in (404, 405): + message = f"影巢网页签到入口暂不可用或 Cookie 已失效(HTTP {response.status_code}),请更新本插件里的影巢网页 Cookie 后重试" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return False, result, message + + response_text = "" + try: + response_text = response.content.decode("utf-8", errors="ignore") + except Exception: + response_text = response.text or "" + success, message = self._parse_signin_action_response(response_text) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if "已经签到" in message or "签到过" in message or "明天再来" in message else "签到成功" if success else "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return success, result, message diff --git a/AgentResourceOfficer/services/p115_transfer.py b/AgentResourceOfficer/services/p115_transfer.py new file mode 100644 index 0000000..536f9b5 --- /dev/null +++ b/AgentResourceOfficer/services/p115_transfer.py @@ -0,0 +1,823 @@ +import importlib +import re +import sys +from base64 import b64encode +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None + + +class P115TransferService: + """Reusable 115 share transfer execution layer for Agent影视助手.""" + + CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"} + QR_CLIENT_TYPES = { + "web", + "android", + "115android", + "ios", + "115ios", + "alipaymini", + "wechatmini", + "115ipad", + "tv", + "qandroid", + } + + def __init__( + self, + *, + default_target_path: str = "/待整理", + cookie: str = "", + prefer_direct: bool = True, + ) -> None: + self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理" + self.cookie = self.normalize_text(cookie) + self.prefer_direct = bool(prefer_direct) + + def set_cookie(self, cookie: str = "") -> None: + self.cookie = self.normalize_text(cookie) + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _ensure_helper_import_paths() -> None: + candidate_dirs = [] + try: + plugin_parent = Path(__file__).resolve().parents[2] + candidate_dirs.append(str(plugin_parent)) + except Exception: + pass + try: + app_plugins_spec = importlib.util.find_spec("app.plugins") + for location in app_plugins_spec.submodule_search_locations or []: + candidate_dirs.append(str(Path(location).resolve())) + except Exception: + pass + for base in candidate_dirs: + path = Path(base) + if path.exists(): + text = str(path) + if text not in sys.path: + sys.path.append(text) + + @staticmethod + def is_115_share_url(url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self.normalize_text(url) + if not clean_url: + return "" + access_code = self.normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _extract_115_payload(url: str) -> Tuple[str, str]: + clean_url = str(url or "").strip() + if not clean_url: + return "", "" + try: + from p115client.util import share_extract_payload + + payload = share_extract_payload(clean_url) or {} + return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip() + except Exception: + parsed = urlparse(clean_url) + share_code = "" + match = re.search(r"/s/([^/?#]+)", parsed.path or "") + if match: + share_code = match.group(1).strip() + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip() + return share_code, receive_code + + @classmethod + def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]: + pairs: Dict[str, str] = {} + for part in cls.normalize_text(cookie).strip(";").split(";"): + if "=" not in part: + continue + key, value = part.split("=", 1) + key = key.strip() + value = value.strip() + if key and value: + pairs[key] = value + return pairs + + @classmethod + def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]: + if not cls.normalize_text(cookie): + return False, "未配置独立 115 Cookie" + pairs = cls.parse_cookie_pairs(cookie) + missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs)) + if missing: + return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie" + return True, "" + + def cookie_state(self) -> Dict[str, Any]: + configured = bool(self.normalize_text(self.cookie)) + pairs = self.parse_cookie_pairs(self.cookie) + cookie_keys = sorted(pairs.keys()) + if not configured: + return { + "configured": False, + "valid": False, + "mode": "none", + "cookie_keys": [], + "message": "未配置独立 115 会话,将优先复用 P115StrmHelper 已登录客户端", + } + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + return { + "configured": True, + "valid": cookie_ok, + "mode": "client_cookie" if cookie_ok else "invalid_cookie", + "cookie_keys": cookie_keys, + "message": "" if cookie_ok else cookie_message, + } + + @classmethod + def normalize_qrcode_client_type(cls, client_type: Any) -> str: + text = cls.normalize_text(client_type).lower() + return text if text in cls.QR_CLIENT_TYPES else "alipaymini" + + @staticmethod + def jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + @staticmethod + def _safe_int(value: Any, default: int = -1) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _response_error(resp: Any) -> str: + if not isinstance(resp, dict): + return str(resp or "") + for key in ("error", "message", "msg", "errno"): + value = resp.get(key) + if value not in (None, ""): + return str(value) + return str(resp) + + @classmethod + def _is_already_saved_message(cls, value: Any) -> bool: + text = cls.normalize_text(value) + return any( + marker in text + for marker in ( + "已经转存", + "已转存", + "已经保存", + "已保存", + "already", + "exist", + ) + ) + + @staticmethod + def _response_ok(resp: Any) -> bool: + if not isinstance(resp, dict): + return False + if resp.get("state") is True: + return True + if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0): + return True + if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0): + return True + return False + + @staticmethod + def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]: + try: + P115TransferService._ensure_helper_import_paths() + from app.plugins.p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + try: + P115TransferService._ensure_helper_import_paths() + from p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + pass + return {} + + @staticmethod + def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]: + if PluginManager is None: + return None, "PluginManager 不可用" + try: + plugin = PluginManager().running_plugins.get("P115StrmHelper") + except Exception as exc: + return None, f"读取 P115StrmHelper 运行态失败: {exc}" + if not plugin: + return None, "P115StrmHelper 未加载" + + module_names = [] + plugin_module = getattr(plugin.__class__, "__module__", "") or "" + if plugin_module: + module_names.append(f"{plugin_module}.service") + module_names.extend( + [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ] + ) + + for module_name in module_names: + try: + self._ensure_helper_import_paths() + module = sys.modules.get(module_name) or importlib.import_module(module_name) + servicer = getattr(module, "servicer", None) + if servicer is not None: + return servicer, None + except Exception: + continue + return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer" + + def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + return None, helper_error or "P115StrmHelper 未加载" + client = getattr(servicer, "client", None) + if not client: + return None, "P115StrmHelper 未登录 115 或客户端不可用" + return client, "p115strmhelper_client" + + def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]: + if not self.cookie: + return None, "未配置独立 115 Cookie" + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + if not cookie_ok: + return None, cookie_message + try: + from p115client import P115Client + + return P115Client( + self.cookie, + check_for_relogin=False, + ensure_cookies=False, + console_qrcode=False, + ), "direct_cookie" + except Exception as exc: + return None, f"独立 115 Cookie 初始化失败: {exc}" + + @classmethod + def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_token() + check_response(resp) + resp_info = resp.get("data", {}) if isinstance(resp, dict) else {} + uid = str(resp_info.get("uid") or "") + qrcode_time = str(resp_info.get("time") or "") + sign = str(resp_info.get("sign") or "") + qrcode = P115Client.login_qrcode(uid) + if not isinstance(qrcode, (bytes, bytearray)): + return False, {}, "获取二维码失败:返回内容类型异常" + return True, { + "uid": uid, + "time": qrcode_time, + "sign": sign, + "client_type": final_client_type, + "tips": "请使用 115 App 扫码登录", + "qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}", + }, "success" + except Exception as exc: + return False, {}, f"获取 115 登录二维码失败: {exc}" + + @classmethod + def check_qrcode_login( + cls, + *, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + payload = {"uid": uid, "time": time_value, "sign": sign} + resp = P115Client.login_qrcode_scan_status(payload) + if not isinstance(resp, dict): + return False, {}, "检查二维码状态失败:返回内容类型异常" + check_response(resp) + status_code = (resp.get("data") or {}).get("status") + except Exception as exc: + return False, {}, f"检查二维码状态失败: {exc}" + + if status_code == 0: + return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码" + if status_code == 1: + return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认" + if status_code == -1 or status_code is None: + return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期" + if status_code == -2: + return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录" + if status_code != 2: + return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}" + + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type) + if not isinstance(resp, dict): + return False, {}, "获取登录结果失败:返回内容类型异常" + check_response(resp) + except Exception as exc: + return False, {}, f"获取登录结果失败: {exc}" + + cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None + if not isinstance(cookie_data, dict): + return False, {}, "登录成功但未返回 Cookie" + cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip() + cookie_ok, cookie_message = cls.validate_client_cookie(cookie) + if not cookie_ok: + return False, {}, cookie_message + return True, { + "status": "success", + "client_type": final_client_type, + "cookie": cookie, + "cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()), + }, "登录成功" + + def get_direct_client(self) -> Tuple[Optional[Any], str, str]: + client, source = self._get_cookie_p115_client() + if client: + return client, source, "" + cookie_error = source + client, source = self._get_loaded_p115_client() + if client: + return client, source, "" + return None, "none", source or cookie_error + + @classmethod + def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]: + last_error = "" + for module_name in [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ]: + try: + cls._ensure_helper_import_paths() + service_module = importlib.import_module(module_name) + servicer = getattr(service_module, "servicer", None) + if servicer is not None: + return servicer, None + last_error = f"{module_name} 未暴露 servicer" + except Exception as exc: + last_error = f"{module_name} 导入失败: {exc}" + return None, last_error or "P115StrmHelper 未安装或无法导入" + + def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + servicer, helper_error = self._import_servicer_fallback() + if not servicer: + return None, f"P115StrmHelper 未安装或无法导入: {helper_error}" + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def health(self) -> Tuple[bool, Dict[str, Any], str]: + cookie_state = self.cookie_state() + direct_client, direct_source, direct_error = self.get_direct_client() + direct_ready = direct_client is not None + helper, helper_error = self.get_share_helper() + helper_ready = bool(helper and not helper_error) + ready = direct_ready or helper_ready + message = "" if ready else direct_error or helper_error or "115 转存不可用" + return ready, { + "ready": ready, + "direct_ready": direct_ready, + "direct_source": direct_source if direct_ready else "", + "direct_message": "" if direct_ready else direct_error, + "helper_ready": helper_ready, + "helper_message": "" if helper_ready else helper_error, + "cookie_state": cookie_state, + "message": message or "success", + }, message + + def _get_or_create_path_cid(self, client: Any, path: str) -> int: + return self._get_path_cid(client, path, create=True) + + def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int: + target_path = self.normalize_pan_path(path) or "/" + if target_path == "/": + return 0 + get_kwargs = self._p115_request_kwargs(app=False) + mkdir_kwargs = self._p115_request_kwargs(app=True) + try: + resp = client.fs_dir_getid(target_path, **get_kwargs) + pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1) + if pid > 0: + return pid + except Exception: + pass + + if not create: + return -1 + + try: + resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs) + cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1) + if cid >= 0: + return cid + if self._response_ok(resp): + cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1) + if cid >= 0: + return cid + raise RuntimeError(self._response_error(resp)) + except Exception as exc: + raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc + + def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "path": target_path, + "items": [], + "file_count": 0, + "folder_count": 0, + "removed_count": 0, + "message": "", + } + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + result["direct_source"] = source + return False, result, result["message"] + + cid = self._get_path_cid(client, target_path, create=False) + if cid < 0: + result["ok"] = True + result["direct_source"] = source + result["message"] = "115 默认目录不存在,视为空目录" + return True, result, result["message"] + + payload = { + "cid": int(cid), + "limit": 1150, + "offset": 0, + "show_dir": 1, + "cur": 1, + "count_folders": 1, + } + items: list[dict[str, Any]] = [] + total = 0 + try: + while True: + resp = client.fs_files(payload, **self._p115_request_kwargs(app=False)) + if not isinstance(resp, dict): + result["message"] = "读取 115 目录失败:返回内容异常" + result["direct_source"] = source + return False, result, result["message"] + batch = resp.get("data") or [] + total = self._safe_int(resp.get("count"), total) + for entry in batch: + if not isinstance(entry, dict): + continue + fid = self._safe_int(entry.get("fid"), -1) + item_cid = self._safe_int(entry.get("cid"), -1) + is_dir = fid < 0 + item_id = item_cid if is_dir else fid + if item_id < 0: + continue + items.append( + { + "id": item_id, + "name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")), + "is_dir": is_dir, + "type": "folder" if is_dir else "file", + "raw": entry, + } + ) + payload["offset"] = int(payload["offset"]) + len(batch) + if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total: + break + except Exception as exc: + result["message"] = f"读取 115 目录失败: {exc}" + result["direct_source"] = source + return False, result, result["message"] + + file_count = len([item for item in items if not item.get("is_dir")]) + folder_count = len([item for item in items if item.get("is_dir")]) + result.update( + { + "ok": True, + "direct_source": source, + "cid": cid, + "items": items, + "file_count": file_count, + "folder_count": folder_count, + "message": "success", + } + ) + return True, result, "success" + + def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + client, source, client_error = self.get_direct_client() + result = { + "ok": False, + "direct_source": source, + "removed_count": 0, + "message": "", + } + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + return False, result, result["message"] + + ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0] + if not ids: + result.update({"ok": True, "message": "115 默认目录当前层已是空目录"}) + return True, result, result["message"] + + try: + resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"删除 115 目录内容失败: {exc}" + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "删除 115 目录内容失败" + result["raw"] = self.jsonable(resp) + return False, result, result["message"] + + result.update( + { + "ok": True, + "removed_count": len(ids), + "message": "115 默认目录已清空当前层", + "raw": self.jsonable(resp), + } + ) + return True, result, result["message"] + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path) + if not listed_ok: + return False, listed_result, listed_message + + items = listed_result.get("items") or [] + if not items: + listed_result["message"] = "115 默认目录当前层已是空目录" + return True, listed_result, listed_result["message"] + + delete_ok, delete_result, delete_message = self.delete_items(items) + merged = dict(listed_result) + merged.update( + { + "ok": delete_ok, + "removed_count": delete_result.get("removed_count", 0), + "direct_source": delete_result.get("direct_source", listed_result.get("direct_source")), + "delete_raw": delete_result.get("raw"), + "message": delete_message, + } + ) + return delete_ok, merged, delete_message + + def transfer_share_direct( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "strategy": "direct", + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + share_code, receive_code = self._extract_115_payload(share_url) + if not share_code or not receive_code: + result["message"] = "解析 115 分享链接失败,缺少分享码或提取码" + return False, result, result["message"] + + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 直转客户端" + result["data"] = {"direct_source": source} + return False, result, result["message"] + + try: + parent_id = self._get_or_create_path_cid(client, transfer_path) + except Exception as exc: + result["message"] = str(exc) + result["data"] = {"direct_source": source} + return False, result, result["message"] + + payload = { + "share_code": share_code, + "receive_code": receive_code, + "file_id": 0, + "cid": int(parent_id), + "is_check": 0, + } + try: + resp = client.share_receive(payload, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"调用 115 直转接口失败: {exc}" + result["data"] = {"direct_source": source, "parent_id": parent_id} + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "115 直转失败" + result["data"] = { + "direct_source": source, + "parent_id": parent_id, + "raw": self.jsonable(resp), + } + if self._is_already_saved_message(result["message"]): + result["ok"] = True + result["message"] = "115 直转已存在" + return True, result, result["message"] + return False, result, result["message"] + + result.update( + { + "ok": True, + "message": "115 直转成功", + "data": { + "direct_source": source, + "share_code": share_code, + "receive_code": receive_code, + "save_parent": transfer_path, + "parent_id": parent_id, + "raw": self.jsonable(resp), + }, + } + ) + return True, result, result["message"] + + def transfer_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + if self.prefer_direct: + direct_ok, direct_result, direct_message = self.transfer_share_direct( + url=share_url, + access_code=access_code, + path=transfer_path, + trigger=trigger, + ) + if direct_ok: + return True, direct_result, direct_message + result["data"]["direct_fallback"] = direct_result + + helper, helper_error = self.get_share_helper() + if helper_error or not helper: + direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message") + result["message"] = helper_error or direct_error or "P115StrmHelper 不可用" + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self.normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self.normalize_text(transfer_result[1]) + if self._is_already_saved_message(error_message): + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存已存在", + "data": {"raw": self.jsonable(transfer_result)}, + } + ) + return True, result, result["message"] + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self.jsonable(transfer_result)} + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存成功", + "data": { + "media_info": self.jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + return True, result, result["message"] diff --git a/AgentResourceOfficer/services/quark_transfer.py b/AgentResourceOfficer/services/quark_transfer.py new file mode 100644 index 0000000..68261e8 --- /dev/null +++ b/AgentResourceOfficer/services/quark_transfer.py @@ -0,0 +1,664 @@ +import json +import random +import re +import time +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlparse, urlencode + +import requests + +from app.log import logger + +try: + from app.core.config import settings +except Exception: + settings = None + + +class QuarkTransferService: + """ + Reusable execution layer migrated out of QuarkShareSaver. + + This service intentionally focuses on transfer execution and directory + resolution. UI, plugin form logic, and entry adapters stay outside. + """ + + def __init__( + self, + *, + cookie: str = "", + timeout: int = 30, + default_target_path: str = "/飞书", + auto_import_cookiecloud: bool = True, + cookie_refresh_callback: Optional[Callable[[], str]] = None, + ) -> None: + self.cookie = self.clean_text(cookie) + self.timeout = max(10, self.safe_int(timeout, 30)) + self.default_target_path = self.normalize_path(default_target_path or "/飞书") + self.auto_import_cookiecloud = auto_import_cookiecloud + self.cookie_refresh_callback = cookie_refresh_callback + self.path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + @staticmethod + def extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + @classmethod + def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = cls.clean_text(share_text) + share_url = cls.extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = cls.clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = cls.clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + @classmethod + def validate_share_url(cls, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if cls.is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def set_cookie(self, cookie: str) -> None: + self.cookie = self.clean_text(cookie) + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self.cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _refresh_cookie(self) -> bool: + if not self.auto_import_cookiecloud or not self.cookie_refresh_callback: + return False + try: + cookie = self.clean_text(self.cookie_refresh_callback()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}") + return False + if not cookie: + return False + self.cookie = cookie + return True + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookie_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + try: + response = requests.request( + method=method.upper(), + url=url, + params=params or None, + json=json_body, + headers=self._build_headers(), + timeout=self.timeout, + ) + status_code = response.status_code + raw_body = response.text or "" + except requests.RequestException as exc: + return False, {}, f"请求失败: {exc}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = response.json() + except Exception: + text = str(raw_body)[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie(): + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookie_retry=False, + ) + + if status_code != 200: + if isinstance(data, dict): + code = self.clean_text(data.get("code")) + detail = self.clean_text(data.get("message") or data.get("msg")) + if detail: + if code: + return False, data, f"HTTP {status_code} [{code}]: {detail}" + return False, data, f"HTTP {status_code}: {detail}" + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self.clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self.safe_int(meta.get("_total"), 0) + count = self.safe_int(meta.get("_count"), len(current)) + size = max(1, self.safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + "raw": item, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + source_items = [item for item in (items or []) if isinstance(item, dict)] + + def build_fids(candidates: List[Dict[str, Any]]) -> List[str]: + result: List[str] = [] + for item in candidates: + fid = self.clean_text(item.get("fid")) + if fid: + result.append(fid) + return result + + def item_label(item: Dict[str, Any]) -> str: + return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid")) + + def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + fids = build_fids(candidates) + if not fids: + return False, {}, "默认目录当前层没有可删除项目" + payloads = [ + { + "action_type": 2, + "exclude_fids": [], + "filelist": [{"fid": fid} for fid in fids], + }, + { + "action_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + { + # Some web scripts historically used this misspelled key. + "actoin_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + ] + last_data: Dict[str, Any] = {} + last_message = "" + for index, payload in enumerate(payloads, start=1): + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/file/delete", + params={ + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + }, + json_body=payload, + ) + if ok: + if isinstance(data, dict): + data["delete_payload_variant"] = index + return True, data, "" + last_data = data if isinstance(data, dict) else {} + last_message = message or last_message + return False, last_data, last_message or "夸克删除失败" + + filelist: List[Dict[str, Any]] = [] + for item in source_items: + fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else "" + if fid: + filelist.append({"fid": fid}) + if not filelist: + return False, {}, "默认目录当前层没有可删除项目" + + ok, data, message = call_delete(source_items) + if ok: + data["deleted_count"] = len(filelist) + data["delete_mode"] = "batch" + return True, data, "" + + if len(source_items) <= 1: + return False, data, message or "夸克删除失败" + + deleted_count = 0 + failed_items: List[Dict[str, Any]] = [] + for item in source_items: + single_ok, single_data, single_message = call_delete([item]) + if single_ok: + deleted_count += 1 + continue + failed_items.append({ + "fid": self.clean_text(item.get("fid")), + "name": item_label(item), + "message": single_message or "删除失败", + "result": single_data, + }) + + result = { + "deleted_count": deleted_count, + "failed_count": len(failed_items), + "failed_items": failed_items[:20], + "delete_mode": "single_fallback", + "batch_error": message or "夸克批量删除失败", + "batch_result": data, + } + if failed_items: + return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败" + return True, result, "" + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path) + if not ok: + return False, {}, target_fid or "定位夸克目录失败" + + ok, children, message = self.list_children(target_fid) + if not ok: + return False, {}, message or "读取夸克目录失败" + + files = [item for item in children if not bool(item.get("dir"))] + folders = [item for item in children if bool(item.get("dir"))] + if not children: + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "默认目录当前层为空" + + ok, delete_result, message = self.delete_items(children) + removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0) + if not ok: + return False, { + "target_path": normalized_path, + "target_fid": target_fid, + "file_count": len(files), + "folder_count": len(folders), + "removed_count": removed_count, + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "failed_items": (delete_result or {}).get("failed_items") or [], + "delete_result": delete_result, + }, message or "夸克清空默认目录失败" + + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "delete_result": delete_result, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "success" + + def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self.list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self.clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self.normalize_path(path or self.default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self.path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self.path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self.find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self.create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self.path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self.clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self.safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self.list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code) + ok, message = self.validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + if not self.cookie: + self._refresh_cookie() + if not self.cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self.get_stoken(pwd_id, final_code) + if not ok: + return False, {}, message + + ok, share_items, message = self.get_share_items(pwd_id, stoken) + if not ok: + return False, {}, message + + ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path) + if not ok: + return False, {}, target_fid + + ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + return False, {}, message + + ok, task, message = self.wait_task(task_id) + if not ok: + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + return True, result, "success" diff --git a/FeishuCommandBridgeLong/README.md b/FeishuCommandBridgeLong/README.md new file mode 100644 index 0000000..66acbcf --- /dev/null +++ b/FeishuCommandBridgeLong/README.md @@ -0,0 +1,109 @@ +# FeishuCommandBridgeLong + +MoviePilot 的飞书长连接桥接插件。当前定位是兼容/备份入口;新用户更推荐直接使用 `Agent影视助手` 内置的飞书入口。 + +## 这版的定位 + +- 保留旧飞书桥接的轻量远程操作体验 +- 作为迁移期兼容插件继续可用 +- 新功能优先进入 `Agent影视助手`,避免飞书入口和资源执行逻辑继续分叉 +- 如果只想装一个插件完成云盘资源整合 + 飞书入口,优先安装并开启 `Agent影视助手` 的内置飞书入口 + +## 当前能力 + +- 飞书长连接接收 `im.message.receive_v1` +- 智能单入口:自动识别片名、115 链接、夸克链接、盘搜搜索 +- 影巢两段式搜索:先选影片,再看资源 +- `详情` / `审查` / `n 下一页` 会话续接 +- MoviePilot 原生搜索、下载、订阅、订阅搜索 +- `P115StrmHelper` 的手动整理、增量 STRM、全量 STRM +- 115 扫码登录与状态查询 +- 待继续 115 任务查看、继续、取消 + +## 执行后端 + +- `旧桥接直连` + 适合保持现有飞书操作习惯,速度快。 +- `自动优先新主线,失败回落旧桥接` + 优先委托 `Agent影视助手`,失败再退回旧桥接。 +- `仅走 Agent影视助手 新主线` + 调试和后续统一主干时更合适。 + +日常老环境可以继续用 `旧桥接直连`。新环境建议改用 `Agent影视助手` 内置飞书入口;如果暂时仍使用本插件,建议切到 `仅走 Agent影视助手 新主线`,让资源动作统一落到 Agent影视助手。 + +## 新推荐入口 + +`Agent影视助手` 已内置可选 `Feishu Channel`,开启后可以直接接收飞书长连接消息,并复用同一套 `assistant/route`、`assistant/pick`、115 扫码和待任务续跑能力。 + +迁移建议: + +1. 在本插件里先关闭 `启用插件`。 +2. 到 `Agent影视助手` 中打开 `启用内置飞书入口`。 +3. 迁移同一组飞书 `App ID / App Secret / Verification Token / 白名单`。 +4. 确认 `GET /api/v1/plugin/AgentResourceOfficer/feishu/health` 显示运行正常。 + +## 常用飞书命令 + +```txt +处理 流浪地球2 +影巢搜索 流浪地球2 +yc流浪地球2 +2流浪地球2 + +盘搜搜索 流浪地球2 +ps流浪地球2 +1流浪地球2 + +链接 https://115cdn.com/s/xxxx path=/待整理 +链接 https://pan.quark.cn/s/xxxx path=/飞书 + +选择 1 +选择 1 path=/最新动画 + +详情 +审查 +n 下一页 +``` + +## 115 相关命令 + +```txt +115登录 +115扫码 +检查115登录 +115登录状态 +115状态 +115帮助 +115任务 +继续115任务 +取消115任务 +``` + +- 当飞书桥接走 `Agent影视助手` 新主线时,`115登录` 会直接拉起扫码登录流程 +- 如果飞书回复里带了二维码图片,直接用 115 App 扫码即可 +- 某次 115 转存因为登录或会话问题失败后,可直接回复 `115任务` 查看当前待处理任务 +- 登录成功后回复 `检查115登录`,会自动尝试继续上一次待处理的 115 任务 + +## 智能单入口说明 + +- 发片名:进入影巢或盘搜搜索流程 +- 发 115 / 夸克链接:自动识别并转存,其中 115 链接会优先委托 `Agent影视助手`,确保失败后的待任务、扫码续跑和取消任务都在同一条会话链里 +- `path=/目录`、`位置=目录` 都支持 +- 裸链接也支持,不一定要带 `处理` 或 `链接` 前缀 + +## 智能体 API + +插件提供两条更适合外部智能体调用的入口: + +```txt +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/route +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick +``` + +`route` 负责分流,`pick` 负责继续选择。飞书消息入口和这两条 API 用的是同一套会话逻辑。 + +## 依赖 + +```txt +lark-oapi==1.5.3 +``` diff --git a/FeishuCommandBridgeLong/__init__.py b/FeishuCommandBridgeLong/__init__.py new file mode 100644 index 0000000..4e1b478 --- /dev/null +++ b/FeishuCommandBridgeLong/__init__.py @@ -0,0 +1,4111 @@ +import asyncio +import concurrent.futures +import copy +import difflib +import fcntl +import importlib +import json +import re +import sys +import threading +import time +import traceback +from base64 import b64decode +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode, urlparse +from urllib.request import urlopen, Request as UrlRequest + +from fastapi import Request +from app.core.config import settings +from app.core.event import eventmanager +from app.core.metainfo import MetaInfo +from app.core.plugin import PluginManager +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain +from app.scheduler import Scheduler +from app.utils.string import StringUtils +from app.utils.http import RequestUtils + +for _plugin_dir in ( + str(Path(__file__).resolve().parent), + "/config/plugins/FeishuCommandBridgeLong", +): + if Path(_plugin_dir).exists() and _plugin_dir not in sys.path: + sys.path.insert(0, _plugin_dir) + +for _site_path in ( + "/usr/local/lib/python3.12/site-packages", + "/usr/local/lib/python3.11/site-packages", +): + if Path(_site_path).exists() and _site_path not in sys.path: + sys.path.append(_site_path) + +try: + import lark_oapi as lark +except Exception: + lark = None + + +class _LongConnectionRuntime: + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._fingerprint = "" + self._plugin: Optional["FeishuCommandBridgeLong"] = None + + def start(self, plugin: "FeishuCommandBridgeLong") -> None: + global lark + if lark is None: + try: + import lark_oapi as runtime_lark + lark = runtime_lark + except Exception as exc: + logger.error( + f"[FeishuCommandBridgeLong] 缺少依赖 lark-oapi,请先安装插件依赖:{exc}" + ) + return + + if not plugin._enabled or not plugin._app_id or not plugin._app_secret: + return + + fingerprint = plugin._connection_fingerprint() + with self._lock: + self._plugin = plugin + if self._thread and self._thread.is_alive(): + if fingerprint != self._fingerprint: + logger.warning( + "[FeishuCommandBridgeLong] 长连接已在运行,App ID / App Secret / Token 变更需要重启 MoviePilot 后生效" + ) + return + + self._fingerprint = fingerprint + self._thread = threading.Thread( + target=self._run, + name="feishu-command-bridge-long", + daemon=True, + ) + self._thread.start() + + def _run(self) -> None: + plugin = self._plugin + if plugin is None: + return + + def _on_message(data) -> None: + current_plugin = self._plugin + if current_plugin is None: + return + current_plugin._handle_long_connection_event(data) + + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + import lark_oapi.ws.client as lark_ws_client + lark_ws_client.loop = loop + + event_handler = ( + lark.EventDispatcherHandler.builder("", "") + .register_p2_im_message_receive_v1(_on_message) + .build() + ) + ws_client = lark.ws.Client( + plugin._app_id, + plugin._app_secret, + log_level=lark.LogLevel.DEBUG if plugin._debug else lark.LogLevel.INFO, + event_handler=event_handler, + ) + logger.info("[FeishuCommandBridgeLong] 正在启动飞书长连接") + ws_client.start() + except Exception as exc: + logger.error(f"[FeishuCommandBridgeLong] 长连接退出:{exc}\n{traceback.format_exc()}") + + def is_running(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + +_runtime = _LongConnectionRuntime() +_EVENT_CACHE_FILE = Path("/config/plugins/FeishuCommandBridgeLong/.event_cache.json") +_SMART_CACHE_FILE = Path("/config/plugins/FeishuCommandBridgeLong/.smart_cache.json") + + +class FeishuCommandBridgeLong(_PluginBase): + plugin_name = "飞书命令桥接" + plugin_desc = "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/feishucommandbridgelong.png" + plugin_version = "0.5.26" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "feishucommandbridgelong_" + plugin_order = 29 + auth_level = 1 + + _enabled = False + _allow_all = False + _verification_token = "" + _app_id = "" + _app_secret = "" + _allowed_chat_ids: List[str] = [] + _allowed_user_ids: List[str] = [] + _reply_enabled = True + _reply_receive_id_type = "chat_id" + _command_whitelist: List[str] = [] + _command_aliases = "" + _debug = False + _tmdb_api_key_override = "" + _execution_backend = "legacy" + + _token_cache: Dict[str, Any] = {} + _token_lock = threading.Lock() + _event_cache: Dict[str, float] = {} + _event_lock = threading.Lock() + _search_cache: Dict[str, Dict[str, Any]] = {} + _search_cache_lock = threading.Lock() + _smart_cache: Dict[str, Dict[str, Any]] = {} + _smart_cache_lock = threading.Lock() + _candidate_actor_cache: Dict[str, List[str]] = {} + _candidate_actor_cache_lock = threading.Lock() + _tmdb_api_key_cache = "" + _tmdb_api_key_lock = threading.Lock() + + @classmethod + def _default_command_whitelist(cls) -> List[str]: + return [ + "/p115_manual_transfer", + "/p115_inc_sync", + "/p115_full_sync", + "/p115_strm", + "/quark_save", + "/pansou_search", + "/smart_entry", + "/smart_pick", + "/media_search", + "/media_download", + "/media_subscribe", + "/media_subscribe_search", + "/version", + ] + + @classmethod + def _default_command_aliases(cls) -> str: + return ( + "刮削=/p115_manual_transfer\n" + "搜索=/media_search\n" + "MP搜索=/media_search\n" + "原生搜索=/media_search\n" + "盘搜搜索=/pansou_search\n" + "盘搜=/pansou_search\n" + "ps=/pansou_search\n" + "1=/pansou_search\n" + "影巢搜索=/smart_entry\n" + "yc=/smart_entry\n" + "2=/smart_entry\n" + "下载=/media_download\n" + "订阅=/media_subscribe\n" + "订阅搜索=/media_subscribe_search\n" + "生成STRM=/p115_inc_sync\n" + "全量STRM=/p115_full_sync\n" + "指定路径STRM=/p115_strm\n" + "夸克转存=/quark_save\n" + "夸克=/quark_save\n" + "链接=/smart_entry\n" + "处理=/smart_entry\n" + "115登录=/smart_entry\n" + "115扫码=/smart_entry\n" + "检查115登录=/smart_entry\n" + "115登录状态=/smart_entry\n" + "115状态=/smart_entry\n" + "115帮助=/smart_entry\n" + "115任务=/smart_entry\n" + "继续115任务=/smart_entry\n" + "取消115任务=/smart_entry\n" + "选择=/smart_pick\n" + "详情=/smart_pick\n" + "审查=/smart_pick\n" + "选=/smart_pick\n" + "继续=/smart_pick\n" + "影巢=/smart_entry\n" + "搜索资源=/media_search\n" + "下载资源=/media_download\n" + "订阅媒体=/media_subscribe\n" + "订阅并搜索=/media_subscribe_search\n" + "版本=/version" + ) + + @staticmethod + def _clean_input(value: Any) -> str: + if value is None: + return "" + text = str(value) + for ch in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060", "\ufffc"): + text = text.replace(ch, "") + return text.strip() + + @classmethod + def _normalize_execution_backend(cls, value: Any) -> str: + clean = cls._clean_input(value).lower() + if clean in {"auto", "agent_resource_officer", "legacy"}: + return clean + if clean in {"agent", "aro", "agentresourceofficer"}: + return "agent_resource_officer" + return "legacy" + + @classmethod + def _describe_execution_backend(cls, value: Any) -> str: + backend = cls._normalize_execution_backend(value) + mapping = { + "legacy": "旧桥接直连", + "auto": "自动优先新主线", + "agent_resource_officer": "仅走 Agent影视助手", + } + return mapping.get(backend, "旧桥接直连") + + def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) + self._allow_all = bool(config.get("allow_all")) + self._verification_token = self._clean_input(config.get("verification_token")) + self._app_id = self._clean_input(config.get("app_id")) + self._app_secret = self._clean_input(config.get("app_secret")) + self._allowed_chat_ids = self._split_lines(config.get("allowed_chat_ids")) + self._allowed_user_ids = self._split_lines(config.get("allowed_user_ids")) + self._reply_enabled = bool(config.get("reply_enabled", True)) + self._reply_receive_id_type = str( + config.get("reply_receive_id_type") or "chat_id" + ).strip() + self._command_whitelist = self._merge_command_whitelist( + self._split_commands(config.get("command_whitelist")) + ) + self._command_aliases = self._merge_command_aliases( + str(config.get("command_aliases") or "").strip() + ) + self._debug = bool(config.get("debug")) + self._tmdb_api_key_override = self._clean_input(config.get("tmdb_api_key")) + self._execution_backend = self._normalize_execution_backend( + config.get("execution_backend") + ) + type(self)._tmdb_api_key_override = self._tmdb_api_key_override + with type(self)._tmdb_api_key_lock: + type(self)._tmdb_api_key_cache = "" + + _runtime.start(self) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/health", + "endpoint": self.health, + "methods": ["GET"], + "summary": "健康检查", + "description": "返回飞书长连接插件当前状态与基础配置", + "auth": "bear", + }, + { + "path": "/assistant/route", + "endpoint": self.api_assistant_route, + "methods": ["POST"], + "summary": "智能单入口分流", + "description": "自动识别夸克链接、115 链接或影巢片名搜索", + "auth": "bear", + }, + { + "path": "/assistant/pick", + "endpoint": self.api_assistant_pick, + "methods": ["POST"], + "summary": "按编号继续执行", + "description": "对上一轮智能分流结果按编号确认执行", + "auth": "bear", + }, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "allow_all", + "label": "允许所有飞书会话", + }, + }, + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "verification_token", + "label": "Verification Token", + "placeholder": "飞书事件订阅 Token", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "tmdb_api_key", + "label": "TMDB API Key(可选)", + "placeholder": "仅用于影巢候选影片补充主演", + "type": "password", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "app_id", + "label": "App ID", + "placeholder": "cli_xxxxxxxxx", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "app_secret", + "label": "App Secret", + "placeholder": "飞书应用凭证", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "allowed_chat_ids", + "label": "允许的群聊 Chat ID", + "rows": 4, + "placeholder": "一个一行;留空时仅允许 allow_all 或允许的用户", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "allowed_user_ids", + "label": "允许的用户 Open ID", + "rows": 4, + "placeholder": "一个一行", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "command_whitelist", + "label": "命令白名单", + "placeholder": ",".join(self._default_command_whitelist()), + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "reply_enabled", + "label": "发送即时回执", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "command_aliases", + "label": "命令别名", + "rows": 6, + "placeholder": self._default_command_aliases(), + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "execution_backend", + "label": "执行后端", + "items": [ + {"title": "旧桥接直连(推荐保留旧体验)", "value": "legacy"}, + {"title": "自动优先新主线,失败回落旧桥接", "value": "auto"}, + {"title": "仅走 Agent影视助手 新主线", "value": "agent_resource_officer"}, + ], + }, + }, + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "debug", + "label": "输出调试日志", + }, + } + ], + } + ], + }, + ], + } + ], { + "enabled": self._enabled, + "allow_all": self._allow_all, + "verification_token": self._verification_token, + "app_id": self._app_id, + "app_secret": self._app_secret, + "allowed_chat_ids": "\n".join(self._allowed_chat_ids), + "allowed_user_ids": "\n".join(self._allowed_user_ids), + "reply_enabled": self._reply_enabled, + "reply_receive_id_type": self._reply_receive_id_type, + "command_whitelist": ",".join(self._command_whitelist) if self._command_whitelist else ",".join(self._default_command_whitelist()), + "command_aliases": self._command_aliases or self._default_command_aliases(), + "debug": self._debug, + "tmdb_api_key": self._tmdb_api_key_override, + "execution_backend": self._execution_backend or "legacy", + } + + def get_page(self) -> Optional[List[dict]]: + aliases = self._parse_aliases() + alias_lines = [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"{key} -> {value}", + } + for key, value in aliases.items() + ] or [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "未配置别名", + } + ] + + command_lines = [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": cmd, + } + for cmd in (self._command_whitelist or []) + ] or [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "未配置命令白名单", + } + ] + + return [ + { + "component": "VContainer", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"border": True, "flat": True}, + "content": [ + { + "component": "VCardTitle", + "text": "运行状态", + }, + { + "component": "VCardText", + "content": [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"启用状态:{'是' if self._enabled else '否'}", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"长连接运行中:{'是' if _runtime.is_running() else '否'}", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"执行后端:{self._describe_execution_backend(self._execution_backend)}", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"允许所有会话:{'是' if self._allow_all else '否'}", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"App ID:{self._app_id or '未填写'}", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"Token:{self._mask_secret(self._verification_token) or '未填写'}", + }, + ], + }, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"border": True, "flat": True}, + "content": [ + { + "component": "VCardTitle", + "text": "可用命令", + }, + { + "component": "VCardText", + "content": command_lines, + }, + ], + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"border": True, "flat": True}, + "content": [ + { + "component": "VCardTitle", + "text": "命令别名", + }, + { + "component": "VCardText", + "content": alias_lines, + }, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"border": True, "flat": True}, + "content": [ + { + "component": "VCardTitle", + "text": "使用示例", + }, + { + "component": "VCardText", + "content": [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "处理 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "选择 1", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "版本", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "刮削 /待整理/", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "/p115_strm /待整理/", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "MP搜索 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "影巢搜索 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "盘搜搜索 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "115登录", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "115帮助", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "检查115登录", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "115任务", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "继续115任务", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "取消115任务", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "链接 https://115cdn.com/s/xxxx path=/待整理", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "下载资源 1", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "订阅媒体 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "订阅并搜索 流浪地球2", + }, + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": "帮助", + }, + ], + }, + ], + } + ], + }, + ], + }, + ], + } + ] + + def health(self): + return { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "running": _runtime.is_running(), + "allow_all": self._allow_all, + "reply_enabled": self._reply_enabled, + "allowed_chat_count": len(self._allowed_chat_ids), + "allowed_user_count": len(self._allowed_user_ids), + "command_whitelist": self._command_whitelist, + "sdk_available": lark is not None, + } + + async def api_assistant_route(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + session = self._clean_input( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ) + text = self._clean_input( + body.get("text") + or body.get("query") + or body.get("message") + or "" + ) + mode, query = self._strip_search_prefix(text) + cache_key = f"api::{session}" + if mode == "mp": + message = await asyncio.to_thread(self._execute_media_search, query, cache_key) + ok = "失败" not in message and "未识别" not in message + data = {"action": "media_search", "ok": ok, "keyword": query} + elif mode == "pansou": + message = await asyncio.to_thread(self._execute_pansou_search, query, cache_key) + ok = not message.startswith("盘搜搜索失败") + data = {"action": "pansou_search", "ok": ok, "keyword": query} + elif mode == "hdhive": + ok, message, data = await asyncio.to_thread( + self._execute_smart_entry, + query, + cache_key, + ) + else: + ok, message, data = await asyncio.to_thread( + self._execute_smart_entry, + text, + cache_key, + ) + return {"success": ok, "message": message, "data": data} + + async def api_assistant_pick(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + session = self._clean_input( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ) + if body.get("arg"): + arg = self._clean_input(body.get("arg")) + else: + index = str(body.get("index") or "").strip() + path = self._normalize_pan_path(body.get("path") or "") + arg = index + if path: + arg = f"{arg} path={path}".strip() + ok, message, data = await asyncio.to_thread( + self._execute_smart_pick, + arg, + f"api::{session}", + ) + return {"success": ok, "message": message, "data": data} + + def stop_service(self): + logger.info("[FeishuCommandBridge] 当前版本未实现长连接主动停止;如需彻底停掉,请重启 MoviePilot") + + def _connection_fingerprint(self) -> str: + return "|".join([ + self._app_id, + self._app_secret, + self._verification_token, + ]) + + def _handle_long_connection_event(self, data) -> None: + if not self._enabled: + return + + event_context = data + event = getattr(event_context, "event", None) + header = getattr(event_context, "header", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + sender_id = getattr(sender, "sender_id", None) + + event_id = str(getattr(header, "event_id", "") or "").strip() + if event_id and self._is_duplicate_event(event_id): + return + + if self._debug: + logger.info( + f"[FeishuCommandBridge] event_id={event_id} " + f"event_type={getattr(header, 'event_type', '')} " + f"chat_id={getattr(message, 'chat_id', '')}" + ) + + if not message or str(getattr(message, "message_type", "")).strip() != "text": + return + + raw_text = self._extract_text(getattr(message, "content", None)) + if not raw_text: + return + + sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip() + chat_id = str(getattr(message, "chat_id", "") or "").strip() + + if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): + self._reply_if_needed( + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + text="该会话未在白名单中,命令已拒绝。", + ) + return + + if self._is_help_request(raw_text): + self._reply_if_needed( + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + text=self._build_help_text(), + ) + return + + if self._is_menu_request(raw_text): + self._reply_if_needed( + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + text=self._build_menu_text(), + ) + return + + command_text = self._map_text_to_command(raw_text) + if not command_text: + return + + cmd = command_text.split()[0] + if cmd not in self._command_whitelist: + self._reply_if_needed( + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + text=f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}", + ) + return + + if self._handle_builtin_command( + command_text=command_text, + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + ): + return + + logger.info(f"[FeishuCommandBridge] 转发命令:{command_text}") + eventmanager.send_event( + EventType.CommandExcute, + { + "cmd": command_text, + "source": None, + "user": sender_open_id or chat_id or "feishu", + }, + ) + self._reply_if_needed( + receive_chat_id=chat_id, + receive_open_id=sender_open_id, + text=f"已接收命令:{command_text}\n任务已提交给 MoviePilot。", + ) + + def _handle_builtin_command( + self, + command_text: str, + receive_chat_id: str, + receive_open_id: str, + ) -> bool: + parts = command_text.split(maxsplit=1) + cmd = parts[0].strip() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd == "/p115_strm" and not arg: + command_text = "/p115_full_sync" + logger.info(f"[FeishuCommandBridge] 转发命令:{command_text}") + eventmanager.send_event( + EventType.CommandExcute, + { + "cmd": command_text, + "source": None, + "user": receive_open_id or receive_chat_id or "feishu", + }, + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"已接收命令:{command_text}\n任务已提交给 MoviePilot。", + ) + return True + + if cmd == "/media_search": + if not arg: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text="用法:搜索资源 片名\n示例:MP搜索 流浪地球2", + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在使用 MP 原生搜索:{arg}\n我会返回前 10 条结果,之后可直接回复:下载资源 序号", + ) + threading.Thread( + target=self._run_media_search, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-media-search", + daemon=True, + ).start() + return True + + if cmd == "/pansou_search": + if not arg: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text="用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2", + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在使用盘搜搜索:{arg}", + ) + threading.Thread( + target=self._run_pansou_search, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-pansou-search", + daemon=True, + ).start() + return True + + if cmd == "/media_download": + if not arg or not arg.isdigit(): + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text="用法:下载资源 序号\n示例:下载资源 1", + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在提交第 {arg} 条资源到下载器,请稍候。", + ) + threading.Thread( + target=self._run_media_download, + args=(int(arg), receive_chat_id, receive_open_id), + name="feishu-media-download", + daemon=True, + ).start() + return True + + if cmd == "/quark_save": + if not arg: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=( + "用法:夸克转存 分享链接 pwd=提取码 path=/保存目录\n" + "示例:夸克转存 https://pan.quark.cn/s/xxxx pwd=abcd path=/最新动画" + ), + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在处理夸克转存:{arg}", + ) + threading.Thread( + target=self._run_quark_save, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-quark-save", + daemon=True, + ).start() + return True + + if cmd == "/smart_entry": + if not arg: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=( + "用法:处理 片名 或 处理 分享链接\n" + "示例1:处理 流浪地球2\n" + "示例2:处理 https://pan.quark.cn/s/xxxx pwd=abcd path=/最新动画" + ), + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在智能处理:{arg}", + ) + threading.Thread( + target=self._run_smart_entry, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-smart-entry", + daemon=True, + ).start() + return True + + if cmd == "/smart_pick": + if not arg: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=( + "用法:选择 序号\n" + "示例:选择 1\n" + "也支持:直接回复 1\n" + "也支持:选择 1 path=/目录\n" + "如需补充当前候选页全部主演:详情" + ), + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在继续执行:{arg}", + ) + threading.Thread( + target=self._run_smart_pick, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-smart-pick", + daemon=True, + ).start() + return True + + if cmd in {"/media_subscribe", "/media_subscribe_search"}: + if not arg: + usage = ( + "用法:订阅媒体 片名" + if cmd == "/media_subscribe" + else "用法:订阅并搜索 片名" + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"{usage}\n示例:{usage.replace('片名', '流浪地球2')}", + ) + return True + immediate_search = cmd == "/media_subscribe_search" + action_text = "订阅并搜索" if immediate_search else "订阅" + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"正在{action_text}:{arg}", + ) + threading.Thread( + target=self._run_media_subscribe, + args=(arg, immediate_search, receive_chat_id, receive_open_id), + name="feishu-media-subscribe", + daemon=True, + ).start() + return True + + if cmd != "/p115_manual_transfer": + return False + + if not arg: + paths = self._get_p115_manual_transfer_paths() + if not paths: + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text="未配置待整理目录。\n请先在 P115StrmHelper 中配置 pan_transfer_paths,或直接发送:刮削 /待整理/", + ) + return True + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=( + f"已开始刮削 {len(paths)} 个目录:\n" + + "\n".join(f"- {path}" for path in paths) + + "\n正在调用 115 整理流程,请稍候。" + ), + ) + threading.Thread( + target=self._run_p115_manual_transfer_batch, + args=(paths, receive_chat_id, receive_open_id), + name="feishu-p115-manual-transfer-batch", + daemon=True, + ).start() + return True + + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=f"已开始刮削:{arg}\n正在调用 115 整理流程,请稍候。", + ) + + threading.Thread( + target=self._run_p115_manual_transfer, + args=(arg, receive_chat_id, receive_open_id), + name="feishu-p115-manual-transfer", + daemon=True, + ).start() + return True + + def _get_p115_manual_transfer_paths(self) -> List[str]: + try: + config = self.systemconfig.get("plugin.P115StrmHelper") or {} + raw = str(config.get("pan_transfer_paths") or "").strip() + if not raw: + return [] + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取待整理目录失败:{exc}") + return [] + + def _run_p115_manual_transfer_batch( + self, + paths: List[str], + receive_chat_id: str, + receive_open_id: str, + ) -> None: + summaries: List[str] = [] + for path in paths: + summaries.append(self._execute_p115_manual_transfer(path)) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text="\n\n".join(summary for summary in summaries if summary), + ) + + def _run_p115_manual_transfer( + self, + path: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + summary_text = self._execute_p115_manual_transfer(path) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=summary_text, + ) + + def _execute_p115_manual_transfer(self, path: str) -> str: + log_path = Path("/config/logs/plugins/P115StrmHelper.log") + log_offset = self._safe_log_offset(log_path) + try: + service_module = importlib.import_module( + "app.plugins.p115strmhelper.service" + ) + servicer = getattr(service_module, "servicer", None) + if not servicer or not getattr(servicer, "monitorlife", None): + return "刮削失败:P115StrmHelper 未初始化或未启用。" + + logger.info(f"[FeishuCommandBridge] 开始执行手动刮削:{path}") + result = servicer.monitorlife.once_transfer(path) + logger.info(f"[FeishuCommandBridge] 手动刮削完成:{path}") + summary_text = self._format_p115_manual_transfer_result(result) + if not summary_text: + summary_text = self._build_p115_manual_transfer_summary(log_path, log_offset, path) + return summary_text or f"刮削完成:{path}" + except Exception as exc: + logger.error( + f"[FeishuCommandBridge] 手动刮削失败:{path} {exc}\n{traceback.format_exc()}" + ) + return f"刮削失败:{path}\n错误:{exc}" + + def _format_p115_manual_transfer_result(self, result: Any) -> Optional[str]: + if not isinstance(result, dict): + return None + + path = result.get("path") or "" + total = result.get("total", 0) + files = result.get("files", 0) + dirs = result.get("dirs", 0) + success = result.get("success", 0) + failed = result.get("failed", 0) + skipped = result.get("skipped", 0) + error = result.get("error") + failed_items = result.get("failed_items") or [] + + lines = [ + f"刮削完成:{path}", + f"总计:{total} 个项目(文件 {files},文件夹 {dirs})", + f"成功:{success} 个", + f"失败:{failed} 个", + f"跳过:{skipped} 个", + ] + if error: + lines.append(f"错误:{error}") + if failed_items: + lines.append("失败示例:") + lines.extend(f"- {item}" for item in failed_items[:3]) + remain = len(failed_items) - 3 + if remain > 0: + lines.append(f"- 还有 {remain} 项未展示") + strm_hint_path = self._get_p115_strm_hint_path() or path + lines.append("如需增量生成 STRM,请再发送:生成STRM") + lines.append("如需按全部媒体库全量生成,请再发送:全量STRM") + lines.append(f"如需指定路径全量生成,请再发送:指定路径STRM {strm_hint_path}") + return "\n".join(lines) + + def _get_p115_strm_hint_path(self) -> Optional[str]: + try: + config = self.systemconfig.get("plugin.P115StrmHelper") or {} + paths = str(config.get("full_sync_strm_paths") or "").strip() + if not paths: + return None + first_line = next( + (line.strip() for line in paths.splitlines() if line.strip()), + "", + ) + if not first_line: + return None + parts = first_line.split("#") + if len(parts) >= 2 and parts[1].strip(): + return parts[1].strip() + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取 P115 STRM 提示路径失败:{exc}") + return None + + def _safe_log_offset(self, log_path: Path) -> int: + try: + if log_path.exists(): + return log_path.stat().st_size + except Exception: + pass + return 0 + + def _build_p115_manual_transfer_summary( + self, + log_path: Path, + start_offset: int, + path: str, + ) -> Optional[str]: + try: + if not log_path.exists(): + return None + + with log_path.open("r", encoding="utf-8", errors="ignore") as f: + f.seek(start_offset) + chunk = f.read() + + if not chunk: + return None + + path_re = re.escape(path) + summary_pattern = re.compile( + rf"手动网盘整理完成 - 路径: {path_re}\n" + rf"\s*总计: (?P\d+) 个项目 \(文件: (?P\d+), 文件夹: (?P\d+)\)\n" + rf"\s*成功: (?P\d+) 个\n" + rf"\s*失败: (?P\d+) 个\n" + rf"\s*跳过: (?P\d+) 个", + re.S, + ) + match = summary_pattern.search(chunk) + if not match: + return None + + summary = ( + f"刮削完成:{path}\n" + f"总计:{match.group('total')} 个项目" + f"(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" + f"成功:{match.group('success')} 个\n" + f"失败:{match.group('failed')} 个\n" + f"跳过:{match.group('skipped')} 个" + ) + + failed_pattern = re.compile( + r"失败项目详情 \((?P\d+) 个\):\n(?P(?:\s*-\s.*(?:\n|$))*)", + re.S, + ) + failed_match = failed_pattern.search(chunk, match.end()) + if failed_match: + items = [ + item.strip()[2:].strip() + for item in failed_match.group("items").splitlines() + if item.strip().startswith("- ") + ] + if items: + preview = "\n".join(f"- {item}" for item in items[:3]) + remain = len(items) - 3 + summary += f"\n失败示例:\n{preview}" + if remain > 0: + summary += f"\n- 还有 {remain} 项未展示" + + strm_hint_path = self._get_p115_strm_hint_path() or path + summary += "\n如需增量生成 STRM,请再发送:生成STRM" + summary += "\n如需按全部媒体库全量生成,请再发送:全量STRM" + summary += f"\n如需指定路径全量生成,请再发送:指定路径STRM {strm_hint_path}" + return summary + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 解析 P115 刮削结果失败:{exc}") + return None + + def _is_duplicate_event(self, event_id: str) -> bool: + now = time.time() + with self._event_lock: + expired = [key for key, ts in self._event_cache.items() if now - ts > 600] + for key in expired: + self._event_cache.pop(key, None) + if event_id in self._event_cache: + return True + self._event_cache[event_id] = now + return self._is_duplicate_event_cross_instance(event_id, now) + + def _is_duplicate_event_cross_instance(self, event_id: str, now: float) -> bool: + try: + _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + with _EVENT_CACHE_FILE.open("a+", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + f.seek(0) + raw = f.read().strip() + cache = json.loads(raw) if raw else {} + cache = { + key: ts + for key, ts in cache.items() + if isinstance(ts, (int, float)) and now - float(ts) <= 600 + } + if event_id in cache: + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + return True + cache[event_id] = now + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 跨实例事件去重失败:{exc}") + return False + + def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: + if self._allow_all: + return True + if chat_id and chat_id in self._allowed_chat_ids: + return True + if user_open_id and user_open_id in self._allowed_user_ids: + return True + return False + + def _map_text_to_command(self, text: str) -> Optional[str]: + text = self._sanitize_text(text) + if not text: + return None + if text.startswith("/"): + return text + normalized = text.strip().lower() + if normalized in {"n", "next", "下一页", "下页"} or normalized.startswith("n "): + return f"/smart_pick {text}".strip() + shortcut_match = re.fullmatch(r"(\d+)(?:\s+(.+))?", text) + if shortcut_match: + rest = str(shortcut_match.group(2) or "").strip() + if not rest or "=" in rest or rest.startswith("/"): + return f"/smart_pick {text}".strip() + first_url = self._extract_first_url(text) + if first_url and self._detect_share_kind(first_url) in {"115", "quark"}: + return f"/smart_entry {text}".strip() + + alias_map = self._parse_aliases() + parts = text.split(maxsplit=1) + alias = parts[0] + rest = parts[1] if len(parts) > 1 else "" + target = alias_map.get(alias) + if not target: + for alias_key in sorted(alias_map.keys(), key=len, reverse=True): + if not text.startswith(alias_key): + continue + remain = text[len(alias_key):].strip() + target = alias_map.get(alias_key) + if target: + if target == "/smart_pick" and alias_key in {"详情", "审查"}: + return f"{target} {alias_key} {remain}".strip() + return f"{target} {remain}".strip() + return None + if target == "/smart_pick" and alias in {"详情", "审查"}: + return f"{target} {alias} {rest}".strip() + return f"{target} {rest}".strip() + + def _is_help_request(self, text: str) -> bool: + text = self._sanitize_text(text) + return text in {"帮助", "/help", "help"} + + def _is_menu_request(self, text: str) -> bool: + text = self._sanitize_text(text) + return text in {"菜单", "/menu", "menu", "面板", "控制面板"} + + def _parse_aliases(self) -> Dict[str, str]: + result: Dict[str, str] = {} + for line in self._command_aliases.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key and value.startswith("/"): + result[key] = value + return result + + @classmethod + def _merge_command_whitelist(cls, configured: List[str]) -> List[str]: + merged: List[str] = [] + seen = set() + for cmd in configured or []: + if cmd and cmd not in seen: + merged.append(cmd) + seen.add(cmd) + for cmd in cls._default_command_whitelist(): + if cmd not in seen: + merged.append(cmd) + seen.add(cmd) + return merged + + @classmethod + def _merge_command_aliases(cls, configured_text: str) -> str: + merged = cls._parse_alias_text(cls._default_command_aliases()) + for key, value in cls._parse_alias_text(configured_text).items(): + merged[key] = value + return "\n".join(f"{key}={value}" for key, value in merged.items()) + + @staticmethod + def _parse_alias_text(text: str) -> Dict[str, str]: + result: Dict[str, str] = {} + for line in str(text or "").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key and value.startswith("/"): + result[key] = value + return result + + def _build_help_text(self) -> str: + aliases = self._parse_aliases() + alias_lines = [f"{k} -> {v}" for k, v in aliases.items()] + alias_text = "\n".join(alias_lines) if alias_lines else "未配置别名" + return ( + "可用命令:\n" + f"{', '.join(self._command_whitelist)}\n\n" + "别名:\n" + f"{alias_text}\n\n" + "快捷入口:发送“菜单”可查看可复制的快捷命令。" + ) + + def _build_menu_text(self) -> str: + return ( + "快捷菜单\n" + "1. MP搜索 片名\n\n" + "2. 影巢搜索 片名\n\n" + "3. 盘搜搜索 片名\n\n" + "4. 直接发 115 / 夸克链接\n\n" + "5. 选择 序号\n\n" + "6. 刮削\n\n" + "7. 生成STRM\n\n" + "8. 全量STRM\n\n" + "9. 夸克转存 分享链接 pwd=提取码 path=/保存目录\n\n" + "10. 下载资源 序号\n\n" + "11. 订阅媒体 片名\n\n" + "12. 订阅并搜索 片名\n\n" + "13. 版本" + ) + + def _cache_key(self, receive_chat_id: str, receive_open_id: str) -> str: + return f"{receive_chat_id or ''}::{receive_open_id or ''}" + + def _set_search_cache( + self, + cache_key: str, + keyword: str, + mediainfo: Any, + results: List[Any], + ) -> None: + with self._search_cache_lock: + self._search_cache[cache_key] = { + "ts": time.time(), + "keyword": keyword, + "mediainfo": mediainfo, + "results": results[:10], + } + + def _get_search_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: + with self._search_cache_lock: + item = self._search_cache.get(cache_key) + if not item: + return None + if time.time() - float(item.get("ts") or 0) > 1800: + self._search_cache.pop(cache_key, None) + return None + return item + + def _set_smart_cache( + self, + cache_key: str, + *, + action: str, + items: List[Dict[str, Any]], + target_path: str = "", + keyword: str = "", + meta: Optional[Dict[str, Any]] = None, + ) -> None: + item_limit = 50 if action == "hdhive_candidates" else 20 + payload = { + "ts": time.time(), + "action": action, + "keyword": keyword, + "target_path": target_path, + "items": items[:item_limit], + "meta": meta or {}, + } + with self._smart_cache_lock: + self._smart_cache[cache_key] = payload + self._persist_smart_cache(cache_key, payload) + + def _get_smart_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: + with self._smart_cache_lock: + item = self._smart_cache.get(cache_key) + if not item: + item = self._load_persisted_smart_cache(cache_key) + if item: + with self._smart_cache_lock: + self._smart_cache[cache_key] = item + if not item: + return None + if time.time() - float(item.get("ts") or 0) > 1800: + with self._smart_cache_lock: + self._smart_cache.pop(cache_key, None) + self._remove_persisted_smart_cache(cache_key) + return None + return item + + def _persist_smart_cache(self, cache_key: str, payload: Dict[str, Any]) -> None: + try: + _SMART_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + with _SMART_CACHE_FILE.open("a+", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + f.seek(0) + raw = f.read().strip() + cache = json.loads(raw) if raw else {} + if not isinstance(cache, dict): + cache = {} + now = time.time() + cache = { + key: value + for key, value in cache.items() + if isinstance(value, dict) and now - float(value.get("ts") or 0) <= 1800 + } + cache[cache_key] = payload + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 写入智能缓存失败:{exc}") + + def _load_persisted_smart_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: + try: + if not _SMART_CACHE_FILE.exists(): + return None + with _SMART_CACHE_FILE.open("r", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) + raw = f.read().strip() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + cache = json.loads(raw) if raw else {} + item = cache.get(cache_key) if isinstance(cache, dict) else None + return item if isinstance(item, dict) else None + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 读取智能缓存失败:{exc}") + return None + + def _remove_persisted_smart_cache(self, cache_key: str) -> None: + try: + if not _SMART_CACHE_FILE.exists(): + return + with _SMART_CACHE_FILE.open("a+", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + f.seek(0) + raw = f.read().strip() + cache = json.loads(raw) if raw else {} + if isinstance(cache, dict) and cache.pop(cache_key, None) is not None: + f.seek(0) + f.truncate() + json.dump(cache, f, ensure_ascii=False) + f.flush() + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 删除智能缓存失败:{exc}") + + def _run_media_search( + self, + keyword: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + text = self._execute_media_search( + keyword=keyword, + cache_key=self._cache_key(receive_chat_id, receive_open_id), + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + def _run_pansou_search( + self, + keyword: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + text = self._execute_pansou_search( + keyword=keyword, + cache_key=self._cache_key(receive_chat_id, receive_open_id), + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + def _run_media_download( + self, + index: int, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + text = self._execute_media_download( + index=index, + cache_key=self._cache_key(receive_chat_id, receive_open_id), + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + def _run_media_subscribe( + self, + keyword: str, + immediate_search: bool, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + text = self._execute_media_subscribe( + keyword=keyword, + immediate_search=immediate_search, + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + def _run_smart_entry( + self, + arg: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + ok, text, data = self._execute_smart_entry( + arg=arg, + cache_key=self._cache_key(receive_chat_id, receive_open_id), + ) + result = data.get("result") or {} + if data.get("action") == "p115_qrcode_start": + self._reply_qrcode_data_url_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + data_url=str(result.get("qrcode") or ""), + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + def _run_smart_pick( + self, + arg: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + ok, text, _ = self._execute_smart_pick( + arg=arg, + cache_key=self._cache_key(receive_chat_id, receive_open_id), + ) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=text, + ) + + @staticmethod + def _extract_first_url(text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", str(text or "")) + return match.group(0).rstrip(".,);]") if match else "" + + @staticmethod + def _is_p115_qrcode_start_text(text: str) -> bool: + compact = re.sub(r"\s+", "", str(text or "")).lower() + return compact in { + "115登录", + "115扫码", + "扫码115", + "登录115", + "115login", + "115qrcode", + "p115login", + "p115qrcode", + } + + @staticmethod + def _is_p115_qrcode_check_text(text: str) -> bool: + compact = re.sub(r"\s+", "", str(text or "")).lower() + return compact in { + "检查115登录", + "115登录状态", + "115状态", + "检查115扫码", + "检查扫码", + "115check", + "check115login", + "p115check", + } + + @staticmethod + def _is_p115_assistant_text(text: str) -> bool: + compact = re.sub(r"\s+", "", str(text or "")).lower() + return compact in { + "115帮助", + "115任务", + "继续115任务", + "取消115任务", + } + + @classmethod + def _is_forced_aro_smart_text(cls, text: str) -> bool: + return cls._is_p115_qrcode_start_text(text) or cls._is_p115_qrcode_check_text(text) or cls._is_p115_assistant_text(text) + + @staticmethod + def _detect_share_kind(url: str) -> str: + host = (urlparse(url).hostname or "").lower().strip(".") + if host.endswith("quark.cn"): + return "quark" + if host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host: + return "115" + return "" + + @staticmethod + def _normalize_pan_path(path: str) -> str: + text = str(path or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return re.sub(r"/+", "/", text).rstrip("/") or "/" + + @classmethod + def _resolve_pan_path_value(cls, value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + alias_map = { + "分享": "/飞书", + "飞书": "/飞书", + "待整理": "/待整理", + "最新动画": "/最新动画", + } + mapped = alias_map.get(text, text) + return cls._normalize_pan_path(mapped) + + @staticmethod + def _normalize_search_text(text: str) -> str: + value = str(text or "").strip().lower() + value = re.sub(r"\s+", "", value) + value = re.sub(r"[^\w\u4e00-\u9fff]+", "", value) + return value + + @staticmethod + def _format_pansou_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text or text.startswith("0001-01-01"): + return "" + text = text.replace("T", " ").replace("Z", "") + if len(text) >= 10: + text = text[:10].replace("-", "/") + return text.strip() + + @staticmethod + def _format_pansou_source(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.split(":", 1)[-1] if ":" in text else text + + @staticmethod + def _short_share_code(url: str) -> str: + text = str(url or "").strip() + if not text: + return "" + match = re.search(r"/s/([^/?#]+)", text) + code = match.group(1) if match else text.rstrip("/").rsplit("/", 1)[-1] + return code[:6] + + def _parse_smart_arg(self, arg: str) -> Dict[str, str]: + text = self._sanitize_text(arg or "") + share_url = self._extract_first_url(text) + remain = text.replace(share_url, " ").strip() if share_url else text + keyword_parts: List[str] = [] + options: Dict[str, str] = { + "url": share_url, + "access_code": "", + "path": "", + "type": "", + "year": "", + } + for token in remain.split(): + item = token.strip() + if not item: + continue + if "=" in item: + key, value = item.split("=", 1) + key = key.strip().lower() + value = value.strip() + if key in {"pwd", "passcode", "code", "提取码"} and value: + options["access_code"] = value + continue + if key in {"path", "dir", "目录", "位置"} and value: + options["path"] = self._resolve_pan_path_value(value) + continue + if key in {"type", "媒体类型"} and value: + options["type"] = value.strip().lower() + continue + if key in {"year", "年份"} and value: + options["year"] = value.strip() + continue + if item.startswith("/") and not options["path"]: + options["path"] = self._resolve_pan_path_value(item) + continue + if not share_url and item in {"电影", "movie"}: + options["type"] = "movie" + continue + if not share_url and item in {"电视剧", "剧集", "tv"}: + options["type"] = "tv" + continue + if not share_url and not options["year"] and re.fullmatch(r"(19|20)\d{2}", item): + options["year"] = item + continue + keyword_parts.append(item) + + keyword = " ".join(keyword_parts).strip() + for prefix in ("影巢 ", "影巢搜索 ", "搜索影巢 "): + if keyword.startswith(prefix): + keyword = keyword[len(prefix):].strip() + break + + media_type = options["type"] + if media_type in {"电影", "movie"}: + media_type = "movie" + elif media_type in {"电视剧", "剧集", "tv"}: + media_type = "tv" + elif re.search(r"(第\s*\d+\s*季|S\d{1,2}|EP?\d+)", keyword, re.IGNORECASE): + media_type = "tv" + else: + media_type = "movie" + + return { + "url": options["url"], + "access_code": options["access_code"], + "path": options["path"], + "type": media_type, + "year": options["year"], + "keyword": keyword, + } + + @staticmethod + def _parse_pick_arg(arg: str) -> Tuple[int, str, str]: + text = str(arg or "").strip() + index = 0 + path = "" + action = "pick" + lowered = text.lower() + if lowered in {"n", "next", "下一页", "下页"} or lowered.startswith("n "): + action = "next_page" + for token in text.split(): + item = token.strip() + if not item: + continue + if item.lower() in {"n", "next", "下一页", "下页"}: + action = "next_page" + continue + if item.lower() in {"detail", "details", "review"} or item in {"详情", "审查"}: + action = "detail" + continue + if item.isdigit() and index <= 0: + index = int(item) + continue + if "=" in item: + key, value = item.split("=", 1) + if key.strip().lower() in {"path", "dir", "目录", "位置"} and value.strip(): + path = value.strip() + continue + if item.startswith("/") and not path: + path = item + return index, FeishuCommandBridgeLong._resolve_pan_path_value(path), action + + @staticmethod + def _strip_search_prefix(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if FeishuCommandBridgeLong._is_forced_aro_smart_text(raw): + return "", raw + mappings = [ + ("1搜索", "pansou"), + ("2搜索", "hdhive"), + ("MP搜索", "mp"), + ("原生搜索", "mp"), + ("搜索资源", "mp"), + ("搜索", "mp"), + ("影巢搜索", "hdhive"), + ("yc", "hdhive"), + ("2", "hdhive"), + ("盘搜搜索", "pansou"), + ("盘搜", "pansou"), + ("ps", "pansou"), + ("1", "pansou"), + ] + for prefix, mode in mappings: + if raw == prefix: + return mode, "" + if raw.startswith(prefix + " "): + return mode, raw[len(prefix):].strip() + if raw.startswith(prefix): + remain = raw[len(prefix):].strip() + if remain: + return mode, remain + return "", raw + + def _get_hdhive_default_path(self) -> str: + try: + config = self.systemconfig.get("plugin.AgentResourceOfficer") or {} + path = self._normalize_pan_path(config.get("hdhive_default_path") or "") + if path: + return path + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取 Agent影视助手影巢默认目录失败:{exc}") + try: + config = self.systemconfig.get("plugin.HdhiveOpenApi") or {} + path = self._normalize_pan_path(config.get("transfer_115_path") or "") + if path: + return path + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取影巢默认目录失败:{exc}") + return "/待整理" + + def _get_quark_default_path(self) -> str: + try: + config = self.systemconfig.get("plugin.AgentResourceOfficer") or {} + path = self._normalize_pan_path(config.get("quark_default_path") or "") + if path: + return path + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取 Agent影视助手夸克默认目录失败:{exc}") + try: + config = self.systemconfig.get("plugin.QuarkShareSaver") or {} + path = self._normalize_pan_path( + config.get("default_target_path") + or config.get("target_path") + or "" + ) + if path: + return path + except Exception as exc: + logger.warning(f"[FeishuCommandBridge] 获取夸克默认目录失败:{exc}") + return "/飞书" + + def _local_api_base(self) -> str: + return f"http://127.0.0.1:{settings.PORT}" + + @staticmethod + def _get_running_plugin(plugin_id: str) -> Optional[Any]: + try: + return PluginManager().running_plugins.get(plugin_id) + except Exception: + return None + + def _should_use_agent_resource_officer(self) -> bool: + backend = self._normalize_execution_backend(self._execution_backend) + aro = self._get_running_plugin("AgentResourceOfficer") + if backend == "legacy": + return False + if backend == "agent_resource_officer": + return aro is not None + return aro is not None + + def _requires_agent_resource_officer(self) -> bool: + return self._normalize_execution_backend(self._execution_backend) == "agent_resource_officer" + + def _has_agent_resource_officer(self) -> bool: + return self._get_running_plugin("AgentResourceOfficer") is not None + + def _call_local_json_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Tuple[bool, Dict[str, Any], str]: + query = {"apikey": settings.API_TOKEN} + for key, value in (params or {}).items(): + if value is None or value == "": + continue + query[key] = value + url = f"{self._local_api_base()}{path}?{urlencode(query)}" + try: + response = RequestUtils().get(url=url) + if response is None: + return False, {}, "未收到本机插件响应" + if hasattr(response, "json"): + data = response.json() + elif isinstance(response, (bytes, bytearray)): + data = json.loads(response.decode("utf-8", "ignore")) + elif isinstance(response, str): + data = json.loads(response) + else: + raw = getattr(response, "text", None) + if callable(raw): + raw = raw() + elif raw is None and hasattr(response, "read"): + raw = response.read() + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", "ignore") + data = json.loads(raw or "{}") + except Exception as exc: + return False, {}, f"请求失败:{exc}" + return bool(data.get("success")), data, str(data.get("message") or "") + + def _call_local_json_post(self, path: str, payload: Dict[str, Any]) -> Tuple[bool, Dict[str, Any], str]: + url = f"{self._local_api_base()}{path}?apikey={settings.API_TOKEN}" + try: + response = RequestUtils(content_type="application/json").post( + url=url, + json=payload, + ) + if response is None: + return False, {}, "未收到本机插件响应" + data = response.json() + except Exception as exc: + return False, {}, f"请求失败:{exc}" + return bool(data.get("success")), data, str(data.get("message") or "") + + def _call_quark_transfer( + self, + share_url: str, + access_code: str = "", + target_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + if self._should_use_agent_resource_officer(): + ok, data, message = self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/quark/transfer", + { + "url": share_url, + "access_code": access_code, + "path": target_path, + }, + ) + result = data.get("data") or {} + final_message = ( + message + or str(result.get("message") or "") + or str(result.get("error") or "") + or str(result.get("detail") or "") + ) + return ok, {"data": result}, final_message + if self._requires_agent_resource_officer(): + return False, {}, "Agent影视助手 未加载" + plugin = self._get_running_plugin("QuarkShareSaver") + if not plugin: + return False, {}, "QuarkShareSaver 未加载" + ok, result, message = plugin.transfer_share( + share_text=share_url, + access_code=access_code, + target_path=target_path, + remember=True, + trigger="FeishuCommandBridgeLong 智能入口", + ) + result = result or {} + final_message = ( + message + or str(result.get("message") or "") + or str(result.get("error") or "") + or str(result.get("detail") or "") + ) + return ok, {"data": result}, final_message + + def _call_hdhive_search( + self, + keyword: str, + media_type: str, + year: str = "", + candidate_limit: int = 5, + limit: int = 10, + ) -> Tuple[bool, Dict[str, Any], str]: + plugin = self._get_running_plugin("HdhiveOpenApi") + if not plugin: + return False, {}, "HdhiveOpenApi 未加载" + ok, result, message = asyncio.run( + plugin.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + result_limit=limit, + remember=True, + ) + ) + return ok, {"data": result}, message + + def _call_aro_hdhive_session_search( + self, + keyword: str, + media_type: str, + year: str = "", + target_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/session/hdhive/search", + { + "keyword": keyword, + "type": media_type or "movie", + "year": year, + "path": target_path, + }, + ) + + def _call_aro_hdhive_session_pick( + self, + session_id: str, + index: int, + target_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/session/hdhive/pick", + { + "session_id": session_id, + "index": index, + "path": target_path, + }, + ) + + def _call_aro_assistant_route( + self, + session_id: str, + text: str, + ) -> Tuple[bool, Dict[str, Any], str]: + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/assistant/route", + { + "session": session_id, + "text": text, + }, + ) + + def _call_aro_assistant_pick( + self, + session_id: str, + index: int, + target_path: str = "", + action: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + { + "session": session_id, + "index": index, + "path": target_path, + "action": action, + }, + ) + + def _should_force_aro_for_p115_login(self, text: str) -> bool: + return self._is_forced_aro_smart_text(text) + + def _call_hdhive_search_by_tmdb( + self, + tmdb_id: Any, + media_type: str, + year: str = "", + limit: int = 20, + ) -> Tuple[bool, Dict[str, Any], str]: + tmdb_value = str(tmdb_id or "").strip() + if not tmdb_value: + return False, {}, "缺少 TMDB ID" + if self._should_use_agent_resource_officer(): + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/hdhive/search", + { + "type": media_type or "movie", + "tmdb_id": tmdb_value, + "year": year, + "limit": limit, + }, + ) + if self._requires_agent_resource_officer(): + return False, {}, "Agent影视助手 未加载" + return self._call_local_json_get( + "/api/v1/plugin/HdhiveOpenApi/resources/search", + params={ + "type": media_type or "movie", + "tmdb_id": tmdb_value, + "year": year, + "limit": limit, + }, + ) + + @classmethod + def _read_tmdb_api_key(cls) -> str: + with cls._tmdb_api_key_lock: + if cls._tmdb_api_key_cache: + return cls._tmdb_api_key_cache + override_key = cls._clean_input(getattr(cls, "_tmdb_api_key_override", "")) + if override_key: + cls._tmdb_api_key_cache = override_key + return override_key + env_key = cls._clean_input(__import__("os").environ.get("TMDB_API_KEY")) + if env_key: + cls._tmdb_api_key_cache = env_key + return env_key + compose_path = Path("/Applications/Dockge/moviepilot-ai-recognizer-gateway/docker-compose.yml") + if compose_path.exists(): + for line in compose_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + if "TMDB_API_KEY" not in line: + continue + _, _, value = line.partition(":") + key = cls._clean_input(value.strip().strip("'\"")) + if key: + cls._tmdb_api_key_cache = key + return key + return "" + + @classmethod + def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: + clean_tmdb_id = cls._clean_input(tmdb_id) + clean_media_type = cls._clean_input(media_type).lower() + if not clean_tmdb_id or clean_media_type not in {"movie", "tv"}: + return [] + cache_key = f"{clean_media_type}:{clean_tmdb_id}" + with cls._candidate_actor_cache_lock: + cached = cls._candidate_actor_cache.get(cache_key) + if cached is not None: + return list(cached) + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return [] + query = urlencode( + { + "api_key": tmdb_api_key, + "language": "zh-CN", + "append_to_response": "credits", + } + ) + endpoint = "movie" if clean_media_type == "movie" else "tv" + url = f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?{query}" + actors: List[str] = [] + try: + request = UrlRequest(url=url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + cast = ((payload.get("credits") or {}).get("cast") or []) if isinstance(payload, dict) else [] + for member in cast[:10]: + name = cls._clean_input((member or {}).get("name")) + department = cls._clean_input((member or {}).get("known_for_department")) + if not name: + continue + if department and department != "Acting": + continue + if name not in actors: + actors.append(name) + if len(actors) >= 2: + break + except Exception: + actors = [] + with cls._candidate_actor_cache_lock: + cls._candidate_actor_cache[cache_key] = list(actors) + return actors + + def _maybe_enrich_hdhive_candidate_with_actors( + self, + candidate: Dict[str, Any], + *, + enabled: bool = False, + ) -> Dict[str, Any]: + enriched = dict(candidate or {}) + if not enabled: + return enriched + actors = enriched.get("actors") or [] + if actors: + return enriched + enriched["actors"] = self._fetch_candidate_actors( + enriched.get("tmdb_id"), + str(enriched.get("media_type") or enriched.get("type") or ""), + ) + return enriched + + def _enrich_hdhive_candidates_with_actors( + self, + candidates: List[Dict[str, Any]], + *, + enabled: bool = False, + ) -> List[Dict[str, Any]]: + if not enabled: + return [dict(item) for item in candidates] + indexed_candidates = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] + pending = [ + (idx, candidate) + for idx, candidate in indexed_candidates + if not (candidate.get("actors") or []) + ] + enriched_map: Dict[int, Dict[str, Any]] = {idx: candidate for idx, candidate in indexed_candidates} + if pending: + max_workers = min(4, len(pending)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = { + executor.submit( + self._maybe_enrich_hdhive_candidate_with_actors, + candidate, + enabled=True, + ): idx + for idx, candidate in pending + } + for future in concurrent.futures.as_completed(future_map): + idx = future_map[future] + try: + enriched_map[idx] = future.result() + except Exception: + enriched_map[idx] = dict(indexed_candidates[idx][1]) + return [enriched_map[idx] for idx, _ in indexed_candidates] + + def _call_hdhive_unlock( + self, + slug: str, + *, + transfer_115: bool = True, + target_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + if self._should_use_agent_resource_officer(): + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/hdhive/unlock", + { + "slug": slug, + "path": target_path, + "transfer_115": transfer_115, + }, + ) + if self._requires_agent_resource_officer(): + return False, {}, "Agent影视助手 未加载" + plugin = self._get_running_plugin("HdhiveOpenApi") + if not plugin: + return False, {}, "HdhiveOpenApi 未加载" + ok, result, message = plugin.unlock_resource( + slug=slug, + remember=True, + transfer_115=transfer_115, + transfer_path=target_path, + ) + return ok, {"data": result}, message + + def _call_hdhive_transfer_115( + self, + share_url: str, + access_code: str = "", + target_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + if self._should_use_agent_resource_officer(): + return self._call_local_json_post( + "/api/v1/plugin/AgentResourceOfficer/p115/transfer", + { + "url": share_url, + "access_code": access_code, + "path": target_path, + }, + ) + if self._requires_agent_resource_officer(): + return False, {}, "Agent影视助手 未加载" + plugin = self._get_running_plugin("HdhiveOpenApi") + if not plugin: + return False, {}, "HdhiveOpenApi 未加载" + ok, result, message = plugin.transfer_115_share( + url=share_url, + access_code=access_code, + path=target_path, + remember=True, + trigger="FeishuCommandBridgeLong 智能入口", + ) + return ok, {"data": result}, message + + def _call_pansou_search(self, keyword: str) -> Tuple[bool, Dict[str, Any], str]: + last_error = "" + queries = [ + {"kw": keyword, "res": "merge", "src": "all"}, + {"kw": keyword}, + {"keyword": keyword}, + ] + urls = [] + for query in queries: + urls.append(f"http://host.docker.internal:805/api/search?{urlencode(query)}") + urls.append(f"http://127.0.0.1:805/api/search?{urlencode(query)}") + data: Dict[str, Any] = {} + for url in urls: + try: + request = UrlRequest(url=url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + data = json.loads(response.read().decode("utf-8", "ignore")) + break + except Exception as exc: + last_error = str(exc) + data = {} + if not data: + return False, {}, f"盘搜请求失败:{last_error or '未知错误'}" + ok = str(data.get("code")) == "0" + if not ok: + return False, data, str(data.get("message") or "盘搜搜索失败") + return True, data, str(data.get("message") or "success") + + @staticmethod + def _safe_points_text(item: Dict[str, Any]) -> str: + value = item.get("unlock_points") + if value is None or str(value).strip() == "": + return "未知" + return str(value) + + @staticmethod + def _format_hdhive_candidate_label(candidate: Dict[str, Any]) -> str: + title = str(candidate.get("title") or "未知影片").strip() + year = str(candidate.get("year") or "").strip() + media_type = str(candidate.get("media_type") or candidate.get("type") or "").strip() + actors = candidate.get("actors") or [] + parts = [] + if year: + parts.append(year) + if media_type: + parts.append(media_type) + if actors: + actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) + if actor_text: + parts.append(f"主演:{actor_text}") + if parts: + return f"{title} ({' | '.join(parts)})" + return title + + @staticmethod + def _format_hdhive_size(size: Any) -> str: + text = str(size or "").strip() + if not text or text.lower() == "none": + return "" + if re.search(r"[a-zA-Z]$", text): + return text + return f"{text}GB" + + @staticmethod + def _normalize_hdhive_pan_type(value: Any) -> str: + text = str(value or "").strip().lower() + if "115" in text: + return "115" + if "quark" in text: + return "quark" + return text or "未知" + + def _collect_hdhive_channel_items( + self, + items: List[Dict[str, Any]], + channel_name: str, + limit: int, + ) -> List[Dict[str, Any]]: + channel_results: List[Dict[str, Any]] = [] + seen = set() + for item in items: + if not isinstance(item, dict): + continue + pan_type = self._normalize_hdhive_pan_type(item.get("pan_type")) + if pan_type != channel_name: + continue + slug = str(item.get("slug") or "").strip() + title = str(item.get("title") or item.get("matched_title") or "未知资源").strip() + remark = str(item.get("remark") or "").strip() + key = slug or f"{title}|{remark}" + if key in seen: + continue + seen.add(key) + channel_results.append(item) + if len(channel_results) >= limit: + break + return channel_results + + def _format_hdhive_candidate_text( + self, + keyword: str, + candidates: List[Dict[str, Any]], + target_path: str, + page: int = 1, + page_size: int = 10, + ) -> str: + total = len(candidates) + safe_page_size = max(1, page_size) + total_pages = max(1, (total + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, page), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [ + f"影巢搜索:{keyword}", + f"候选影片:{total} 个,请先选择影片:", + ] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for candidate in page_items: + idx = int(candidate.get("index") or 0) + lines.append(f"{idx}. {self._format_hdhive_candidate_label(candidate)}") + lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") + lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + def _format_hdhive_search_text( + self, + keyword: str, + items: List[Dict[str, Any]], + selected_candidate: Optional[Dict[str, Any]], + target_path: str, + ) -> str: + channel_115 = self._collect_hdhive_channel_items(items, "115", 6) + channel_quark = self._collect_hdhive_channel_items(items, "quark", 6) + fallback_items = [] + if not channel_115 and not channel_quark: + fallback_items = [item for item in items[:12] if isinstance(item, dict)] + display_items: List[Dict[str, Any]] = [] + for item in channel_115: + display_items.append({**item, "index": len(display_items) + 1, "_channel": "115"}) + for item in channel_quark: + display_items.append({**item, "index": len(display_items) + 1, "_channel": "quark"}) + for item in fallback_items: + display_items.append( + { + **item, + "index": len(display_items) + 1, + "_channel": self._normalize_hdhive_pan_type(item.get("pan_type")), + } + ) + + lines = [f"影巢搜索:{keyword}"] + if selected_candidate: + lines.append(f"已选影片:{self._format_hdhive_candidate_label(selected_candidate)}") + if channel_115 or channel_quark: + lines.append( + f"资源结果:共 {len(items)} 条,当前展示 115 {len(channel_115)} 条、夸克 {len(channel_quark)} 条:" + ) + else: + lines.append(f"资源结果:共 {len(items)} 条,当前展示前 {len(display_items)} 条:") + + for cached in display_items: + idx = cached["index"] + channel = cached["_channel"] + if idx == 1 and channel == "115": + lines.append("🟦 115 结果") + elif channel == "quark" and idx == len(channel_115) + 1: + lines.append("🟨 夸克结果") + title = str(cached.get("remark") or cached.get("title") or cached.get("matched_title") or "未知资源").strip() + points = self._safe_points_text(cached) + if points == "0": + points_label = "免费" + elif points == "未知": + points_label = "积分未知" + else: + points_label = f"{points}分" + lines.append(f"{idx}. [{channel}][{points_label}] {title}") + + detail_parts = [] + matched_title = str(cached.get("matched_title") or "").strip() + matched_year = str(cached.get("matched_year") or "").strip() + if matched_title: + match_label = f"{matched_title} ({matched_year})" if matched_year else matched_title + detail_parts.append(f"匹配:{match_label}") + resolutions = [str(v).strip() for v in (cached.get("video_resolution") or []) if str(v).strip()] + if resolutions: + detail_parts.append("/".join(resolutions[:2])) + sources = [str(v).strip() for v in (cached.get("source") or []) if str(v).strip()] + if sources: + detail_parts.append("/".join(sources[:2])) + size_text = self._format_hdhive_size(cached.get("share_size")) + if size_text: + detail_parts.append(size_text) + if detail_parts: + lines.append(f" {' | '.join(detail_parts)}") + + if not display_items: + lines.append("当前没有可展示的资源结果。") + lines.append(f"下一步:回复“选择 1”即可解锁并转存到 {target_path}。") + if channel_quark: + start_index = len(channel_115) + 1 + lines.append(f"夸克结果从 {start_index} 开始编号;例如“选择 {start_index}”可直接处理第 1 条夸克结果。") + lines.append(f"如需改目录,可发“选择 1 path=/目录”或“选择 {start_index} path=/目录”。") + else: + lines.append("如需改目录,可发“选择 1 path=/目录”。") + return "\n".join(lines) + + def _format_smart_pick_text( + self, + selected: Dict[str, Any], + response_data: Dict[str, Any], + target_path: str, + ) -> str: + result = response_data.get("data") or {} + unlock_data = result.get("data") or {} + transfer_data = result.get("transfer_115") or {} + quark_transfer = result.get("transfer_quark") or {} + lines = [ + "影巢已执行解锁", + f"资源:{selected.get('title') or selected.get('matched_title') or '-'}", + f"积分:{self._safe_points_text(selected)}", + f"网盘:{selected.get('pan_type') or '-'}", + ] + if unlock_data.get("url") or unlock_data.get("full_url"): + lines.append("解锁结果:已返回资源链接") + success_lines: List[str] = [] + failure_lines: List[str] = [] + if transfer_data: + transfer_ok = bool(transfer_data.get("ok")) + if transfer_ok: + success_lines.extend( + [ + "115转存:成功", + f"目录:{transfer_data.get('path') or target_path}", + ] + ) + if transfer_data.get("message") and str(transfer_data.get("message")).strip().lower() != "success": + success_lines.append(f"详情:{transfer_data.get('message')}") + elif transfer_data.get("message"): + failure_lines.append(f"115转存失败:{transfer_data.get('message')}") + else: + transfer_msg = str(result.get("transfer_115_message") or "").strip() + if transfer_msg: + failure_lines.append(f"115转存失败:{transfer_msg}") + if quark_transfer: + quark_ok = bool(quark_transfer.get("ok")) + if quark_ok: + success_lines.extend( + [ + "夸克转存:成功", + f"目录:{quark_transfer.get('target_path') or target_path or '-'}", + ] + ) + if quark_transfer.get("message") and str(quark_transfer.get("message")).strip().lower() != "success": + success_lines.append(f"详情:{quark_transfer.get('message')}") + elif quark_transfer.get("message"): + failure_lines.append(f"夸克转存失败:{quark_transfer.get('message')}") + if success_lines: + lines.extend(success_lines) + elif failure_lines: + lines.append("自动转存:未成功") + lines.extend(failure_lines) + return "\n".join(lines) + + def _format_aro_route_text( + self, + selected: Dict[str, Any], + route_result: Dict[str, Any], + target_path: str, + ) -> str: + unlock = route_result.get("unlock") or {} + unlock_data = unlock.get("data") or {} + route = route_result.get("route") or {} + lines = [ + "影巢已执行解锁", + f"资源:{selected.get('title') or selected.get('matched_title') or '-'}", + f"积分:{self._safe_points_text(selected)}", + f"网盘:{selected.get('pan_type') or route.get('provider') or route.get('pan_type') or '-'}", + ] + if unlock_data.get("url") or unlock_data.get("full_url"): + lines.append("解锁结果:已返回资源链接") + provider = str(route.get("provider") or route.get("pan_type") or "").strip().lower() + message = str(route.get("message") or "").strip() + final_path = str(route.get("target_path") or target_path or "").strip() + if provider == "115": + lines.append("115转存:成功") + elif provider == "quark": + lines.append("夸克转存:成功") + else: + lines.append("自动路由:已完成") + if final_path: + lines.append(f"目录:{final_path}") + if message and message.lower() != "success": + lines.append(f"详情:{message}") + return "\n".join(lines) + + def _format_pansou_pick_text( + self, + selected: Dict[str, Any], + share_kind: str, + response_data: Dict[str, Any], + target_path: str, + ) -> str: + result = response_data.get("data") or {} + title = str(selected.get("note") or "未命名资源").strip() + lines = [ + "盘搜结果已执行转存", + f"资源:{title}", + f"类型:{share_kind}", + ] + if share_kind == "quark": + lines.append(f"目录:{result.get('target_path') or target_path or '-'}") + else: + lines.append(f"目录:{result.get('path') or target_path}") + lines.append(f"结果:{result.get('message') or 'success'}") + return "\n".join(lines) + + @staticmethod + def _format_115_error_text(message: str) -> str: + text = str(message or "").strip() + if not text: + return "115 转存失败:未知错误" + if text.startswith("115 转存失败") or text.startswith("影巢解锁成功,但 115 转存失败"): + return text + return f"115 转存失败:{text}" + + @staticmethod + def _compact_115_result(result: Dict[str, Any]) -> Dict[str, Any]: + compact = { + "ok": bool(result.get("ok")), + "path": result.get("path"), + "message": result.get("message"), + } + media_info = ((result.get("data") or {}).get("media_info") or {}) + if isinstance(media_info, dict): + compact["media"] = { + "title": media_info.get("title"), + "year": media_info.get("year"), + "type": media_info.get("type"), + "category": media_info.get("category"), + } + return compact + + @staticmethod + def _compact_unlock_result(result: Dict[str, Any]) -> Dict[str, Any]: + unlock_data = result.get("data") or {} + transfer_data = result.get("transfer_115") or {} + quark_transfer = result.get("transfer_quark") or {} + compact = { + "ok": bool(result.get("ok")), + "status_code": result.get("status_code"), + "message": result.get("message"), + "slug": result.get("slug"), + "share_url": unlock_data.get("full_url") or unlock_data.get("url"), + "access_code": unlock_data.get("access_code"), + } + if transfer_data: + compact["transfer_115"] = { + "ok": bool(transfer_data.get("ok")), + "path": transfer_data.get("path"), + "message": transfer_data.get("message"), + } + elif result.get("transfer_115_message"): + compact["transfer_115"] = { + "ok": False, + "path": None, + "message": result.get("transfer_115_message"), + } + if quark_transfer: + compact["transfer_quark"] = { + "ok": bool(quark_transfer.get("ok")), + "target_path": quark_transfer.get("target_path"), + "task_id": quark_transfer.get("task_id"), + "saved_count": quark_transfer.get("saved_count"), + "message": quark_transfer.get("message"), + } + return compact + + def _execute_smart_entry( + self, + arg: str, + cache_key: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + if self._should_force_aro_for_p115_login(arg): + ok, payload, message = self._call_aro_assistant_route(cache_key, arg) + data = payload.get("data") or {} + text = str(message or "处理失败").strip() + return ok, text, { + "action": data.get("action") or "assistant_route", + "ok": ok, + "message": text, + "result": data, + } + if self._should_use_agent_resource_officer(): + ok, payload, message = self._call_aro_assistant_route(cache_key, arg) + data = payload.get("data") or {} + text = str(message or "处理失败").strip() + return ok, text, { + "action": data.get("action") or "assistant_route", + "ok": ok, + "message": text, + "result": data, + } + parsed = self._parse_smart_arg(arg) + share_url = parsed["url"] + access_code = parsed["access_code"] + target_path = parsed["path"] + keyword = parsed["keyword"] + media_type = parsed["type"] + year = parsed["year"] + + # Keep 115 direct-link handling on the new ARO path so pending-task, + # login-resume and cancellation all stay in the same session chain. + if share_url and self._detect_share_kind(share_url) == "115" and self._has_agent_resource_officer(): + ok, payload, message = self._call_aro_assistant_route(cache_key, arg) + data = payload.get("data") or {} + text = str(message or "处理失败").strip() + return ok, text, { + "action": data.get("action") or "assistant_route", + "ok": ok, + "message": text, + "result": data, + } + + if share_url: + share_kind = self._detect_share_kind(share_url) + if share_kind == "quark": + final_path = target_path or self._get_quark_default_path() + ok, payload, message = self._call_quark_transfer(share_url, access_code, final_path) + result = payload.get("data") or {} + text = ( + "夸克转存已完成\n" + f"目录:{result.get('target_path') or final_path or '-'}" + if ok + else f"夸克转存失败:{message or '未知错误'}" + ) + return ok, text, { + "action": "quark_transfer", + "ok": ok, + "message": message or text, + "result": { + "target_path": result.get("target_path"), + "task_id": result.get("task_id"), + "saved_count": result.get("saved_count"), + }, + } + if share_kind == "115": + final_path = target_path or self._get_hdhive_default_path() + ok, payload, message = self._call_hdhive_transfer_115(share_url, access_code, final_path) + result = payload.get("data") or {} + text = ( + "115 转存已完成\n" + f"目录:{result.get('path') or final_path}\n" + f"结果:{result.get('message') or 'success'}" + if ok + else self._format_115_error_text(message) + ) + return ok, text, { + "action": "transfer_115", + "ok": ok, + "message": message or text, + "result": self._compact_115_result(result), + } + return False, "暂不支持该分享链接类型,请发送夸克链接、115 链接或影巢片名。", { + "action": "unknown_url", + "ok": False, + "message": "unsupported url", + } + + if not keyword: + return False, "未识别到可处理内容。你可以发送片名,或直接发送夸克/115 分享链接。", { + "action": "empty", + "ok": False, + "message": "empty input", + } + + final_path = target_path or self._get_hdhive_default_path() + if self._should_use_agent_resource_officer(): + ok, payload, message = self._call_aro_hdhive_session_search( + keyword=keyword, + media_type=media_type, + year=year, + target_path=final_path, + ) + result = payload.get("data") or {} + candidates = result.get("candidates") or [] + if not ok: + return False, f"影巢搜索失败:{message or '暂无结果'}", { + "action": "hdhive_candidates", + "ok": False, + "message": message or "session search failed", + } + session_id = str(result.get("session_id") or "").strip() + if not candidates or not session_id: + text = result.get("text") or f"影巢搜索失败:{message or '暂无结果'}" + return False, text, { + "action": "hdhive_candidates", + "ok": False, + "message": message or "empty candidates", + } + self._set_smart_cache( + cache_key, + action="aro_hdhive", + items=[], + target_path=final_path, + keyword=keyword, + meta={ + "session_id": session_id, + "stage": "candidate", + "media_type": media_type, + "year": year, + "candidate_count": len(candidates), + }, + ) + if len(candidates) == 1: + pick_ok, pick_text, pick_data = self._execute_smart_pick("1", cache_key) + return pick_ok, pick_text, pick_data + text = str(result.get("text") or "").strip() or self._format_hdhive_candidate_text( + keyword, + [ + { + **dict(candidate or {}), + "index": idx, + } + for idx, candidate in enumerate(candidates, start=1) + ], + final_path, + page=1, + page_size=self._hdhive_candidate_page_size, + ) + return True, text, { + "action": "hdhive_candidates", + "ok": True, + "keyword": keyword, + "path": final_path, + "candidate_count": len(candidates), + "next_action": "pick_candidate", + "session_id": session_id, + } + candidate_page_size = 10 + ok, payload, message = self._call_hdhive_search(keyword, media_type, year, candidate_limit=30, limit=20) + result = payload.get("data") or {} + items = result.get("data") or [] + candidates = result.get("candidates") or [] + if not ok or not items: + text = f"影巢搜索失败:{message or result.get('message') or '暂无结果'}" + if candidates and not items: + text = ( + f"已解析到 {len(candidates)} 个候选影片,但影巢暂无可用资源:{keyword}\n" + "可以换个年份、片名别名,或稍后再试。" + ) + return False, text, { + "action": "hdhive_search", + "ok": False, + "message": message or result.get("message") or text, + "candidates": candidates, + "items": [], + } + + if len(candidates) > 1: + cached_candidates = [] + public_candidates = [] + for index, candidate in enumerate(candidates, start=1): + cached = dict(candidate) + cached["index"] = index + cached_candidates.append(cached) + public_candidates.append( + { + "index": index, + "tmdb_id": candidate.get("tmdb_id"), + "title": candidate.get("title"), + "year": candidate.get("year"), + "media_type": candidate.get("media_type"), + "actors": candidate.get("actors") or [], + } + ) + self._set_smart_cache( + cache_key, + action="hdhive_candidates", + items=cached_candidates, + target_path=final_path, + keyword=keyword, + meta={ + "media_type": media_type, + "year": year, + "page": 1, + "page_size": candidate_page_size, + }, + ) + text = self._format_hdhive_candidate_text( + keyword, + cached_candidates, + final_path, + page=1, + page_size=candidate_page_size, + ) + return True, text, { + "action": "hdhive_candidates", + "ok": True, + "keyword": keyword, + "path": final_path, + "candidates": public_candidates, + "next_action": "pick_candidate", + } + + cached_items = [] + public_items = [] + selected_candidate = candidates[0] if candidates else {} + for item in self._collect_hdhive_channel_items(items, "115", 6) + self._collect_hdhive_channel_items(items, "quark", 6): + cached = dict(item) + cached["index"] = len(cached_items) + 1 + cached_items.append(cached) + if not cached_items: + for item in items[:12]: + cached = dict(item) + cached["index"] = len(cached_items) + 1 + cached_items.append(cached) + for item in cached_items: + cached = dict(item) + public_items.append( + { + "index": cached.get("index"), + "title": item.get("title"), + "year": item.get("year"), + "pan_type": item.get("pan_type"), + "unlock_points": item.get("unlock_points"), + "matched_title": item.get("matched_title"), + "matched_year": item.get("matched_year"), + } + ) + self._set_smart_cache( + cache_key, + action="hdhive_search", + items=cached_items, + target_path=final_path, + keyword=keyword, + meta={"media_type": media_type, "year": year, "candidate": selected_candidate}, + ) + text = self._format_hdhive_search_text(keyword, cached_items, selected_candidate, final_path) + return True, text, { + "action": "hdhive_search", + "ok": True, + "keyword": keyword, + "path": final_path, + "items": public_items, + "candidate_count": len(candidates), + "next_action": "pick", + } + + def _execute_smart_pick( + self, + arg: str, + cache_key: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + index, override_path, pick_action = self._parse_pick_arg(arg) + if self._should_use_agent_resource_officer(): + if index <= 0 and not pick_action: + return False, "请选择有效序号,例如:选择 1", { + "action": "pick", + "ok": False, + "message": "invalid index", + } + ok, payload, message = self._call_aro_assistant_pick( + cache_key, + index, + override_path or "", + pick_action, + ) + data = payload.get("data") or {} + text = str(message or "处理失败").strip() + return ok, text, { + "action": data.get("action") or "assistant_pick", + "ok": ok, + "message": text, + "result": data, + } + cache = self._get_smart_cache(cache_key) + if not cache: + return False, "没有可继续的缓存,请先发送:处理 片名 或 处理 分享链接", { + "action": "pick", + "ok": False, + "message": "cache not found", + } + cache_action = cache.get("action") + if pick_action == "detail": + if cache_action != "hdhive_candidates": + return False, "当前结果不支持详情补充,请先发送影巢搜索。", { + "action": "pick", + "ok": False, + "message": "detail unsupported", + } + items = cache.get("items") or [] + if not items: + return False, "当前没有可补充的候选影片。", { + "action": "hdhive_candidates", + "ok": False, + "message": "empty candidates", + } + meta = dict(cache.get("meta") or {}) + page_size = int(meta.get("page_size") or 10) + current_page = int(meta.get("page") or 1) + final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() + start = max(0, (max(1, current_page) - 1) * max(1, page_size)) + end = start + max(1, page_size) + enriched_items = [dict(item or {}) for item in items] + enriched_page_items = self._enrich_hdhive_candidates_with_actors( + enriched_items[start:end], + enabled=True, + ) + enriched_items[start:end] = enriched_page_items + self._set_smart_cache( + cache_key, + action="hdhive_candidates", + items=enriched_items, + target_path=final_path, + keyword=cache.get("keyword") or "", + meta=meta, + ) + text = self._format_hdhive_candidate_text( + cache.get("keyword") or "", + enriched_items, + final_path, + page=current_page, + page_size=page_size, + ) + return True, text, { + "action": "hdhive_candidates", + "ok": True, + "keyword": cache.get("keyword") or "", + "path": final_path, + "page": current_page, + "next_action": "pick_candidate", + } + if pick_action == "next_page": + if cache_action != "hdhive_candidates": + return False, "当前结果不支持翻页,请直接回复编号继续。", { + "action": "pick", + "ok": False, + "message": "next page unsupported", + } + items = cache.get("items") or [] + meta = dict(cache.get("meta") or {}) + page_size = int(meta.get("page_size") or 10) + total_pages = max(1, (len(items) + page_size - 1) // page_size) + current_page = int(meta.get("page") or 1) + if current_page >= total_pages: + return False, "已经是最后一页了,可以直接回复编号继续选择。", { + "action": "hdhive_candidates", + "ok": False, + "message": "already last page", + } + next_page = current_page + 1 + final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() + meta["page"] = next_page + self._set_smart_cache( + cache_key, + action="hdhive_candidates", + items=items, + target_path=final_path, + keyword=cache.get("keyword") or "", + meta=meta, + ) + text = self._format_hdhive_candidate_text( + cache.get("keyword") or "", + items, + final_path, + page=next_page, + page_size=page_size, + ) + return True, text, { + "action": "hdhive_candidates", + "ok": True, + "keyword": cache.get("keyword") or "", + "path": final_path, + "page": next_page, + "total_pages": total_pages, + "next_action": "pick_candidate", + } + if index <= 0: + return False, "请选择有效序号,例如:选择 1", { + "action": "pick", + "ok": False, + "message": "invalid index", + } + items = cache.get("items") or [] + if cache_action == "aro_hdhive": + if pick_action in {"detail", "next_page"}: + return False, "当前后端暂不支持详情补充或翻页,请直接回复编号继续。", { + "action": "pick", + "ok": False, + "message": "unsupported action for aro session", + } + meta = cache.get("meta") or {} + session_id = str(meta.get("session_id") or "").strip() + final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() + if not session_id: + return False, "当前会话缺少 session_id,请重新发起影巢搜索。", { + "action": "pick", + "ok": False, + "message": "session id missing", + } + ok, payload, message = self._call_aro_hdhive_session_pick( + session_id=session_id, + index=index, + target_path=final_path, + ) + result = payload.get("data") or {} + if not ok: + return False, message or "资源处理失败", { + "action": "aro_hdhive", + "ok": False, + "message": message or "session pick failed", + } + stage = str(result.get("stage") or "").strip() + if stage == "resource": + selected_candidate = dict(result.get("selected_candidate") or {}) + resources = [dict(item or {}) for item in (result.get("resources") or [])] + self._set_smart_cache( + cache_key, + action="aro_hdhive", + items=[], + target_path=final_path, + keyword=cache.get("keyword") or "", + meta={ + **meta, + "session_id": session_id, + "stage": "resource", + "candidate": selected_candidate, + }, + ) + text = str(result.get("text") or "").strip() or self._format_hdhive_search_text( + cache.get("keyword") or "", + resources, + selected_candidate, + final_path, + ) + return True, text, { + "action": "hdhive_search", + "ok": True, + "keyword": cache.get("keyword") or "", + "path": final_path, + "session_id": session_id, + "next_action": "pick", + } + selected_resource = dict(result.get("selected_resource") or {}) + route_result = dict(result.get("result") or {}) + text = str(result.get("text") or "").strip() or self._format_aro_route_text( + selected_resource, + route_result, + final_path, + ) + return True, text, { + "action": "hdhive_unlock", + "ok": True, + "path": final_path, + "session_id": session_id, + "result": route_result, + } + if index > len(items): + return False, f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。", { + "action": "pick", + "ok": False, + "message": "index out of range", + } + selected = items[index - 1] + if cache_action == "pansou_search": + share_url = str(selected.get("url") or "").strip() + access_code = str(selected.get("password") or "").strip() + share_kind = self._detect_share_kind(share_url) + final_path = override_path or ( + self._get_hdhive_default_path() + if share_kind == "115" + else self._get_quark_default_path() + if share_kind == "quark" + else cache.get("target_path") or "" + ) + if share_kind == "115": + ok, payload, message = self._call_hdhive_transfer_115( + share_url, + access_code, + final_path, + ) + if not ok: + return False, self._format_115_error_text(message), { + "action": "transfer_115", + "ok": False, + "message": message or "transfer failed", + } + text = self._format_pansou_pick_text(selected, share_kind, payload, final_path) + return True, text, { + "action": "transfer_115", + "ok": True, + "path": final_path, + "item": { + "index": selected.get("index"), + "title": selected.get("note"), + "source": selected.get("source"), + "channel": selected.get("channel"), + }, + "result": self._compact_115_result(payload.get("data") or {}), + } + if share_kind == "quark": + ok, payload, message = self._call_quark_transfer( + share_url, + access_code, + final_path, + ) + if not ok: + return False, f"夸克转存失败:{message or '未知错误'}", { + "action": "quark_transfer", + "ok": False, + "message": message or "transfer failed", + } + text = self._format_pansou_pick_text(selected, share_kind, payload, final_path) + result = payload.get("data") or {} + return True, text, { + "action": "quark_transfer", + "ok": True, + "path": final_path, + "item": { + "index": selected.get("index"), + "title": selected.get("note"), + "source": selected.get("source"), + "channel": selected.get("channel"), + }, + "result": { + "target_path": result.get("target_path"), + "task_id": result.get("task_id"), + "saved_count": result.get("saved_count"), + }, + } + return False, "当前盘搜结果不是 115 或夸克链接,暂不支持直接转存。", { + "action": "pick", + "ok": False, + "message": "unsupported pansou result", + } + if cache_action == "hdhive_candidates": + tmdb_id = selected.get("tmdb_id") + if not tmdb_id: + return False, "当前候选影片缺少 TMDB ID,无法继续查询资源。", { + "action": "hdhive_candidates", + "ok": False, + "message": "tmdb_id missing", + } + meta = cache.get("meta") or {} + final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() + media_type = str(selected.get("media_type") or meta.get("media_type") or "movie").strip() + year = str(selected.get("year") or meta.get("year") or "").strip() + ok, payload, message = self._call_hdhive_search_by_tmdb(tmdb_id, media_type, year=year, limit=20) + result = payload.get("data") or {} + items = result.get("data") or [] + if not items: + candidate_label = self._format_hdhive_candidate_label(selected) + hint = ( + f"影巢当前暂无资源:{candidate_label}\n" + "可以直接回复其他编号,继续查看别的候选影片。" + ) + if not ok: + reason = message or result.get("message") or "暂无结果" + hint = f"影巢搜索失败:{reason}\n{hint}" + return False, hint, { + "action": "hdhive_search", + "ok": False, + "message": message or result.get("message") or "no results", + "candidate": { + "index": selected.get("index"), + "tmdb_id": tmdb_id, + "title": selected.get("title"), + "year": selected.get("year"), + "media_type": selected.get("media_type"), + }, + } + cached_items = [] + for item in self._collect_hdhive_channel_items(items, "115", 6) + self._collect_hdhive_channel_items(items, "quark", 6): + cached = dict(item) + cached["index"] = len(cached_items) + 1 + cached_items.append(cached) + if not cached_items: + for item in items[:12]: + cached = dict(item) + cached["index"] = len(cached_items) + 1 + cached_items.append(cached) + self._set_smart_cache( + cache_key, + action="hdhive_search", + items=cached_items, + target_path=final_path, + keyword=cache.get("keyword") or "", + meta={"media_type": media_type, "year": year, "candidate": selected}, + ) + text = self._format_hdhive_search_text(cache.get("keyword") or "", cached_items, selected, final_path) + return True, text, { + "action": "hdhive_search", + "ok": True, + "keyword": cache.get("keyword") or "", + "path": final_path, + "candidate": { + "index": selected.get("index"), + "tmdb_id": tmdb_id, + "title": selected.get("title"), + "year": selected.get("year"), + "media_type": selected.get("media_type"), + "actors": selected.get("actors") or [], + }, + "next_action": "pick", + } + if cache_action != "hdhive_search": + return False, "当前缓存不支持按编号继续,请先发送影巢搜索或盘搜搜索。", { + "action": "pick", + "ok": False, + "message": "unsupported cache action", + } + slug = str(selected.get("slug") or "").strip() + if not slug: + return False, "当前资源缺少 slug,无法继续解锁。", { + "action": "pick", + "ok": False, + "message": "slug missing", + } + default_path = ( + self._get_quark_default_path() + if str(selected.get("pan_type") or "").strip().lower() == "quark" + else self._get_hdhive_default_path() + ) + final_path = override_path or default_path + ok, payload, message = self._call_hdhive_unlock( + slug, + transfer_115=True, + target_path=final_path, + ) + if not ok: + return False, f"影巢解锁失败:{message or '未知错误'}", { + "action": "hdhive_unlock", + "ok": False, + "message": message or "unlock failed", + } + result = payload.get("data") or {} + unlock_data = result.get("data") or {} + share_url = str(unlock_data.get("full_url") or unlock_data.get("url") or "").strip() + access_code = str(unlock_data.get("access_code") or "").strip() + if self._detect_share_kind(share_url) == "quark": + quark_ok, quark_payload, quark_message = self._call_quark_transfer( + share_url, + access_code, + final_path, + ) + quark_result = quark_payload.get("data") or {} + result["transfer_quark"] = { + "ok": quark_ok, + "target_path": quark_result.get("target_path") or final_path, + "task_id": quark_result.get("task_id"), + "saved_count": quark_result.get("saved_count"), + "message": quark_message or quark_result.get("message"), + } + text = self._format_smart_pick_text(selected, payload, final_path) + return True, text, { + "action": "hdhive_unlock", + "ok": True, + "path": final_path, + "item": { + "index": selected.get("index"), + "title": selected.get("title"), + "year": selected.get("year"), + "pan_type": selected.get("pan_type"), + "unlock_points": selected.get("unlock_points"), + }, + "result": self._compact_unlock_result(payload.get("data") or {}), + } + + def _execute_media_search(self, keyword: str, cache_key: str) -> str: + try: + meta = MetaInfo(keyword) + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return f"未识别到媒体信息:{keyword}" + + season = meta.begin_season if meta.begin_season else mediainfo.season + results = SearchChain().search_by_id( + tmdbid=mediainfo.tmdb_id, + doubanid=mediainfo.douban_id, + mtype=mediainfo.type, + season=season, + cache_local=False, + ) or [] + if not results: + return f"已识别 {self._format_media_label(mediainfo, season)},但暂未搜索到资源。" + + self._set_search_cache(cache_key, keyword, mediainfo, results) + lines = [ + f"已识别:{self._format_media_label(mediainfo, season)}", + f"共找到 {len(results)} 条资源,展示前 {min(len(results), 10)} 条:", + ] + for idx, context in enumerate(results[:10], start=1): + torrent = context.torrent_info + title = str(torrent.title or "").strip() + size = StringUtils.str_filesize(torrent.size) if torrent.size else "未知" + seeders = torrent.seeders if torrent.seeders is not None else "?" + site = torrent.site_name or "未知站点" + volume = torrent.volume_factor if getattr(torrent, "volume_factor", None) else "未知" + lines.append(f"{idx}. [{site}] {title}") + lines.append(f" 大小:{size} | 做种:{seeders} | 促销:{volume}") + lines.append("下一步:回复“下载资源 序号”即可下载选中项。") + lines.append("如需长期跟踪,回复“订阅媒体 片名”或“订阅并搜索 片名”。") + return "\n".join(lines) + except Exception as exc: + logger.error( + f"[FeishuCommandBridge] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}" + ) + return f"搜索资源失败:{keyword}\n错误:{exc}" + + def _execute_pansou_search(self, keyword: str, cache_key: str = "") -> str: + ok, payload, message = self._call_pansou_search(keyword) + if not ok: + return f"盘搜搜索失败:{keyword}\n错误:{message}" + + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + + def normalize_channel_name(channel: str) -> str: + text = str(channel or "").strip().lower() + if text == "115" or "115" in text: + return "115" + if "quark" in text: + return "quark" + return str(channel or "").strip() or "未知" + + def collect_channel_items(channel_name: str, limit: int) -> List[Dict[str, Any]]: + raw_items = merged.get(channel_name) or [] + if not isinstance(raw_items, list): + return [] + results: List[Dict[str, Any]] = [] + seen = set() + for item in raw_items: + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + if not url: + continue + note = str(item.get("note") or "未命名资源").strip() + password = str(item.get("password") or "").strip() + source = str(item.get("source") or "").strip() + dt = self._format_pansou_datetime(item.get("datetime")) + key = (url, note) + if key in seen: + continue + seen.add(key) + results.append( + { + "channel": normalize_channel_name(channel_name), + "url": url, + "password": password, + "note": note, + "source": source, + "datetime": dt, + } + ) + if len(results) >= limit: + break + return results + + channel_115 = collect_channel_items("115", 6) + channel_quark = collect_channel_items("quark", 6) + cached_items: List[Dict[str, Any]] = [] + for item in channel_115: + cached_items.append({**item, "index": len(cached_items) + 1}) + for item in channel_quark: + cached_items.append({**item, "index": len(cached_items) + 1}) + + if not cached_items: + return f"盘搜暂无结果:{keyword}" + + total = int(data.get("total") or (len(channel_115) + len(channel_quark))) + if cache_key and cached_items: + self._set_smart_cache( + cache_key, + action="pansou_search", + keyword=keyword, + target_path=self._get_hdhive_default_path(), + items=cached_items, + ) + lines = [ + f"盘搜搜索:{keyword}", + ( + f"共找到 {total} 条结果,当前展示 115 {len(channel_115)} 条" + f"、夸克 {len(channel_quark)} 条:" + ), + ] + for idx, cached in enumerate(cached_items): + idx = cached["index"] + channel = cached["channel"] + note = cached["note"] + url = cached["url"] + password = cached["password"] + source = cached["source"] + dt = cached.get("datetime") or "" + if idx == 1: + lines.append("🟦 115 结果") + elif channel == "quark" and idx == len(channel_115) + 1: + lines.append("🟨 夸克结果") + title_line = f"{idx}. [{channel}] {note}" + lines.append(title_line) + detail_parts = [] + if source: + detail_parts.append(source) + if dt: + detail_parts.append(dt) + if detail_parts: + lines.append(f" {' · '.join(detail_parts)}") + if password: + lines.append(f" 提取码:{password}") + lines.append(f" {url}") + lines.append("下一步:回复“选择 1”即可直接转存支持的 115 / 夸克结果。") + if channel_quark: + start_index = len(channel_115) + 1 + lines.append(f"夸克结果从 {start_index} 开始编号;例如“选择 {start_index}”可直接处理第 1 条夸克结果。") + next_quark_hint = len(channel_115) + 1 if channel_quark else 1 + lines.append(f"如需改目录,可发“选择 1 path=/目录”或“选择 {next_quark_hint} path=/目录”。") + return "\n".join(lines) + + def _execute_media_download(self, index: int, cache_key: str) -> str: + cache = self._get_search_cache(cache_key) + if not cache: + return "没有可用的搜索缓存,请先发送:搜索资源 片名" + results = cache.get("results") or [] + if index < 1 or index > len(results): + return f"序号超出范围,请输入 1 到 {len(results)} 之间的数字。" + context = copy.deepcopy(results[index - 1]) + torrent = context.torrent_info + try: + download_id = DownloadChain().download_single( + context=context, + username="feishucommandbridgelong", + source="FeishuCommandBridgeLong", + ) + if not download_id: + return f"下载提交失败:{torrent.title}" + return ( + f"已提交下载:{torrent.title}\n" + f"站点:{torrent.site_name or '未知站点'}\n" + f"任务ID:{download_id}" + ) + except Exception as exc: + logger.error( + f"[FeishuCommandBridge] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}" + ) + return f"下载资源失败:{torrent.title}\n错误:{exc}" + + def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: + meta = MetaInfo(keyword) + season = meta.begin_season + try: + sid, message = SubscribeChain().add( + title=keyword, + year=meta.year, + mtype=meta.type, + season=season, + username="feishucommandbridgelong", + exist_ok=True, + message=False, + ) + if not sid: + return f"订阅失败:{keyword}\n原因:{message}" + lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] + if immediate_search: + Scheduler().start( + job_id="subscribe_search", + **{"sid": sid, "state": None, "manual": True}, + ) + lines.append("已触发一次订阅搜索。") + return "\n".join(lines) + except Exception as exc: + logger.error( + f"[FeishuCommandBridge] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}" + ) + return f"订阅失败:{keyword}\n错误:{exc}" + + def _run_quark_save( + self, + arg: str, + receive_chat_id: str, + receive_open_id: str, + ) -> None: + summary = self._execute_quark_save(arg) + self._reply_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + text=summary, + ) + + @staticmethod + def _parse_quark_save_arg(arg: str) -> Tuple[str, str, str]: + text = str(arg or "").strip() + url_match = re.search(r"https?://[^\s<>\"']+", text) + share_url = url_match.group(0).rstrip(".,);]") if url_match else "" + access_code = "" + target_path = "" + remain = text.replace(share_url, " ").strip() if share_url else text + for token in remain.split(): + item = token.strip() + if not item: + continue + if "=" in item: + key, value = item.split("=", 1) + key = key.strip().lower() + value = value.strip() + if key in {"pwd", "passcode", "code", "提取码"} and value: + access_code = value + continue + if key in {"path", "dir", "目录", "位置"} and value: + target_path = value + continue + if item.startswith("/") and not target_path: + target_path = item + continue + if not access_code and len(item) <= 8: + access_code = item + return share_url, access_code, FeishuCommandBridgeLong._resolve_pan_path_value(target_path) + + def _execute_quark_save(self, arg: str) -> str: + share_url, access_code, target_path = self._parse_quark_save_arg(arg) + if not share_url: + return ( + "夸克转存失败:未识别到分享链接\n" + "用法:夸克转存 分享链接 pwd=提取码 path=/保存目录" + ) + + ok, payload, message = self._call_quark_transfer( + share_url=share_url, + access_code=access_code, + target_path=target_path or self._get_quark_default_path(), + ) + if not ok: + return f"夸克转存失败:{message or '未知错误'}" + + result = payload.get("data") or {} + return "\n".join( + [ + "夸克转存已完成", + f"目录:{result.get('target_path') or target_path or self._get_quark_default_path() or '-'}", + ] + ) + + @staticmethod + def _format_media_label(mediainfo: Any, season: Optional[int] = None) -> str: + title = getattr(mediainfo, "title", "") or "未知媒体" + year = getattr(mediainfo, "year", None) + label = f"{title} ({year})" if year else title + media_type = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type, "name", "") + if media_type_name == "TV" and season: + return f"{label} 第{season}季" + return label + + def _extract_text(self, content: Any) -> str: + if isinstance(content, dict): + return str(content.get("text") or "").strip() + if isinstance(content, str): + try: + payload = json.loads(content) + except json.JSONDecodeError: + return content.strip() + return str(payload.get("text") or "").strip() + return "" + + @staticmethod + def _sanitize_text(text: str) -> str: + text = re.sub(r"]*>.*?", " ", text or "", flags=re.IGNORECASE) + text = re.sub(r"\s+", " ", text).strip() + return text + + @staticmethod + def _split_lines(value: Any) -> List[str]: + return [line.strip() for line in str(value or "").splitlines() if line.strip()] + + @staticmethod + def _split_commands(value: Any) -> List[str]: + raw = str(value or "").replace("\n", ",") + return [item.strip() for item in raw.split(",") if item.strip()] + + @staticmethod + def _mask_secret(value: str) -> str: + value = str(value or "").strip() + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + + def _reply_if_needed( + self, + receive_chat_id: str, + receive_open_id: str, + text: str, + ) -> None: + if not self._reply_enabled: + return + if not self._app_id or not self._app_secret: + return + + receive_id_type = self._reply_receive_id_type + receive_id = receive_chat_id if receive_id_type == "chat_id" else receive_open_id + if not receive_id: + return + + access_token = self._get_tenant_access_token() + if not access_token: + return + + url = ( + "https://open.feishu.cn/open-apis/im/v1/messages" + f"?receive_id_type={receive_id_type}" + ) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + } + payload = { + "receive_id": receive_id, + "msg_type": "text", + "content": json.dumps({"text": text}, ensure_ascii=False), + } + logger.info(f"[FeishuCommandBridge] 准备回复飞书:{text}") + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[FeishuCommandBridge] failed to send reply to Feishu") + return + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[FeishuCommandBridge] reply failed: " + f"status={response.status_code} body={data}" + ) + + def _upload_image_to_feishu(self, image_bytes: bytes, file_name: str = "qrcode.png") -> Optional[str]: + if not image_bytes or not self._app_id or not self._app_secret: + return None + access_token = self._get_tenant_access_token() + if not access_token: + return None + headers = {"Authorization": f"Bearer {access_token}"} + response = RequestUtils(headers=headers).post( + url="https://open.feishu.cn/open-apis/im/v1/images", + data={"image_type": "message"}, + files={"image": (file_name, image_bytes, "image/png")}, + ) + if response is None: + logger.error("[FeishuCommandBridge] 上传飞书图片失败:无响应") + return None + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[FeishuCommandBridge] 上传飞书图片失败: status={response.status_code} body={data}" + ) + return None + return str(((data.get("data") or {}).get("image_key")) or "").strip() or None + + def _reply_image_if_needed( + self, + receive_chat_id: str, + receive_open_id: str, + image_key: str, + ) -> None: + if not image_key or not self._reply_enabled or not self._app_id or not self._app_secret: + return + receive_id_type = self._reply_receive_id_type + receive_id = receive_chat_id if receive_id_type == "chat_id" else receive_open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + } + payload = { + "receive_id": receive_id, + "msg_type": "image", + "content": json.dumps({"image_key": image_key}, ensure_ascii=False), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[FeishuCommandBridge] 发送飞书图片失败:无响应") + return + try: + data = response.json() + except Exception: + data = {} + if response.status_code != 200 or data.get("code") not in (0, None): + logger.error( + f"[FeishuCommandBridge] 发送飞书图片失败: status={response.status_code} body={data}" + ) + + def _reply_qrcode_data_url_if_needed( + self, + receive_chat_id: str, + receive_open_id: str, + data_url: str, + ) -> None: + text = str(data_url or "").strip() + if not text.startswith("data:image/") or ";base64," not in text: + return + _, _, payload = text.partition(";base64,") + try: + image_bytes = b64decode(payload) + except Exception as exc: + logger.error(f"[FeishuCommandBridge] 解码二维码图片失败:{exc}") + return + image_key = self._upload_image_to_feishu(image_bytes=image_bytes, file_name="p115-qrcode.png") + if image_key: + self._reply_image_if_needed( + receive_chat_id=receive_chat_id, + receive_open_id=receive_open_id, + image_key=image_key, + ) + + def _get_tenant_access_token(self) -> Optional[str]: + now = time.time() + with self._token_lock: + token = self._token_cache.get("token") + expires_at = float(self._token_cache.get("expires_at") or 0) + if token and now < expires_at - 60: + return token + + response = RequestUtils(content_type="application/json").post( + url="https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/", + json={"app_id": self._app_id, "app_secret": self._app_secret}, + ) + if response is None: + logger.error("[FeishuCommandBridge] failed to fetch tenant access token") + return None + try: + data = response.json() + except Exception as exc: + logger.error( + f"[FeishuCommandBridge] invalid token response from Feishu: {exc}" + ) + return None + + token = data.get("tenant_access_token") + expire = int(data.get("expire") or 0) + if not token: + logger.error( + f"[FeishuCommandBridge] token missing in response: {data}" + ) + return None + self._token_cache = {"token": token, "expires_at": now + expire} + return token diff --git a/FeishuCommandBridgeLong/requirements.txt b/FeishuCommandBridgeLong/requirements.txt new file mode 100644 index 0000000..db1f7ac --- /dev/null +++ b/FeishuCommandBridgeLong/requirements.txt @@ -0,0 +1 @@ +lark-oapi==1.5.3 diff --git a/HdhiveOpenApi/README.md b/HdhiveOpenApi/README.md new file mode 100644 index 0000000..fc59ab9 --- /dev/null +++ b/HdhiveOpenApi/README.md @@ -0,0 +1,217 @@ +# HdhiveOpenApi + +MoviePilot 影巢 OpenAPI 插件。 + +这个插件的目标很明确: + +把影巢的核心能力直接接进 MoviePilot,包括: + +- 用户信息查询 +- 每日签到 +- 资源搜索 +- 资源解锁 +- 115 自动转存 +- 分享管理 +- 用量与配额查询 + +--- + +## 当前版本重点 + +当前版本已经覆盖这些核心能力: + +1. 用户信息查询 +2. 每日签到 +3. 资源查询与解锁 +4. 分享管理 +5. 用量与配额 +6. 115 自动转存 + +其中“资源查询与解锁”这条链路是当前最重要的部分。 + +--- + +## 公开 Skill 模板 + +如果你想把这套能力交给 AI 智能体,仓库里已经提供了一份可以直接复用的公开 Skill 模板: + +- [skills/hdhive-search-unlock-to-115/README.md](../skills/hdhive-search-unlock-to-115/README.md) +- [skills/hdhive-search-unlock-to-115/SKILL.md](../skills/hdhive-search-unlock-to-115/SKILL.md) +- [skills/hdhive-search-unlock-to-115/PROMPTS.md](../skills/hdhive-search-unlock-to-115/PROMPTS.md) + +适合场景: + +- 让别的机器快速复现 +- 让别的智能体直接调用统一流程 +- 让搜索、确认、解锁、115 落地形成固定工作流 + +推荐搭配支持技能和工作流编排的智能体工作台使用,例如腾讯 WorkBuddy,或其它兼容 Skill 工作流的客户端。 + +--- + +## 资源搜索方式 + +这个插件支持两种搜索方式: + +### 1. 按 TMDB ID 搜索 + +适合已经知道 TMDB ID 的场景。 + +示例: + +```text +GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&tmdb_id=550 +``` + +### 2. 按关键词搜索 + +这是当前更推荐的方式。 + +插件会先借助 MoviePilot 的媒体搜索能力,把片名转换成 TMDB 候选,再去影巢查资源。 + +示例: + +```text +GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&keyword=超级马里奥兄弟大电影 +``` + +支持附加参数: + +- `year=2023` +- `candidate_limit=5` +- `limit=10` + +--- + +## 资源解锁 + +按 `slug` 解锁资源: + +```text +POST /api/v1/plugin/HdhiveOpenApi/resources/unlock +{ + "slug": "资源slug" +} +``` + +如果是 115 资源,还可以在解锁时直接要求自动转存: + +```text +POST /api/v1/plugin/HdhiveOpenApi/resources/unlock +{ + "slug": "资源slug", + "transfer_115": true, + "path": "/待整理" +} +``` + +--- + +## 115 自动转存 + +插件已经支持把解锁得到的 115 分享链接直接交给 `P115StrmHelper`。 + +默认思路是: + +- 解锁资源 +- 如果解锁结果是 115 链接 +- 自动转存到 `/待整理` + +所以这条链路现在可以变成: + +`搜索 -> 选择资源 -> 解锁 -> 自动落到 115 /待整理` + +前提: + +- `P115StrmHelper` 已安装 +- 115 已登录 +- `/待整理` 目录有效 + +--- + +## 非 Premium 账号说明 + +当前实测结论: + +- 非 Premium 账号也可以正常搜索资源 +- 部分接口是 Premium 限制的 + +常见情况: + +- `/account` 可能提示 Premium 限制 +- `/vip/weekly-free-quota` 可能提示 Premium 限制 +- 但 `resources/search` 依然可以使用 + +所以对大部分“搜资源 / 解锁资源”的实际需求来说,非 Premium 用户仍然有使用价值。 + +--- + +## 智能体最佳实践 + +如果你想把这套能力交给 AI 智能体,仓库里更适合写“解决问题的思路”,而不是绑定某个本地 Skill 或脚本实现。 + +推荐思路: + +`插件做能力,智能体做调度` + +也就是把流程拆成下面几步: + +1. 智能体接收用户输入的片名或 TMDB ID +2. 优先调用插件已经暴露的稳定接口,不直接拼影巢原始 API +3. 如果是片名搜索: + - 先让插件完成关键词到候选影片的解析 + - 如果候选存在歧义,再补充 1 到 2 个主演名帮助用户确认版本 +4. 向用户展示前 10 个资源候选 +5. 等用户按编号选择后,再执行解锁 +6. 如果结果是 115 资源,再继续自动转存到目标目录 +7. 如果资源需要积分,必须先征求用户确认,再继续解锁 + +这样做的好处是: + +- 更省 token +- 更稳定 +- 更容易复现 +- 更容易复用到别的机器和智能体环境 + +不推荐的做法: + +- 让智能体现场拼影巢原始 API +- 让智能体自己维护 `slug`、Cookie 或其它运行时状态 +- 为了区分同名影片而临时做网页登录、网页搜索或人工拼接流程 + +如果需要,你也可以直接从仓库里的公开模板开始: + +- [skills/hdhive-search-unlock-to-115/README.md](../skills/hdhive-search-unlock-to-115/README.md) + +--- + +## 已包含的插件目录 + +仓库里已经包含: + +```text +plugins/hdhiveopenapi/__init__.py +plugins.v2/hdhiveopenapi/__init__.py +icons/hdhive.ico +``` + +并且已在: + +```text +package.json +package.v2.json +``` + +中注册。 + +--- + +## 适合谁用 + +这个插件最适合下面这类用户: + +- 已经在用 MoviePilot +- 手里有影巢 Open API Key +- 想在 MoviePilot 内直接完成资源搜索与解锁 +- 想把 115 资源自动放进 `/待整理` +- 想给 AI 智能体一个稳定的影巢入口 diff --git a/QuarkShareSaver/README.md b/QuarkShareSaver/README.md new file mode 100644 index 0000000..1791c62 --- /dev/null +++ b/QuarkShareSaver/README.md @@ -0,0 +1,45 @@ +# QuarkShareSaver + +轻量夸克分享转存插件。 + +它只负责一件事: + +- 把夸克分享链接直接转存到你自己的夸克网盘目录 + +适合的调用方式: + +- 智能体调用插件 API +- 飞书桥接发送简短命令 + +推荐接口: + +- `GET /api/v1/plugin/QuarkShareSaver/health` +- `GET /api/v1/plugin/QuarkShareSaver/folders?path=/` +- `POST /api/v1/plugin/QuarkShareSaver/share/info` +- `POST /api/v1/plugin/QuarkShareSaver/transfer` + +`transfer` 请求体示例: + +```json +{ + "url": "https://pan.quark.cn/s/xxxxxxxx", + "access_code": "abcd", + "path": "/来自分享/夸克" +} +``` + +飞书推荐命令: + +```text +夸克转存 https://pan.quark.cn/s/xxxxxxxx pwd=abcd path=/最新动画 +``` + +配置重点: + +- `Cookie` 使用浏览器登录 `pan.quark.cn` 后复制完整 Cookie +- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克` + +这类轻插件更适合做“稳定执行层”: + +- 智能体负责理解意图和补参数 +- 插件负责真正转存 diff --git a/QuarkShareSaver/__init__.py b/QuarkShareSaver/__init__.py new file mode 100644 index 0000000..1369467 --- /dev/null +++ b/QuarkShareSaver/__init__.py @@ -0,0 +1,1113 @@ +import hmac +import json +import random +import re +import time +from datetime import datetime +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen +from fastapi import Request + +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.core.config import settings +except Exception: + settings = None + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None + + +class QuarkShareSaver(_PluginBase): + plugin_name = "夸克分享转存" + plugin_desc = "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/quark.ico" + plugin_version = "0.1.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "quarksharesaver_" + plugin_order = 32 + auth_level = 1 + + _enabled = False + _notify = True + _cookie = "" + _default_target_path = "/飞书" + _timeout = 30 + _auto_import_cookiecloud = True + _import_cookiecloud_once = False + + _share_url = "" + _access_code = "" + _target_path = "" + _transfer_once = False + + _last_transfer_key = "last_transfer" + _last_error_key = "last_error" + _path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "cookie": self._cookie, + "default_target_path": self._default_target_path, + "timeout": self._timeout, + "auto_import_cookiecloud": self._auto_import_cookiecloud, + "import_cookiecloud_once": self._import_cookiecloud_once, + "share_url": self._share_url, + "access_code": self._access_code, + "target_path": self._target_path, + "transfer_once": self._transfer_once, + } + if overrides: + config.update(overrides) + return config + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 读取状态失败 {key}: {exc}") + return default + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify or not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 发送通知失败: {exc}") + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _try_import_cookiecloud_cookie(self, *, force: bool = False) -> Tuple[bool, str]: + if self._cookie and not force: + return True, "已存在 Cookie,跳过自动导入" + cookie, message = self._load_cookiecloud_quark_cookie() + if not cookie: + logger.info(f"[QuarkShareSaver] CookieCloud 导入未命中: {message}") + return False, message + self._cookie = cookie + logger.info(f"[QuarkShareSaver] 已从 CookieCloud 导入夸克 Cookie,长度: {len(cookie)}") + return True, "已从 CookieCloud 导入夸克 Cookie" + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + @staticmethod + def _extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + def _extract_share_info(self, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = self._clean_text(share_text) + share_url = self._extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = self._clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = self._clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = self._clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def _is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + def _validate_share_url(self, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if self._is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self._cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookiecloud_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + final_url = url + if params: + query = urlencode([(key, "" if value is None else value) for key, value in params.items()]) + final_url = f"{url}?{query}" if query else url + payload = None + if json_body is not None: + payload = json.dumps(json_body).encode("utf-8") + try: + request = UrlRequest( + url=final_url, + data=payload, + headers=self._build_headers(), + method=method.upper(), + ) + with urlopen(request, timeout=self._timeout) as response: + status_code = getattr(response, "status", 200) + raw_body = response.read() + except HTTPError as exc: + status_code = exc.code + raw_body = exc.read() if hasattr(exc, "read") else b"" + except URLError as exc: + return False, {}, f"请求失败: {exc.reason}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = json.loads(raw_body.decode("utf-8")) + except Exception: + text = raw_body.decode("utf-8", errors="ignore")[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code == 401 and allow_cookiecloud_retry and self._auto_import_cookiecloud: + imported, _ = self._try_import_cookiecloud_cookie(force=True) + if imported: + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookiecloud_retry=False, + ) + + if status_code != 200: + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self._clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def _get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self._safe_int(meta.get("_total"), 0) + count = self._safe_int(meta.get("_count"), len(current)) + size = max(1, self._safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def _list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def _find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self._list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def _create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self._clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def _ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path or self._default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self._create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _resolve_existing_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + return False, "", f"目录不存在: {built}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self._clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def _wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self._safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self._clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def _check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self._list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + remember: bool = True, + trigger: str = "插件 API", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self._extract_share_info(share_text, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + + if not self._enabled: + return False, {}, "插件未启用" + if not self._cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + self._remember_error("get_stoken", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, share_items, message = self._get_share_items(pwd_id, stoken) + if not ok: + self._remember_error("get_share_items", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, target_fid, normalized_path = self._ensure_target_dir(target_path or self._default_target_path) + if not ok: + self._remember_error("ensure_target_dir", target_fid, {"path": target_path or self._default_target_path}) + return False, {}, target_fid + + ok, task_id, message = self._create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + self._remember_error("create_save_task", message, {"pwd_id": pwd_id, "path": normalized_path}) + return False, {}, message + + ok, task, message = self._wait_task(task_id) + if not ok: + self._remember_error("wait_task", message, {"task_id": task_id}) + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + if remember: + self._save_state(self._last_transfer_key, result) + self._notify_message( + "夸克分享转存完成", + ( + f"保存目录:{normalized_path}\n" + f"任务ID:{task_id}\n" + f"顶层条目:{len(share_items)}" + ), + ) + return True, result, "success" + + def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._cookie = self._clean_text(config.get("cookie")) + self._default_target_path = self._normalize_path(config.get("default_target_path") or "/飞书") + self._timeout = max(10, self._safe_int(config.get("timeout"), 30)) + self._auto_import_cookiecloud = bool(config.get("auto_import_cookiecloud", True)) + self._import_cookiecloud_once = bool(config.get("import_cookiecloud_once")) + + self._share_url = self._clean_text(config.get("share_url")) + self._access_code = self._clean_text(config.get("access_code")) + self._target_path = self._normalize_path(config.get("target_path") or self._default_target_path) + self._transfer_once = bool(config.get("transfer_once")) + self._path_cache = {"/": "0"} + + if self._import_cookiecloud_once or (self._auto_import_cookiecloud and not self._cookie): + imported_cookie, message = self._try_import_cookiecloud_cookie(force=self._import_cookiecloud_once) + if self._import_cookiecloud_once: + self._import_cookiecloud_once = False + self.update_config(self._build_config({"cookie": self._cookie, "import_cookiecloud_once": False})) + elif imported_cookie: + self.update_config(self._build_config({"cookie": self._cookie})) + if imported_cookie and self._notify: + self._notify_message("夸克 Cookie 已导入", message) + + if self._transfer_once: + self._transfer_once = False + self.update_config(self._build_config({"transfer_once": False})) + if self._enabled and self._share_url: + ok, _, message = self.transfer_share( + self._share_url, + access_code=self._access_code, + target_path=self._target_path, + remember=True, + trigger="插件页面立即转存", + ) + if not ok: + self._notify_message("夸克分享转存失败", message) + + def get_state(self) -> bool: + return self._enabled and bool(self._cookie) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查 Cookie 与默认目录状态"}, + {"path": "/folders", "endpoint": self.api_folders, "methods": ["GET"], "summary": "列出夸克网盘目录"}, + {"path": "/share/info", "endpoint": self.api_share_info, "methods": ["POST"], "summary": "解析夸克分享链接顶层条目"}, + {"path": "/transfer", "endpoint": self.api_transfer, "methods": ["POST"], "summary": "把夸克分享链接转存到指定目录"}, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "发送站内通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VTextField", "props": {"model": "timeout", "label": "请求超时(秒)", "type": "number"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "auto_import_cookiecloud", "label": "Cookie 为空时自动从 CookieCloud 导入"} + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "import_cookiecloud_once", "label": "立即从 CookieCloud 重新导入一次"} + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "浏览器登录 pan.quark.cn 后复制完整 Cookie", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "default_target_path", + "label": "默认保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "推荐给智能体或飞书调用的接口:\n" + "POST /api/v1/plugin/QuarkShareSaver/transfer\n" + "参数:url, access_code, path。\n" + "飞书建议命令:夸克转存 分享链接 pwd=提取码 path=/最新动画\n" + "如果你启用了本地 CookieCloud,插件可以自动导入 quark.cn Cookie。" + ), + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_once", "label": "立即转存一次"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "target_path", + "label": "本次保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_url", + "label": "夸克分享链接", + "placeholder": "https://pan.quark.cn/s/xxxx", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "access_code", + "label": "提取码(可留空)", + "placeholder": "abcd", + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def get_page(self) -> List[dict]: + last_transfer = self._load_state(self._last_transfer_key, default={}) or {} + last_error = self._load_state(self._last_error_key, default={}) or {} + + transfer_lines = [ + f"最近一次:{last_transfer.get('time') or '暂无'}", + f"保存目录:{last_transfer.get('target_path') or '-'}", + f"任务ID:{last_transfer.get('task_id') or '-'}", + f"顶层条目:{last_transfer.get('saved_count') or 0}", + ] + if last_transfer.get("items"): + transfer_lines.append("示例条目:" + ", ".join(last_transfer.get("items")[:5])) + + error_lines = [ + f"最近错误动作:{last_error.get('action') or '暂无'}", + f"错误时间:{last_error.get('time') or '-'}", + f"错误信息:{last_error.get('message') or '-'}", + ] + + return [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"variant": "tonal"}, + "content": [ + { + "component": "VCardText", + "text": ( + "夸克分享转存插件负责做一件事:把夸克分享链接稳定转存到自己的夸克网盘。" + "推荐让智能体和飞书只调用这一个稳定入口,不要自己拼夸克接口。" + ), + } + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近转存"}, + {"component": "VCardText", "text": "\n".join(transfer_lines)}, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近错误"}, + {"component": "VCardText", "text": "\n".join(error_lines)}, + ], + } + ], + }, + ], + } + ] + + def get_service(self) -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + pass + + async def api_health(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok = False + message = "" + if self._enabled and self._cookie: + ok, message = self._check_cookie() + return { + "success": ok if self._enabled and self._cookie else False, + "message": "success" if ok else (message or "插件未启用或未配置 Cookie"), + "data": { + "plugin_enabled": self._enabled, + "cookie_configured": bool(self._cookie), + "default_target_path": self._default_target_path, + "timeout": self._timeout, + }, + } + + async def api_folders(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + path = self._normalize_path(request.query_params.get("path") or "/") + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"path": path, "items": []}} + + ok, folder_id, normalized = self._resolve_existing_dir(path) + if not ok: + return {"success": False, "message": folder_id or "目录不存在", "data": {"path": path, "items": []}} + + ok, items, message = self._list_children(folder_id) + dirs = [ + {"fid": item.get("fid"), "name": item.get("name"), "path": f"{normalized.rstrip('/')}/{item.get('name')}".replace("//", "/")} + for item in items + if item.get("dir") + ] + return {"success": ok, "message": "success" if ok else message, "data": {"path": normalized, "items": dirs}} + + async def api_share_info(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + share_url = body.get("url") or body.get("share_url") or "" + access_code = body.get("access_code") or body.get("pwd") or "" + share_url, pwd_id, final_code = self._extract_share_info(share_url, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return {"success": False, "message": message, "data": {}} + if not pwd_id: + return {"success": False, "message": "未识别到有效夸克分享链接", "data": {}} + + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"pwd_id": pwd_id}} + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + return {"success": False, "message": message, "data": {"pwd_id": pwd_id}} + + ok, items, message = self._get_share_items(pwd_id, stoken) + return { + "success": ok, + "message": "success" if ok else message, + "data": { + "pwd_id": pwd_id, + "access_code": final_code, + "items": items[:20], + "count": len(items), + }, + } + + async def api_transfer(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok, result, message = self.transfer_share( + share_text=body.get("url") or body.get("share_url") or "", + access_code=body.get("access_code") or body.get("pwd") or "", + target_path=body.get("path") or body.get("target_path") or self._default_target_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": message, "data": result} diff --git a/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md b/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md new file mode 100644 index 0000000..e0bfda4 --- /dev/null +++ b/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md @@ -0,0 +1,192 @@ +# 外部智能体接入 Agent影视助手 + +当前插件版本:`Agent影视助手 0.2.68` + +当前 helper 版本:`agent-resource-officer 0.1.46` + +让 `OpenClaw`、`Hermes`、`WorkBuddy` 或其他外部智能体,也能稳定调用 MoviePilot 的搜片、转存、下载、签到和修复能力。 + +核心思路很简单:外部智能体负责理解你说的话、调用 `Agent影视助手`、展示结果;真正的资源搜索、转存、下载和账号操作,都交给 MoviePilot 里的插件执行。 + +--- + +## 一步接入 + +把下面这段直接发给你的外部智能体: + +```text +请从这个仓库创建并使用 agent-resource-officer Skill: +https://github.com/liuyuexi1987/MoviePilot-Plugins + +创建后请依次读取: +1. skills/agent-resource-officer/SKILL.md +2. skills/agent-resource-officer/EXTERNAL_AGENTS.md +3. docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md + +连接配置: +ARO_BASE_URL=http://MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN + +如果你的客户端支持 MoviePilot 官方 MCP,也请同时接入: +MCP 地址:http://MoviePilot地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN + +分工规则: +1. 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询,可以优先用 MCP。 +2. 云盘搜索、盘搜、影巢、转存、夸克转存、115转存、下载、更新检查、编号选择、翻页、详情、Cookie 修复,继续优先用 agent-resource-officer skill / helper。 +3. 只有当前会话真的加载出 mcp__moviepilot__* 工具,才算 MCP 已接通;没接通时不要假装在用 MCP。 + +请把配置写入 ~/.config/agent-resource-officer/config。 +然后运行 readiness 验证连接,成功后按文档规则接入。 +``` + +`ARO_API_KEY` 在 MoviePilot 管理后台的系统设置 / 安全设置里找。 + +--- + +## 连接地址怎么填 + +先判断 MoviePilot 和智能体是不是在同一台机器。 + +### 同机部署 + +如果 MoviePilot 和智能体在同一台电脑或同一个容器网络里,可以这样填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +这也是最简单的情况。 + +### 跨机器部署 + +如果 MoviePilot 在 NAS,智能体在 Win / Mac 电脑上,`ARO_BASE_URL` 必须填 NAS 的实际地址: + +```bash +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +这里的 `127.0.0.1` 只代表智能体自己这台机器,不是 NAS。 + +如果你有多套 MoviePilot,要特别注意: + +- `ARO_BASE_URL` 指向哪套 MoviePilot,`下载 / MP搜索 / PT搜索 / 转存` 就使用哪套 MoviePilot。 +- 如果当前 MoviePilot 只用于网盘或 STRM,不要在这套实例里确认 PT 下载。 +- 如果 MoviePilot 和 qBittorrent 不在一台机器,可在 Agent影视助手设置里填写 `PT 下载保存路径`,路径要按目标 NAS / qB 的真实下载目录填写。 + +跨机器部署详细说明见 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)。 + +--- + +## 手动添加 MCP + +有些智能体不会自动读取或启用 MoviePilot MCP,需要你在智能体的 MCP 设置里手动添加。 + +填写: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS,地址要写 NAS 的实际地址: + +```text +MCP 地址:http://你的NAS地址:3000/api/v1/mcp +``` + +添加后,需要在智能体里确认 MCP 已启用,并且当前会话能看到类似 `mcp__moviepilot__*` 的工具。 + +如果看不到这些工具,就说明 MCP 没有真正加载成功。此时不要让智能体假装在用 MCP,资源流继续走 `agent-resource-officer skill / helper`。 + +--- + +## 怎么用 + +接入完成后,直接对智能体说: + +| 命令 | 作用 | +|---|---| +| `搜索 蜘蛛侠` | 搜索云盘资源,默认走盘搜 | +| `云盘搜索 蜘蛛侠` | 盘搜 + 影巢一起搜 | +| `MP搜索 蜘蛛侠` / `PT搜索 蜘蛛侠` | 走 MoviePilot 原生 PT 搜索 | +| `转存 蜘蛛侠` | 默认等同 `115转存 蜘蛛侠` | +| `115转存 蜘蛛侠` | 搜索后转存到 115 | +| `夸克转存 蜘蛛侠` | 搜索后转存到夸克 | +| `下载 蜘蛛侠` | 搜索并生成 PT 下载计划 | +| `更新检查 蜘蛛侠` | 检查是否有新资源 | +| `115登录` | 扫码登录 115 | +| `影巢签到` | 执行影巢签到 | + +完整命令列表见:`docs/ALL_COMMANDS.md`。 + +--- + +## MCP 要不要接 + +MoviePilot 官方 MCP 可以接,但它和 `agent-resource-officer skill / helper` 的定位不同。 + +推荐这样分工: + +| 场景 | 推荐入口 | +|---|---| +| 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询 | 官方 MCP | +| 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情、Cookie 修复 | `agent-resource-officer skill / helper` | +| `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流 | 优先 `agent-resource-officer skill / helper` | + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +``` + +认证头: + +```text +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +注意:只有当前智能体客户端真的加载出了 `mcp__moviepilot__*` 工具,才算 MCP 已接通。没有接通时,不要让智能体假装在用 MCP;资源流继续走 `agent-resource-officer`。 + +--- + +## 给智能体看的执行规则 + +这部分规则已经写在 `agent-resource-officer` Skill 里,普通用户不用背。 + +接入时只要让外部智能体读取本仓库里的 Skill,它就会知道哪些命令必须走 `route / pick`、哪些动作需要确认、哪些结果不能重排编号。 + +--- + +## 长线程维护 + +微信、飞书、WorkBuddy、Claw 这类长线程用久后,可能会出现: + +- `15详情` 被误解成 `选择 15` +- 编号续接到旧搜索结果 +- 一直套用旧格式或旧规则 + +这时直接对智能体说: + +```text +校准影视技能 +``` + +这条命令会让智能体重新加载影视助手的关键规则。不要在普通 `搜索 / 更新检查 / 检查` 前主动清会话,否则会破坏正常编号续接。 + +--- + +## 相关文档 + +- 全部命令一览:`docs/ALL_COMMANDS.md` +- [跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) +- 外部智能体详细规范:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` diff --git a/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md b/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md new file mode 100644 index 0000000..2bbb3d1 --- /dev/null +++ b/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md @@ -0,0 +1,181 @@ +# Agent影视助手跨机器部署 + +当前插件版本:`Agent影视助手 0.2.68` + +当前 helper 版本:`agent-resource-officer 0.1.46` + +这份文档只讲一种常见情况: + +```text +MoviePilot 在 NAS / Docker / 远程主机 +外部智能体在 Win / Mac 电脑 +``` + +这属于正常用法,不是特殊模式。关键只有一个:智能体要能访问到 MoviePilot。 + +--- + +## 先填对 ARO_BASE_URL + +外部智能体所在电脑的配置文件一般是: + +```text +~/.config/agent-resource-officer/config +``` + +如果 MoviePilot 在 NAS,配置应类似: + +```text +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要写: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +因为这里的 `127.0.0.1` 代表智能体自己这台电脑,不是 NAS。 + +只有 MoviePilot 和智能体在同一台机器时,才用: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +--- + +## 多套 MoviePilot 时要注意 + +`ARO_BASE_URL` 指向哪套 MoviePilot,下面这些命令就使用哪套 MoviePilot: + +```text +MP搜索 +PT搜索 +下载 +订阅 +转存 +更新检查 +``` + +如果你有一套 MoviePilot 只用于网盘 / STRM,不要在这套实例里确认 PT 下载。 + +如果你真正下载用的是 NAS 上另一套 MoviePilot,就把 `ARO_BASE_URL` 指向那一套。 + +--- + +## MP 和 qB 不同机时 + +如果 MoviePilot 和 qBittorrent 不在一台机器,可以在 `Agent影视助手` 设置页填写: + +```text +PT 下载保存路径 +``` + +简单理解: + +- MoviePilot 和 qB 在同一台机器:通常不用填。 +- MoviePilot 和 qB 不在一台机器:填 qB 能识别的真实下载目录。 + +示例: + +```text +/downloads +/volume1/downloads +local:/downloads +``` + +不要填你当前电脑上的临时路径,除非 qB 也真的在这台电脑上。 + +--- + +## 盘搜 API 地址按 MoviePilot 视角填 + +这里容易混: + +- `ARO_BASE_URL` 是外部智能体访问 MoviePilot 的地址。 +- `盘搜 API 地址` 是 MoviePilot 插件访问 PanSou 的地址。 + +如果 PanSou 和 MoviePilot 在同一台 NAS / Docker 网络里,`盘搜 API 地址` 要填 MoviePilot 那边能访问到的地址,不一定是你电脑能访问到的地址。 + +--- + +## Cookie 修复读的是哪台电脑 + +这些命令会用到浏览器 Cookie: + +```text +刷新影巢Cookie +修复影巢签到 +刷新夸克Cookie +修复夸克转存 +``` + +跨机器时,它们读取的是**智能体所在电脑**的浏览器登录态,然后写回 NAS 上的 MoviePilot。 + +所以如果 MoviePilot 在 NAS、智能体在 Mac: + +1. 在 Mac 浏览器里登录 `https://hdhive.com` 或 `https://pan.quark.cn`。 +2. 再让智能体执行修复命令。 +3. 不需要去 NAS 桌面上找浏览器 Cookie。 + +--- + +## 最小验证 + +在智能体所在机器执行: + +```bash +python3 scripts/aro_request.py readiness +``` + +如果通过,说明智能体已经能访问 MoviePilot 插件。 + +再试一个只读命令: + +```bash +python3 scripts/aro_request.py route "115状态" +``` + +如果也能返回,跨机器主链基本就通了。 + +--- + +## 常见错误 + +### 1. NAS 环境还写 127.0.0.1 + +表现:智能体连接失败、请求打到自己电脑。 + +解决:把 `ARO_BASE_URL` 改成 NAS 的局域网 IP 或域名。 + +### 2. 改了仓库文件,但 MoviePilot 还在跑旧插件 + +仓库里的文件改完后,不等于容器里的插件已经更新。 + +如果页面或接口还是旧表现,先确认 MoviePilot 实际加载的是最新插件。 + +### 3. 长线程被旧上下文污染 + +表现: + +- `15详情` 被当成 `选择 15` +- 编号接到旧搜索结果 +- 明明更新了规则,智能体还是按旧说法执行 + +直接对智能体说: + +```text +校准影视技能 +``` + +不要在普通搜索前固定清会话,否则会破坏正常编号续接。 + +--- + +## 推荐阅读 + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- 全部命令:`docs/ALL_COMMANDS.md` +- [插件安装说明](./PLUGIN_INSTALL.md) diff --git a/docs/ALL_COMMANDS.md b/docs/ALL_COMMANDS.md new file mode 100644 index 0000000..859afb2 --- /dev/null +++ b/docs/ALL_COMMANDS.md @@ -0,0 +1,288 @@ +# Agent影视助手命令总览 + +这份文档只做一件事:把当前主线命令讲清楚。 + +适用范围: + +- MoviePilot 插件 API +- Agent影视助手内置飞书入口 +- 外部智能体通过 `agent-resource-officer` skill / helper 调用 + +不同入口会有少量别名差异,但主命令尽量保持一致。 + +--- + +## 先记住 4 条 + +1. `搜索 <片名>` 默认走盘搜,不是 PT 搜索。 +2. `转存 <片名>` 默认等同 `115转存 <片名>`。 +3. `下载 <片名>` 只走 MoviePilot 原生 PT 下载链,不会改成云盘转存。 +4. `下载1` 是生成下载计划,不会立刻真实下载;确认后才执行。 + +--- + +## 搜索类 + +| 命令 | 作用 | +|---|---| +| `MP搜索 <片名>` | 走 MoviePilot 原生 PT 搜索 | +| `PT搜索 <片名>` | 同 `MP搜索` | +| `原生搜索 <片名>` | 同 `MP搜索` | +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢一起搜 | +| `下载 <片名>` | 先识别媒体,再走 MoviePilot 原生 PT 搜索和下载计划链 | +| `更新检查 <片名>` | 检查当前媒体是否有更新资源 | +| `检查 <片名>` | 同 `更新检查` | + +补充说明: + +- `MP搜索` / `PT搜索` 遇到片名歧义时,通常会先让你选正确影片或剧集。 +- `下载 <片名>` 如果片名有歧义,也会先让你选影片,再继续 PT 下载链。 + +--- + +## 选择、详情、翻页 + +| 命令 | 作用 | +|---|---| +| `1` | 继续处理当前第 1 条结果 | +| `1详情` | 查看当前第 1 条详情 | +| `选择 1` | 显式选择第 1 条 | +| `选择 1 详情` | 显式查看第 1 条详情 | +| `下载1` | 对当前第 1 条 PT 结果生成下载计划 | +| `n` | 下一页 | +| `下一页` | 同 `n` | + +这里最容易混淆的是 `1`: + +- 在 PT 结果里,`1` 通常是继续第 1 条,并生成或确认下载计划 +- 在云盘结果里,`1` 通常是继续第 1 条,并执行对应选择/转存逻辑 + +如果你只想看详情,用 `1详情` 最稳。 + +--- + +## 转存类 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `转存资源 <片名>` | 同 `转存 <片名>` | + +补充说明: + +- 现在主线规则是:`转存` 默认就是 `115转存` +- 只有你明确说 `夸克转存`,才会走夸克 + +--- + +## 直接处理分享链接 + +直接发送分享链接即可,无需额外前缀: + +| 输入 | 效果 | +|---|---| +| `https://115.com/s/xxxxx` | 自动走 115 转存 | +| `https://pan.quark.cn/s/xxxxx` | 自动走夸克转存 | + +如有提取码,也可以一起发。 + +--- + +## PT 下载类 + +| 命令 | 作用 | +|---|---| +| `下载 <片名>` | 先找片,再列出 PT 资源或直接生成计划 | +| `下载1` | 给当前第 1 条 PT 结果生成下载计划 | +| `执行计划` | 执行当前待确认计划 | +| `确认计划` | 同 `执行计划` | +| `计划列表` | 查看当前待确认或已保存计划 | +| `取消计划` | 清理当前计划 | + +PT 下载主线是: + +```text +下载 流浪地球2 +下载1 +执行计划 +``` + +默认先计划,再确认执行。 + +--- + +## 订阅类 + +| 命令 | 作用 | +|---|---| +| `订阅 <片名>` | 创建订阅 | +| `订阅并搜索 <片名>` | 创建订阅并立刻跑一次搜索 | +| `订阅列表` | 查看订阅列表 | +| `搜索订阅 <编号>` | 手动刷新某条订阅 | +| `暂停订阅 <编号>` | 暂停订阅 | +| `恢复订阅 <编号>` | 恢复订阅 | +| `删除订阅 <编号>` | 删除订阅 | + +--- + +## 115 相关 + +| 命令 | 作用 | +|---|---| +| `115登录` | 生成 115 扫码登录 | +| `检查115登录` | 检查 115 扫码是否成功 | +| `115状态` | 查看当前 115 登录状态 | +| `115任务` | 查看待继续的 115 任务 | +| `继续115任务` | 继续上次 115 任务 | +| `取消115任务` | 取消待处理 115 任务 | +| `清空115转存目录` | 清空 115 默认转存目录 | + +--- + +## 夸克相关 + +| 命令 | 作用 | +|---|---| +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `清空夸克转存目录` | 清空夸克默认转存目录 | +| `刷新夸克Cookie` | 从本机浏览器重新导入夸克 Cookie | +| `修复夸克转存` | 走夸克转存修复链 | + +--- + +## 影巢相关 + +| 命令 | 作用 | +|---|---| +| `影巢搜索 <片名>` | 搜影巢资源 | +| `影巢签到` | 执行影巢签到 | +| `影巢签到日志` | 查看影巢签到日志 | +| `刷新影巢Cookie` | 从本机浏览器重新导入影巢 Cookie | +| `修复影巢签到` | 走影巢签到修复链 | + +--- + +## 下载器 / 站点 / 状态 + +| 命令 | 作用 | +|---|---| +| `下载任务` | 查看当前下载任务 | +| `下载历史` | 查看下载历史 | +| `入库历史` | 查看最近入库/整理历史 | +| `最近入库` | 查看最近入库活动 | +| `站点` | 查看 PT 站点状态 | +| `下载器` | 查看下载器状态 | +| `MP识别 <片名>` | 查看 MoviePilot 对该媒体的识别结果 | +| `追踪 <片名>` | 看某个媒体从搜索到下载到入库的状态 | +| `最近` | 看最近下载和入库动态 | +| `诊断 <片名>` | 诊断为什么没有入库 | + +--- + +## 推荐 / 发现 + +这组能力代码已接入,但当前不属于这轮重点实测主线。 +更适合先在测试环境、短线程或本地会话里验证后再长期使用。 + +| 命令 | 作用 | +|---|---| +| `推荐` | 热门推荐 | +| `热门电影` | 热门电影推荐 | +| `热门电视剧` | 热门剧集推荐 | +| `豆瓣热门` | 豆瓣热门榜单 | +| `正在热映` | 当前热映 | +| `今日番剧` | 今日番剧放送 | + +--- + +## 智能决策类 + +这组命令代码已接入,但状态切换和分支很多,目前不建议新手直接把它当成熟主线使用。 +更适合外部智能体或熟悉工作流后,在测试环境先跑通。 + +| 命令 | 作用 | +|---|---| +| `资源决策 <片名>` | 综合搜索并给出当前最佳方案 | +| `智能决策 <片名>` | 同 `资源决策` | +| `确认执行` | 执行当前推荐动作 | +| `先看详情` | 看当前推荐详情 | +| `先做计划` | 为当前推荐先生成计划 | +| `换影巢` | 切到影巢结果 | +| `换盘搜` | 切到盘搜结果 | +| `换PT` | 切到 MP/PT 结果 | +| `保守一点` | 切到更保守策略 | +| `激进一点` | 切到更激进策略 | +| `只用夸克` | 只保留夸克云盘 | +| `只用115` | 只保留 115 云盘 | +| `只走PT` | 只保留 MP/PT | +| `继续决策` | 延续当前决策流程 | +| `跟进` | 查看当前媒体后续进展 | + +--- + +## 偏好设置 + +这组功能代码已接入,但这轮几乎没有做真实使用测试。 +如果你只是第一次上手,可以先跳过;等主线命令稳定后再启用。 + +| 命令 | 作用 | +|---|---| +| `查看偏好` | 查看当前片源偏好 | +| `保存偏好 <内容>` | 更新片源偏好 | +| `重置偏好` | 恢复默认偏好 | +| `评分策略` | 查看评分和自动化规则 | + +--- + +## AI识别增强 + +这一组需要启用 `AI识别增强` 插件。 + +| 命令 | 作用 | +|---|---| +| `失败样本` | 查看失败样本 | +| `工作清单` | 查看待处理识别样本 | +| `样本洞察` | 看失败样本统计 | +| `重放样本 <编号>` | 重跑某个失败样本 | +| `重跑样本 <编号> 保留样本` | 重跑但不自动移除样本 | + +--- + +## 外部智能体维护 + +这一组主要给 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体用。 + +| 命令 | 作用 | +|---|---| +| `校准影视技能` | 让外部智能体重新读取当前 Skill 硬规则 | +| `帮助` | 查看帮助 | +| `版本` | 查看插件版本 | + +如果长线程用久了,出现: + +- `15详情` 被误判成 `选择 15` +- 编号串到旧搜索结果 +- 一直套用旧格式 + +优先先清 session,再重新读取 skill。详细见: + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) + +--- + +## 飞书入口的额外说明 + +飞书入口除了上面这些主命令外,还有一些历史兼容别名。 + +新手建议: + +1. 直接使用完整命令,不要依赖缩写。 +2. 尤其优先用 `MP搜索`、`盘搜搜索`、`影巢搜索`、`云盘搜索`、`转存`、`115转存`、`夸克转存`、`下载` 这些主命令。 + +如果你只想最稳地用飞书,就把它当成一个命令聊天窗口,不要自己发明新句式。 diff --git a/docs/GITHUB_PUBLISH.md b/docs/GITHUB_PUBLISH.md new file mode 100644 index 0000000..092564e --- /dev/null +++ b/docs/GITHUB_PUBLISH.md @@ -0,0 +1,63 @@ +# GitHub 发布说明 + +## 推荐仓库名 + +```text +MoviePilot-Plugins +``` + +## 推荐描述 + +```text +Personal MoviePilot plugin suite for agent-driven resource workflows, AI recognition fallback, Feishu control, HDHive, Quark and media refresh helpers +``` + +## 发布建议 + +- 开始发版或仓库维护前,先执行一次: + - `bash scripts/repo-hygiene.sh` +- 如果想一条命令跑完整发版前检查,优先执行: + - `bash scripts/release-preflight.sh` +- README 首页保持中文 +- GitHub 仓库描述使用简短英文 +- 当前对外文档优先以 `docs/INDEX.md` 为导航;不要把历史规划文档当成当前说明 +- 如果只想快速查维护/发布命令,不想先读长文,直接看: + - `docs/MAINTENANCE_COMMANDS.md` +- 如果只想单独跑底层发布检查,再执行: + - `bash scripts/pre-release-check.sh` +- 如果只想先验证“当前状态”文档有没有版本漂移,可以单独执行: + - `python3 scripts/check-doc-current-state.py` +- Release 附件可上传 `dist/` 下生成的插件 ZIP,以及 `dist/skills/` 下生成的公开 Skill ZIP;校验文件在 Release 附件中使用 `PLUGIN_` / `SKILL_` 前缀避免重名 +- `Release Preflight` workflow 通过后会把插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json` 上传为 Actions artifact,可直接下载核对或作为 Release 附件来源 +- 可以用 `bash scripts/create-draft-release.sh --dry-run` 预览 Release 附件和说明,再去掉 `--dry-run` 创建 Draft Release +- 也可以手动运行 GitHub Actions -> Draft Release;默认 `dry_run=true`,并会上传 release asset artifact 供核对 +- Draft Release 核对无误后,用 `gh release edit --draft=false --latest --target main` 发布正式 Release +- 正式发布后执行 `bash scripts/verify-release-download.sh `,确认公开附件可下载且校验通过 +- GitHub Actions 已支持手动运行,可在 Actions -> Release Preflight -> Run workflow 主动触发一次完整发布检查 +- 具体发版步骤见:[RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) + +## 当前对外文档 + +真正对用户和外部智能体公开的主文档,发布前至少确认这几份没有落后于代码: + +- `README.md` +- `docs/INDEX.md` +- `docs/PLUGIN_INSTALL.md` +- `docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md` +- `docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md` + +## 当前 ZIP 覆盖 + +`release-preflight.sh` 的完整检查阶段会生成当前清单里的 8 个本地安装包: + +- `AIRecognizerEnhancer` +- `AgentResourceOfficer` +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +## 历史说明 + +早期 `v2.0.0-alpha.1` 是旧 AI Gateway 拆分阶段的首发说明,已移到历史文档: + +- [RELEASE_v2.0.0-alpha.1.md](./RELEASE_v2.0.0-alpha.1.md) diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..0fbd184 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,55 @@ +# 文档索引 + +这份索引只做一件事:让你按目标快速落到当前有效文档。历史文档只保留,不作为当前操作手册。 + +## 我现在要装和用 + +1. [README.md](../README.md) +2. [ALL_COMMANDS.md](./ALL_COMMANDS.md) +3. [PLUGIN_INSTALL.md](./PLUGIN_INSTALL.md) +4. [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +5. 如果 `MoviePilot` 不在当前机器,再看 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +## 我现在要接外部智能体 + +1. [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +2. 如果跨机器,再看 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +3. 智能体安装 Skill 时会读取 [skills/agent-resource-officer/SKILL.md](../skills/agent-resource-officer/SKILL.md),普通用户一般不用手读。 + +## 我现在要打包和发布 + +1. [PACKAGING.md](./PACKAGING.md) +2. [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +3. [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) + +## 我现在要做仓库维护 + +1. 先跑: + `bash scripts/repo-hygiene.sh` +2. 如果准备发版,再跑: + `bash scripts/release-preflight.sh` +3. 再看: + [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +4. 如需命令速查: + [MAINTENANCE_COMMANDS.md](./MAINTENANCE_COMMANDS.md) +5. 如需当前发版口径: + [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) + +## 当前有效文档清单 + +- [ALL_COMMANDS.md](./ALL_COMMANDS.md) +- [PLUGIN_INSTALL.md](./PLUGIN_INSTALL.md) +- [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [PACKAGING.md](./PACKAGING.md) +- [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +- [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) +- [MAINTENANCE_COMMANDS.md](./MAINTENANCE_COMMANDS.md) + +## 历史归档文档 + +- [REBUILD_AGENT_SUITE.md](./REBUILD_AGENT_SUITE.md) + 早期重构规划记录,只用于回看设计演进 + +- [RELEASE_v2.0.0-alpha.1.md](./RELEASE_v2.0.0-alpha.1.md) + 旧 AI Gateway 阶段的历史发布草稿,不作为当前发布说明 diff --git a/docs/MAINTENANCE_COMMANDS.md b/docs/MAINTENANCE_COMMANDS.md new file mode 100644 index 0000000..93602d9 --- /dev/null +++ b/docs/MAINTENANCE_COMMANDS.md @@ -0,0 +1,193 @@ +# 仓库维护命令索引 + +这份文档只列当前常用的仓库维护与发布命令,不解释历史方案。 + +## 当前状态 + +- 当前插件版本:`AgentResourceOfficer 0.2.68` +- 当前 Skill helper 版本:`0.1.46` +- 当前 Release: + +## 最常用入口 + +- 仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 发版前完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +- 低层发布检查: + +```bash +bash scripts/pre-release-check.sh +``` + +## 推荐顺序 + +- 日常看状态或准备整理仓库: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 想清理本地生成文件或顺手删除 `dist/`: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +- 准备发版、打包、更新 Draft Release 之前: + +```bash +bash scripts/release-preflight.sh +``` + +- 准备在 GitHub 上创建或更新 Draft Release: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 想确认最近一次 GitHub Actions 产物是否完整: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +## 状态与审计 + +- 检查当前状态文档是否和代码版本一致: + +```bash +python3 scripts/check-doc-current-state.py +``` + +- 审计远端和本地历史分支: + +```bash +python3 scripts/audit-remote-branches.py +``` + +- 归档本地非 `main` 分支到 `archive/*` tag: + +```bash +python3 scripts/archive-local-branches.py +python3 scripts/archive-local-branches.py --apply +``` + +## 打包与发布 + +- 创建 Draft Release 前 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 创建 Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +- 用当前 `dist/` 覆盖已有 Draft Release 附件: + +```bash +bash scripts/update-draft-release-assets.sh +``` + +- 校验公开 Release 下载附件: + +```bash +bash scripts/verify-release-download.sh +``` + +- 单独打包公开 Skill ZIP: + +```bash +bash scripts/package-skills.sh +``` + +## Artifact 与产物校验 + +- 下载并校验最近一次成功的 `Release Preflight` workflow artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +bash scripts/verify-release-preflight-artifact.sh +``` + +- 校验本地 release 资产目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +- 校验插件 ZIP: + +```bash +DIST_DIR=dist bash scripts/verify-dist.sh +``` + +- 校验 Skill ZIP: + +```bash +DIST_DIR=dist/skills bash scripts/verify-skill-dist.sh +``` + +## 汇总输出 + +- 打印插件 ZIP Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +- 打印 Skill ZIP Markdown 表格: + +```bash +bash scripts/print-skill-release-summary.sh +``` + +- 生成 Release notes: + +```bash +bash scripts/generate-release-notes.sh +``` + +## 帮助 + +这些脚本现在都支持 `--help` 或 `-h`,包括: + +- `repo-hygiene.sh` +- `release-preflight.sh` +- `pre-release-check.sh` +- `check-skills.sh` +- `clean-generated.sh` +- `package-plugin.sh` +- `package-skills.sh` +- `sync-repo-layout.sh` +- `sync-package-v2.sh` +- `create-draft-release.sh` +- `update-draft-release-assets.sh` +- `generate-release-notes.sh` +- `write-dist-sha256.sh` +- `patch-p115strmhelper-mp-compat.sh` +- `verify-release-preflight-artifact.sh` +- `verify-ci-artifact.sh` +- `verify-release-download.sh` +- `verify-release-assets.sh` +- `verify-dist.sh` +- `verify-skill-dist.sh` +- `print-release-summary.sh` +- `print-skill-release-summary.sh` +- `check-doc-current-state.py` +- `audit-remote-branches.py` +- `archive-local-branches.py` diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..fa45285 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,227 @@ +# 插件和 Skill ZIP 打包说明 + +开始打包或发布前,先执行一次仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +如果只想快速查维护/发布命令,不想先读完整文档,直接看: + +- `docs/MAINTENANCE_COMMANDS.md` + +如果要直接跑完整发版前检查,执行: + +```bash +bash scripts/release-preflight.sh +``` + +## 目标 + +用于生成可在 MoviePilot 本地上传安装的插件 ZIP 包,以及可复制到外部智能体环境的公开 Skill ZIP 包。 + +打包内容会保留以下标准结构: + +```text +/ + __init__.py + README.md + requirements.txt +``` + +## 一键打包 + +在仓库根目录执行: + +```bash +bash scripts/package-plugin.sh +bash scripts/package-plugin.sh --help +``` + +默认打包 `AIRecognizerEnhancer`。 + +查看当前可打包插件: + +```bash +bash scripts/package-plugin.sh --list +``` + +只打包全部插件、不运行完整发布检查: + +```bash +bash scripts/package-plugin.sh --all +``` + +`--all` 和 `pre-release-check.sh` 会在打包前清理 `dist/*.zip`、`SHA256SUMS.txt` 和 `MANIFEST.json`;完整发布检查还会清理并重建 `dist/skills/`,避免旧版本产物混在发布附件里。 + +`--all` 会在打包后自动生成 `SHA256SUMS.txt`、`MANIFEST.json` 并执行 `scripts/verify-dist.sh`。 + +如需打包其他插件,例如 `AgentResourceOfficer` 或飞书桥接插件: + +```bash +bash scripts/package-plugin.sh AgentResourceOfficer +bash scripts/package-plugin.sh FeishuCommandBridgeLong +``` + +脚本会自动先同步一次官方仓库布局,再生成 ZIP。 + +同步脚本会根据 `package.json` 自动发现根目录中带 `__init__.py` 的源码插件,并同步到 `plugins/` 和 `plugins.v2/`。 + +插件名会优先按 `package.json` 做大小写不敏感匹配。例如 `hdhiveopenapi` 会被规范为 `HdhiveOpenApi`,生成的 ZIP 根目录也会保持标准插件 ID。 + +如果插件代码目录来自 `plugins/` 或 `plugins.v2/`,但说明文档保留在仓库顶层同名目录下,打包脚本会自动把顶层 `README.md` 补进 ZIP。 + +发布前完整检查会一次打包当前仓库清单里的可本地安装插件: + +```bash +bash scripts/pre-release-check.sh +``` + +如果改了 `package.json`,可以先同步派生清单: + +```bash +bash scripts/sync-package-v2.sh +``` + +`pre-release-check.sh` 也会自动运行这个同步脚本;如果 `package.v2.json` 因此发生变化,工作区检查会失败并提示先提交。 + +完整检查会在 `dist/` 下额外生成 `SHA256SUMS.txt` 和 `MANIFEST.json`,用于核对每个 ZIP 的 SHA256,并给自动化脚本读取插件 ID、展示名、版本、文件名和大小。 + +完整检查还会在 `dist/skills/` 下生成公开 Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`: + +```bash +bash scripts/package-skills.sh +bash scripts/verify-skill-dist.sh +``` + +如需只刷新当前 `dist/*.zip` 的校验清单和机器可读 manifest: + +```bash +bash scripts/write-dist-sha256.sh +``` + +如果只想校验已经生成或从 `Release Preflight` artifact 下载下来的完整发布产物目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-dist.sh +bash scripts/verify-skill-dist.sh +``` + +也可以校验其他目录: + +```bash +bash scripts/verify-release-assets.sh /path/to/downloaded-artifact +DIST_DIR=/path/to/downloaded-artifact bash scripts/verify-dist.sh +DIST_DIR=/path/to/downloaded-artifact/skills bash scripts/verify-skill-dist.sh +``` + +如果要生成可复制到 GitHub Release 的 Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +如果本地运行测试后产生了 `__pycache__`、`.pyc` 或 `.DS_Store`,可以清理生成物: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +如果要下载并校验最近一次成功 `Release Preflight` artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +如果要创建 GitHub Draft Release,先 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +也可以走 GitHub Actions 手动 dry-run: + +```bash +gh workflow run draft-release.yml -f tag= -f dry_run=true +``` + +当前完整检查覆盖: + +- `AIRecognizerEnhancer` +- `AgentResourceOfficer` +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +完整检查还会校验: + +- 仓库内发布脚本和 Skill shell helper 必须能通过 shell 语法检查 +- 插件代码和仓库内 Skill helper 脚本必须能通过 Python 语法检查 +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill helper 的本地 `selftest` 必须通过 +- `AgentResourceOfficer` Skill 的 `external-agent` 入口必须能输出 `external_agent.v1`、3 个最小工具和有效 `EXTERNAL_AGENTS.md`;`workbuddy` 仅作为兼容别名保留,并已标记为 deprecated。 +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill helper 版本必须同步到 README 和 CHANGELOG +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill 安装脚本的 `--dry-run` 必须通过 +- 如果设置 `RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE=1`,完整检查还会执行 `scripts/smoke-agent-resource-officer.py --include-search`,对本机 MoviePilot 做真实只读 smoke +- 以上 Skill 检查可以单独运行 `bash scripts/check-skills.sh` +- 发布脚本中的插件清单必须和 `package.json` 一致 +- `package-plugin.sh --list` 输出必须和发布插件清单一致 +- `package.json` 插件市场展示字段和图标文件必须存在 +- `package.json` 中 `version`、`labels`、`level`、`history` 等字段类型必须符合预期 +- `package.json` 中每个插件必须标记 `v2: true` +- `package.json` 当前版本必须出现在对应插件的 `history` 中 +- `package.json` 中每个插件都必须能在根目录、`plugins/` 或 `plugins.v2/` 找到 `__init__.py` +- 仓库首页 `README.md` 必须列出 `package.json` 中每个插件的 ID、展示名和当前版本 +- `docs/PLUGIN_INSTALL.md` 必须列出当前版本对应的 ZIP 文件名 +- `dist/SHA256SUMS.txt` 必须随 ZIP 一起生成 +- `dist/MANIFEST.json` 必须随 ZIP 一起生成 +- `dist/skills/` 必须生成公开 Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json` +- `scripts/verify-dist.sh` 必须能验证 ZIP SHA256、MANIFEST、插件元数据、基础目录结构和不应发布的生成文件 +- `scripts/verify-skill-dist.sh` 必须能验证 Skill ZIP SHA256、MANIFEST、基础目录结构、不应发布的生成文件,以及 `agent-resource-officer` ZIP 解压后的 `external-agent` 入口 +- `scripts/verify-release-assets.sh` 必须能一次校验插件 ZIP 和 Skill ZIP +- `scripts/verify-release-preflight-artifact.sh` 必须能下载并校验 GitHub Actions artifact +- `scripts/print-release-summary.sh` 必须能基于 `MANIFEST.json` 输出 Release Markdown 表格 +- `scripts/generate-release-notes.sh` 必须能生成统一 Release 正文,并包含 `external-agent / external-agent --full` 重点 +- `.github/workflows/ci.yml` 和 `draft-release.yml` 必须使用 artifact 上传步骤,并包含插件 ZIP、Skill ZIP、`SHA256SUMS.txt`、`MANIFEST.json` +- `draft-release.yml` 必须保留手动触发、`dry_run` 输入和创建 Draft Release 所需的 `contents: write` 权限 +- Markdown 文档中的本地相对链接必须存在 +- 仓库文本中不能包含已知本机路径、历史密码、历史 API Key 或 Bearer JWT 片段 +- 每个 ZIP 必须包含 `/__init__.py` +- 每个 ZIP 必须包含 `/README.md` +- ZIP 中不能包含 `__pycache__`、`.pyc`、`.pyo`、`.DS_Store` + +## 输出位置 + +打包结果输出到: + +```text +dist/ +dist/skills/ +``` + +文件名格式: + +```text +-.zip +``` + +例如: + +```text +AgentResourceOfficer-<当前版本>.zip +``` + +## 使用方式 + +1. 打开 MoviePilot +2. 进入 设置 -> 插件 +3. 选择本地安装插件 +4. 上传 `dist/` 下生成的 ZIP 文件 + +## 注意事项 + +- `plugin_version` 取自目标插件目录下的 `__init__.py` +- 如果改了版本号,重新运行脚本即可生成对应文件名 +- `dist/` 目录默认不纳入 Git 版本管理 +- 提交前建议以 `bash scripts/release-preflight.sh` 作为最终验收 diff --git a/docs/PLUGIN_INSTALL.md b/docs/PLUGIN_INSTALL.md new file mode 100644 index 0000000..456011f --- /dev/null +++ b/docs/PLUGIN_INSTALL.md @@ -0,0 +1,188 @@ +# 插件安装说明 + +这份文档只讲普通用户怎么安装、先装什么、装完从哪里开始。 + +如果你只是新手,不需要看打包、发布、维护命令。 + +--- + +## 先装哪两个 + +优先安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这两个就是当前主线: + +- `Agent影视助手`:飞书命令入口、外部智能体入口、盘搜、影巢、115、夸克、MP/PT 下载。 +- `AI识别增强`:MoviePilot 原生识别失败时,用 LLM 做一层兜底。 + +旧插件可以先不装。 + +--- + +## 插件仓库安装 + +在 MoviePilot 插件市场里添加自定义插件仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +然后在插件市场安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这是最推荐的安装方式。 + +--- + +## 本地 ZIP 安装 + +如果你拿到的是 Release 里的 ZIP 包,也可以在 MoviePilot 插件页本地上传安装。 + +普通用户只需要优先认这两个包: + +```text +AgentResourceOfficer-0.2.68.zip +AIRecognizerEnhancer-0.1.12.zip +``` + +其他旧插件包只用于兼容旧链路,新装一般不用优先安装。 + +当前 Release 里还可能看到这些旧插件包: + +```text +FeishuCommandBridgeLong-0.5.26.zip +HdhiveOpenApi-0.3.0.zip +QuarkShareSaver-0.1.0.zip +``` + +--- + +## 装完 Agent影视助手后做什么 + +打开 `Agent影视助手` 设置页面,按你要用的功能填写: + +| 你想用的功能 | 需要配置 | +|---|---| +| 飞书命令入口 | 飞书应用的 `App ID` / `App Secret` | +| 盘搜搜索 | `盘搜 API 地址` | +| 影巢搜索 | `影巢 OpenAPI Key` | +| 115 转存 | `115 默认目录`,然后发 `115登录` 扫码 | +| 夸克转存 | 夸克 Cookie 或 CookieCloud | +| PT 下载 | 通常依赖 MoviePilot 原生下载器;MP 和 qB 不同机时可填 `PT 下载保存路径` | + +不用的功能可以先不填,插件会自动跳过。 + +--- + +## 不接智能体,只用飞书 + +如果你不使用外部智能体,只想把飞书当成命令入口: + +1. 在插件设置页配好飞书。 +2. 确认只保留一个飞书入口监听,避免旧飞书插件和新插件同时收消息。 +3. 直接在飞书里发命令。 + +常用命令: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 接外部智能体 + +如果你要让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体控制 MoviePilot,安装插件后还要让智能体安装 `agent-resource-officer skill / helper`。 + +最短路径: + +1. MoviePilot 安装并启用 `Agent影视助手`。 +2. 把 [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) 里的提示词发给你的智能体。 +3. 智能体按文档安装 skill,并填写: + +```text +ARO_BASE_URL=http://你的MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS、智能体在 Win / Mac,请看: + +[跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +### MCP 怎么办 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,也可以同时接: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +建议分工: + +- 查插件列表、下载器状态、站点状态、历史记录、工作流这类 MoviePilot 管理信息,可以优先用 MCP。 +- 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、Cookie 修复,继续优先用 `agent-resource-officer skill / helper`。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也继续优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +--- + +## AI识别增强怎么用 + +`AI识别增强` 不需要额外 Gateway。 + +它直接复用 MoviePilot 当前已经启用的 LLM 配置,在原生文件名识别失败时做兜底,然后把结果交回 MoviePilot 原生整理链。 + +详细说明见:[AI识别增强](../AIRecognizerEnhancer/README.md) + +--- + +## 旧插件还要不要装 + +新装一般不需要优先安装旧插件。 + +| 旧插件 | 用途 | 建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 旧影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 旧夸克独立转存 | 主能力已收进 Agent影视助手 | + +如果你是老环境迁移,可以暂时保留;如果是新装,先用 `Agent影视助手`。 + +--- + +## 维护者文档 + +如果你只是普通用户,到这里就够了。 + +资源主线:`Agent影视助手 / AgentResourceOfficer 0.2.68` + +当前 Skill helper:`agent-resource-officer 0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +维护命令路径:`docs/MAINTENANCE_COMMANDS.md` + +如果你要打包、发布或维护仓库,再看: + +- [维护命令](./MAINTENANCE_COMMANDS.md) +- [发布检查](./RELEASE_CHECKLIST.md) +- [打包说明](./PACKAGING.md) diff --git a/docs/REBUILD_AGENT_SUITE.md b/docs/REBUILD_AGENT_SUITE.md new file mode 100644 index 0000000..b2d52be --- /dev/null +++ b/docs/REBUILD_AGENT_SUITE.md @@ -0,0 +1,127 @@ +# 重构计划:Agent Suite + +> 这是历史重构规划文档,主要用于回看设计演进。 +> 当前安装、接入、发布请优先看 `README.md`、`docs/INDEX.md`、`docs/PLUGIN_INSTALL.md`、`docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md`。 + +这个仓库接下来不再继续沿着“功能越分越散”的方向增长,而是进入一次有边界的重构: + +- 保留旧插件作为可运行的 `legacy` 参考 +- 在新分支上并行重建两套新插件 +- 等新插件链路跑稳后,再逐步归档旧插件 + +当前重构分工如下: + +## 目标插件 + +### 1. Agent影视助手 + +定位: + +- 智能体友好的资源工作流主插件 +- 对外统一承接搜索、选择、解锁、转存、签到、远程消息入口 + +计划整合的现有能力: + +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `HDHiveDailySign` +- `QuarkShareSaver` + +首期能力边界: + +- 盘搜 / 影巢搜索与候选选择 +- 影巢资源解锁 +- 115 / 夸克自动转存 +- 通用分享链接路由 +- MP 原生 Agent Tool / 插件 API / 智能体会话入口 +- 飞书桥接后续按需委托 + +### 2. AI识别增强 + +定位: + +- MoviePilot 原生识别失败后的本地 AI 识别增强插件 +- 不再依赖外部 AI Gateway 作为必经链路 + +计划承接的现有能力: + +- 已全部收敛到 `AIRecognizerEnhancer` + +首期能力边界: + +- 识别失败事件兜底 +- 直接调用 MP 内置 LLM 配置进行结构化识别 +- 自动二次整理 +- 为后续“自定义识别词建议”预留扩展点 + +## 旧插件处理原则 + +重构期间,以下目录优先保留;自用魔改和旧签到插件可逐步从公开仓库下架: + +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +处理原则: + +- 旧插件继续作为线上可运行版本 +- `FeishuCommandBridgeLong` 当前继续保留为兼容飞书入口,不做删除 +- `HDHiveDailySign`、`ZspaceMediaFreshMix` 更适合本地自用,不再作为公开主线插件继续发布 +- 新功能尽量优先落到新插件设计里 +- 旧插件只做必要修复,不再继续扩张边界 + +## 迁移顺序 + +建议按下面顺序逐步迁移,避免同时重写太多链路: + +1. `Agent影视助手` 先完成目录骨架、配置模型和入口设计 +2. 先搬入 `QuarkShareSaver` 的稳定执行能力 +3. 再搬入 `HdhiveOpenApi` 的搜索、解锁、转存能力 +4. 接入原生 Agent Tool 与统一 API +5. 最后把 `FeishuCommandBridgeLong` 收缩为消息入口和会话层 +6. 单独重写 `AI识别增强` + +## 为什么不是一个插件 + +这次明确不做“超级大插件”,原因很实际: + +- 搜索/转存/签到属于资源工作流 +- 识别失败兜底属于整理工作流 +- 两类逻辑耦合过深后,配置、排障和升级成本都会显著升高 + +最终目标是: + +- 对外看起来像一套统一产品 +- 仓库内部保留两个清晰边界 + +## 分支与备份 + +本次重构采用: + +- 备份归档后再开新分支 +- 在 `codex/rebuild-agent-suite` 上推进 + +仓库外备份文件已单独存放,作为重构前快照。 + +## 当前状态 + +当前已完成: + +- 仓库快照备份 +- 重构分支创建 +- `Agent影视助手` 目录、配置模型、执行层、统一 API 已落地 +- `Agent影视助手` 已接通影巢搜索/解锁、115 转存、夸克转存、盘搜搜索与直链路由 +- `Agent影视助手` 已接通原生 Agent Tool 和智能体会话式 API +- `Agent影视助手` 已补齐影巢候选分页与 `详情` / `审查` 按需补主演,飞书新主线不再缺这段交互 +- `Agent影视助手` 已补齐 `P115StrmHelper` 新版 MoviePilot 兼容补丁脚本,115 健康检查已验证 `p115_ready=true` +- `Agent影视助手` 已新增 115 轻量直转层,分享链接落盘可优先不走 `P115StrmHelper.sharetransferhelper`,失败时再回退旧执行层 +- `FeishuCommandBridgeLong` 保持线上可运行,默认继续走 `legacy` 快路径 +- `FeishuCommandBridgeLong` 已支持切换到 `auto`,把智能入口委托给 `Agent影视助手` +- 运行环境已完成双链路验证:`legacy` 日常可用,`auto` 可接手统一资源工作流 +- `AIRecognizerEnhancer` 已进入 `0.1.11` 阶段,可直接复用 MoviePilot 当前 LLM 配置,在 `NameRecognize` 阶段做本地结构化兜底,并支持失败样本维护、样本洞察、精简摘要、直接转建议、批量建议、写入动作、样本出队、样本复查和批量复查;当识别词建议模型退化时会自动切到精确规则兜底 + +下一步重点: + +1. 继续把影巢签到、用户态、配额态能力评估是否并入 `Agent影视助手` +2. 继续打磨 `AIRecognizerEnhancer` 的提示词、失败样本洞察和识别词建议质量 +3. 继续完善 `AgentResourceOfficer` Skill 与外部智能体的低 token、可恢复、可审计调用链路 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..ba13699 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,209 @@ +# Release Checklist + +发布前按这个顺序执行,避免漏包、错包或上传旧 ZIP。 + +如果你只想走一条完整命令,直接执行: + +```bash +bash scripts/release-preflight.sh +``` + +它会先跑 `repo-hygiene.sh`,再跑 `pre-release-check.sh`。 + +如果你只想快速查维护/发布命令,不想通读整份清单,直接看: + +- `docs/MAINTENANCE_COMMANDS.md` + +## 1. 确认工作区 + +```bash +git status --short --branch +``` + +工作区应当干净。 + +## 2. 查看插件清单 + +```bash +bash scripts/package-plugin.sh --list +``` + +确认输出的插件和版本符合本次发布预期。 + +同时确认当前对外文档没有落后: + +- `README.md` +- `docs/INDEX.md` +- `docs/PLUGIN_INSTALL.md` +- `docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md` +- `docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md` + +## 3. 执行完整检查 + +如果只改了 Skill,可以先跑轻量检查: + +```bash +bash scripts/check-skills.sh +``` + +最终发布前仍然执行完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +这个命令会先跑 `repo-hygiene.sh`,再执行 `pre-release-check.sh`;后者会同步 `plugins/` 和 `plugins.v2/`,检查元数据、Skill helper、ZIP 内容,并重新生成插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`。 + +其中也会自动执行: + +```bash +python3 scripts/check-doc-current-state.py +``` + +用来校验当前状态文档中的插件版本、helper 版本和 release URL 没有落后于代码。 + +如果本机已经跑着可访问的 MoviePilot,并且 `~/.config/agent-resource-officer/config` 已配置 `ARO_BASE_URL` / `ARO_API_KEY`,建议追加一次真实链路检查: + +```bash +RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE=1 bash scripts/pre-release-check.sh +``` + +## 4. 上传 ZIP + +Release 附件上传 `dist/` 下的插件 ZIP、`dist/skills/` 下的 Skill ZIP。创建 Draft Release 时,脚本会把校验文件改名为唯一附件名,避免 GitHub Release 附件重名: + +- `PLUGIN_SHA256SUMS.txt` +- `PLUGIN_MANIFEST.json` +- `SKILL_SHA256SUMS.txt` +- `SKILL_MANIFEST.json` + +本地核对命令: + +```bash +ls -1 dist/*.zip +ls -1 dist/skills/*.zip +cat dist/SHA256SUMS.txt +cat dist/MANIFEST.json +cat dist/skills/SHA256SUMS.txt +cat dist/skills/MANIFEST.json +bash scripts/verify-release-assets.sh +bash scripts/verify-dist.sh +bash scripts/verify-skill-dist.sh +bash scripts/print-release-summary.sh +bash scripts/print-skill-release-summary.sh +``` + +不要上传历史旧包。`pre-release-check.sh` 会在打包前清理旧 ZIP。 + +## 5. 远端确认 + +推送后确认 GitHub Actions 通过: + +```bash +gh run list --limit 3 +``` + +`Release Preflight` workflow 通过后会在该 run 的 Artifacts 区域生成 `moviepilot-release-assets-`,里面包含本次插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`。Draft Release 附件中的校验文件会使用 `PLUGIN_` / `SKILL_` 前缀避免重名。 + +如需在本地下载并校验最近一次成功 `Release Preflight` artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +也可以指定 run id: + +```bash +bash scripts/verify-release-preflight-artifact.sh 25017759143 +``` + +如果已经从 GitHub Release 页面下载了全部附件,也可以直接校验下载目录: + +```bash +bash scripts/verify-release-download.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +如果 Draft Release 已存在,需要用当前 `dist/` 重新覆盖 notes 和附件: + +```bash +bash scripts/update-draft-release-assets.sh --skip-check +``` + +也可以在 GitHub 页面手动运行:Actions -> Release Preflight -> Run workflow。 + +## 6. 创建 Draft Release + +先 dry-run,确认附件和说明能生成: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +确认无误后创建 GitHub Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +也可以在 GitHub Actions 手动触发: + +```bash +gh workflow run draft-release.yml -f tag= -f dry_run=true +``` + +dry-run 通过后会生成 `moviepilot-release-assets--` artifact,可先下载核对。确认无误后,再用 `dry_run=false` 创建 Draft Release。 + +## 7. 发布正式 Release + +Draft Release 核对无误后发布正式 Release: + +```bash +gh release edit --draft=false --latest --target main +``` + +发布后确认状态、tag 和公开附件: + +```bash +gh release view --json tagName,isDraft,isPrerelease,url,publishedAt,targetCommitish +git ls-remote --tags origin "refs/tags/" +bash scripts/verify-release-download.sh +``` + +正式发布后,`isDraft` 应为 `false`,公开下载校验必须通过。 + +## 8. 发布后清理 + +发布完成后,顺手清理本地过期的远端引用,并检查是否有已经不再需要的发布分支: + +```bash +git fetch --prune origin +git branch -r +python3 scripts/audit-remote-branches.py +``` + +如果远端已经收干净,但本地还留着大量历史分支,可先看 dry-run: + +```bash +python3 scripts/archive-local-branches.py +``` + +确认无误后再执行: + +```bash +python3 scripts/archive-local-branches.py --apply +``` + +这个脚本会先把本地历史分支转成 `archive/` 本地 tag,再删除分支名。 + +如果只是想一条命令快速看当前仓库分支卫生状态,可以执行: + +```bash +bash scripts/repo-hygiene.sh +``` + +注意: + +- 远端分支如果是通过 `squash merge` 合并,`git merge-base --is-ancestor` 不能直接作为删分支依据。 +- 删除前先确认该分支没有关联 PR,且不再需要保留为历史参考。 +- 如果只是本地看到“远端分支还在”,先 `fetch --prune`,不要直接假设远端没清理。 diff --git a/docs/RELEASE_v2.0.0-alpha.1.md b/docs/RELEASE_v2.0.0-alpha.1.md new file mode 100644 index 0000000..30330a0 --- /dev/null +++ b/docs/RELEASE_v2.0.0-alpha.1.md @@ -0,0 +1,92 @@ +# v2.0.0-alpha.1 历史发布文案 + +> 这是旧 AI Gateway 拆分阶段的历史发布草稿,仅保留作归档参考。 +> 当前仓库已经演进为多插件套件,发布前请以 `docs/GITHUB_PUBLISH.md` 和 `scripts/release-preflight.sh` 为准。 + +## GitHub Release 页面填写 + +### Tag version + +```text +v2.0.0-alpha.1 +``` + +### Release title + +```text +v2.0.0-alpha.1 首个拆分仓库版本 +``` + +### 是否勾选 Pre-release + +建议: + +- 勾选 + +因为当前版本仍属于 `alpha` 阶段。 + +### 是否勾选 latest + +建议: + +- 不要手动强调为稳定版 + +## 建议上传的附件 + +建议在 GitHub Release 页面上传这个 ZIP: + +```text +dist/AIRecoginzerForwarder-v2.0.0-alpha.1.zip +``` + +这个 ZIP 已经是可用于 MoviePilot 本地安装的插件包。 + +## Tag + +```text +v2.0.0-alpha.1 +``` + +## Title + +```text +v2.0.0-alpha.1 首个拆分仓库版本 +``` + +## Release Notes + +```md +## v2.0.0-alpha.1 首个拆分仓库版本 + +这是 `MoviePilot-Plugins` 仓库的首个 `v2.0` alpha 版本。 + +本版本的目标,是将 MoviePilot 插件本体从运行时网关中拆分出来,形成更适合 GitHub 和 NAS 用户使用的双仓库发布结构。 + +## 本版本包含 + +- 独立插件仓库结构 +- AI Gateway 对接配置 +- 异步回调处理 +- 二次整理触发逻辑 +- `standard` / `enhanced` 识别增强模式 + +## 当前定位 + +- 插件仓库只负责 MoviePilot 插件本体 +- Gateway 运行时由独立镜像仓库提供 +- 默认与 `moviepilot-ai-recognizer-gateway` 配套使用 + +## 适用场景 + +- MoviePilot 原生识别失败补救 +- PT 资源标准命名识别 +- 网盘拼音、漏词、规避命名识别 +- 本地文件与云盘挂载回调后二次整理 + +## 首发建议 + +- Release 页面上传插件 ZIP +- 仓库安装与本地 ZIP 安装都保留 +- 插件默认推荐与 `moviepilot-ai-recognizer-gateway` 配套使用 +- 默认更推荐同机 Docker / 同网络部署,不建议默认走跨主机方案 +``` diff --git a/icons/agentresourceofficer.png b/icons/agentresourceofficer.png new file mode 100644 index 0000000000000000000000000000000000000000..790c73d5fa9b726cfaa67d90f118d9640aeb0472 GIT binary patch literal 240942 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%0A#Ddx_?wdshN^u!+#e?|nCR@|-b& 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 z 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 z zP+a{!yl*W<{P2i}J^rW|f7JMgnSCrvm#xdHVD>w{_0Z4s^=Es2V~0_z+URA=tb(Xc<0odHz}ck3wUh!n0Y8ho6tdnx*5qvW`h%=a}UD zpg0eZd;B&jC<{`u>||-(VA=zJJQI)Pp`2|e$jz1xMib0shl}u?%jX3!WhK|}P7l`H zbPRs&b~b@X3FjJ={GmT1tG4X)kip5g^pn8xLBj=feqtSY0D#TS%J9c~@!yc>24Jv? zU*zC}XJ7Z)(0k+UlK1s*W=BR$ngVW`r?hBSsfbK<-QDlnG zBE>H;Bu$w~aLSY&dlu}Bheyd*4^VRbAc&@|q^6S!i;$@F6k&DG#*#Rsr9K&kex`{s+5z()|BI_3&K%v6lxG?Fy_9f2054v-}UE ze7pZp$Qg5={Z6rGz#ZcNdZ!2Zpl99RCk3#P@nn7 z;B(F3Bz@uu_pz;hoqyzDSoQXWj&*M_*}R{^oZ zbFY)9$8jhBi6f_e%pnBA16|lP)d%*Em(wN}fJ~UCYZ*d!FHA~kGEW|%Y9)@m;%(Dr z@En~~rVNgAI(~oxFm??3_3f>Jr+A#|f7|1C*fHl;4W**8=*TUzJCJP(cD>}q34i?< zM!4bY1iMaqv5}WdnJ;3AR{VM43+Uj1+brr4i7zoD1OqWV#(#3%KYu$0%=I}5<0amS z7-QE@rZ}}Wzdd=6obTFKK4>|g%g=ZKjPXOgr2gXb&!&C{Pp;`Ei9^=pQ!pg%ZPnLj z*5UNcju(x2oyJch4~V*V_0M*A(EPpmz8QGJ-#Nf%2Y#}xwKogn&O>$ozVx?TKRsSt z@cD9{tOb9g|8W-iqw@MFejmk$y7B8e-N#k-ny;qm8vlo{y`^Jq_OY52m&0BPU_2K( zdO0@r({o9t==y7ZeTiMIT zhJ&z^AI2eNo{9)cbRbwid3;`~za8--w|)~yNRvgD-Z{Xyt H=ZKFl*4AsrCl45i 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

jOL3#E-<^zWm|C7toMLZS|uEUwrN-#grD0 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 literal 0 HcmV?d00001 diff --git a/icons/agentresourceofficer.svg b/icons/agentresourceofficer.svg new file mode 100644 index 0000000..398914b --- /dev/null +++ b/icons/agentresourceofficer.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/airecoginzerforwarder.png b/icons/airecoginzerforwarder.png new file mode 100644 index 0000000000000000000000000000000000000000..67f549f8bf73f123ae11aa4d620072f41042919f GIT binary patch literal 4165 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 hkt?9p>sIU1k + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/airecognizerenhancer.svg b/icons/airecognizerenhancer.svg new file mode 100644 index 0000000..b7fa394 --- /dev/null +++ b/icons/airecognizerenhancer.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/feishucommandbridgelong.png b/icons/feishucommandbridgelong.png new file mode 100644 index 0000000000000000000000000000000000000000..2322d0169dbbfc97a01ea69e2be0f5b509877adb GIT binary patch literal 82736 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