Compare commits

...

23 Commits

Author SHA1 Message Date
jxxghp
e591f872a1 fix: add system version requirement to package.v2.json 2026-06-14 11:26:57 +08:00
jxxghp
8d36e6865b fix: update Maoyan plugin version to 3.1 and enhance UI with pagination and null value handling 2026-06-14 11:17:25 +08:00
jxxghp
4c73b74f59 fix: adapt maoyan rank browser helper 2026-06-14 10:52:28 +08:00
liuyuexi1987
4af7cf1583 chore: archive legacy resource plugins (#1053) 2026-06-14 10:33:41 +08:00
liuyuexi1987
a7d37e2f8f feat(aro): update AgentResourceOfficer to 0.3.0 (#1052) 2026-06-13 20:56:09 +08:00
wumode
8dd0783b4a feat(lexiannot): 适配新版 MoviePilot 助手,提取字幕助手类 SubtitleHelper (v1.2.6) (#1051) 2026-06-12 20:25:43 +08:00
jxxghp
afc28446f5 fix: 修复媒体库刮削弱文件名路径识别 2026-06-12 12:44:43 +08:00
书小白
e89047a95f UpdateWeChatIp 完善日志输出 (#1050) 2026-06-12 10:24:28 +08:00
jxxghp
ad8137a65a fix: 修复媒体库刮削分类目录识别 2026-06-12 10:10:07 +08:00
RamenRa
1ececcb410 修复本地扫码获取不到验证码的问题 (#1048) 2026-06-12 06:47:06 +08:00
书小白
8490668f5d 插件初始化时调用一下check确定登录状态 (#1047) 2026-06-11 13:28:00 +08:00
书小白
1fb6b4845e 修复未登录时_party_cache_data为空导致UI崩溃的BUG (#1046) 2026-06-11 12:43:07 +08:00
书小白
b63e74b680 feat: 新增 企微应用白名单更新插件 (#1045) 2026-06-11 12:08:00 +08:00
Devin
2b575d9e8d feat: 新增 Trakt 观看清理插件 (#1043) 2026-06-11 06:48:20 +08:00
jxxghp
85ea5cba61 fix: repair invites signin success detection 2026-06-09 21:47:04 +08:00
jxxghp
0665cfb71b fix: support jellyfin item added webhook 2026-06-09 20:51:18 +08:00
jxxghp
692205095c feat: update plugin versions to 1.9.14 and 2.17, fix issues with expired site hash values 2026-06-08 14:40:26 +08:00
ui_beam
465ce39f6f feat(oidcauth): 版本升级到0.3.1 (#1041)
* feat(oidcauth): 版本升级到0.3.0

* feat(oidcauth): 版本升级到0.3.1

---------

Co-authored-by: ui_beam <admin@beamnet.cn>
2026-06-07 20:06:49 +08:00
jxxghp
949fced655 Merge remote-tracking branch 'origin/main' 2026-06-06 20:33:26 +08:00
jxxghp
2e352a1845 feat: update dashboard layout and styling for improved responsiveness and user experience 2026-06-06 20:33:02 +08:00
jxxghp
295e49311f Delete .DS_Store 2026-06-06 20:18:39 +08:00
jxxghp
613b1f2604 chore: add remaining workspace changes 2026-06-06 20:18:01 +08:00
jxxghp
088b9e6d98 feat: update agenttokens、moviepilotserver status plugin 2026-06-06 20:16:01 +08:00
116 changed files with 21080 additions and 19135 deletions

BIN
icons/Oidcauth_A.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
icons/Oidcauth_B.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,27 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="44" y1="36" x2="206" y2="222" gradientUnits="userSpaceOnUse">
<stop stop-color="#42D0FF"/>
<stop offset="1" stop-color="#0EA5E9"/>
</linearGradient>
<filter id="shadow" x="46" y="50" width="164" height="152" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0B4A69" flood-opacity="0.18"/>
</filter>
</defs>
<circle cx="128" cy="128" r="106" fill="url(#bg)"/>
<g filter="url(#shadow)">
<rect x="68" y="72" width="96" height="96" rx="24" fill="white"/>
<rect x="86" y="96" width="60" height="12" rx="6" fill="#0E7490"/>
<rect x="86" y="116" width="40" height="12" rx="6" fill="#38BDF8"/>
<rect x="86" y="136" width="52" height="12" rx="6" fill="#7DD3FC"/>
</g>
<path d="M160 124C173.333 124 184 113.333 184 100C184 86.6667 173.333 76 160 76" stroke="white" stroke-width="14" stroke-linecap="round"/>
<path d="M173 62L195 84L173 106" stroke="white" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="183" cy="154" r="25" fill="#0C4A6E"/>
<path d="M171 154H195" stroke="white" stroke-width="12" stroke-linecap="round"/>
<path d="M183 142V166" stroke="white" stroke-width="12" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -253,11 +253,12 @@
"name": "媒体库服务器通知",
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
"labels": "消息通知,媒体库",
"version": "1.3",
"version": "1.4",
"icon": "mediaplay.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.4": "兼容Jellyfin ItemAdded入库Webhook事件",
"v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景",
"v1.2": "播放通知增加超链接跳转需要v1.9.4+"
}
@@ -327,11 +328,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "1.9.13",
"version": "1.9.14",
"icon": "IYUU.png",
"author": "jxxghp",
"level": 2,
"history": {
"v1.9.14": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
"v1.9.13": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v1.9.12": "修复海豹不能辅种的问题",
"v1.9.11": "修复馒头不能辅种的问题",
@@ -476,12 +478,13 @@
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"version": "2.0.3",
"version": "2.0.4",
"icon": "invites.png",
"author": "thsrite",
"level": 2,
"release": true,
"history": {
"v2.0.4": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
"v2.0.3": "增加启用浏览器仿真功能发送请求",
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
"v2.0.1": "尝试修复签到失败问题新增使用代理、Cookie自动更新功能",
@@ -877,12 +880,13 @@
"name": "MoviePilot服务器监控",
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
"labels": "仪表板",
"version": "1.3",
"version": "1.4",
"icon": "Duplicati_A.png",
"author": "jxxghp",
"level": 1,
"v2": true,
"history": {
"v1.4": "重新设计仪表板,仅展示响应延迟、连接状态和速率等关键卡片",
"v1.3": "增加HTTP/DNS/TLS探测、请求速率、连接占比和异常兜底展示",
"v1.2": "优化数量示",
"v1.1": "增加详情界面显示"
@@ -1087,24 +1091,15 @@
"name": "AI识别增强",
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
"version": "0.1.12",
"version": "0.1.13",
"icon": "airecognizerenhancer.png",
"author": "liuyuexi1987",
"level": 1,
"v2": true,
"history": {
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
"0.1.13": "同步运行态能力,兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复失败样本持久化、清空和自动移除后的配置保存一致性。",
"0.1.10": "完善失败样本批量工作流,支持复查、批量建议、批量写入、低 token 摘要,并在模型异常时提供精确规则兜底。",
"0.1.4": "建立失败样本治理闭环,支持失败样本查看、摘要洞察、清理去重、保留上限,以及生成并写入 CustomIdentifiers。",
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
}
},
@@ -1112,271 +1107,21 @@
"name": "Agent影视助手",
"description": "龙虾agent稳定控制 MP飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
"version": "0.2.71",
"version": "0.3.0",
"icon": "agentresourceofficer.png",
"author": "liuyuexi1987",
"level": 1,
"history": {
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
"0.2.63": "为 compact 顶层短命令增加执行语义字段command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred外部智能体现在可以机械判断 直接读 还是 先确认再写。",
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
"0.2.61": "为 compact 失败回执增加统一 error_summary外部智能体现在可以直接读取失败标签、建议说明以及 preferred_command / compact_commands 这样的最短恢复命令。",
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commandsmp_recent_activity 也补齐 followup_summary外部智能体可直接读取最短下一步命令。",
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose并让生命周期/执行后追踪统一返回 diagnosis_summary。",
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup而不是退回会话检查或新任务。",
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe外部智能体可以通过低 token 模板直接续接执行后追踪。",
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint方便外部智能体一跳续接执行后追踪。",
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint让外部智能体执行计划后能直接读取建议下一步。",
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
"0.2.42": "补齐 compact session/recover 协议里的 action_templates外部智能体读取会话状态或恢复入口时也能拿到完整的结构化下一步模板。",
"0.2.41": "补齐 PT 只读会话的 action_templates下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
"0.2.33": "统一 MP 原生命令前缀解析下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary避免外部智能体误读上下文。",
"0.2.30": "细化评分风险结构hard_risk_reasons 表示真正阻断自动化的风险risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
"0.2.28": "插件展示名统一改为 Agent影视助手并同步仓库文档、Skill 文案和兼容插件引用。",
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id再确认执行。",
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
"0.2.19": "新增 MP 搜索结果详情入口MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
"0.2.11": "MP 下载/订阅命令支持无空格自然写法例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”自然语言写入默认生成 plan_id确认后才执行。",
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”飞书与智能体可不用结构化 mode 参数。",
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
"0.2.05": "新增低 token score_summary帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status并在未初始化时优先提示保存偏好。",
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state避免把已禁用但仍加载的旧插件误判为冲突。",
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议方便判断是否能从旧桥接迁移。",
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health支持内置智能助手检查飞书入口状态。",
"0.1.108": "内置可选飞书入口 Channel并为 assistant 回执补充 write_effect/error_code 标准字段。",
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
"0.1.106": "assistant/startup 会带 recommended_request_templates外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message方便外部智能体在写入前提示用户确认。",
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
"0.1.101": "推荐调用会带 url_template外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey避免外部智能体误用 Bearer 鉴权。",
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
"0.1.98": "recommended_recipe_detail 会带 first_call直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail直接给出推荐流程的首个模板、确认模板和写入模板外部智能体可直接照此编排。",
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason外部智能体不必再自己挑选最适合的 recipe。",
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds自检也会验证这些汇总特征。",
"0.1.94": "assistant/request_templates 回执会带场景化 recipes外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence直接给出推荐调用顺序外部智能体可以少做一层启动编排。",
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_secondsexecution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates方便外部智能体决定缓存策略。",
"0.1.91": "assistant/request_templates 支持 include_templates=false可只返回模板名、无效项和执行策略进一步减少 token。",
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1startup/toolbox 也携带 request_templates_schema_version方便外部智能体做兼容判断。",
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
"0.1.87": "request_templates 每个模板都会带 description外部智能体可以直接判断模板用途减少额外解释和 token 消耗。",
"0.1.86": "request_templates 每个模板都会带 tool_args区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit方便结构化智能体直接用 body 请求过滤模板。",
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names原生 Tool 同步支持 names 参数。",
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool外部智能体可只拉请求模板而不拉完整启动包。",
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body外部智能体无需猜维护调用方式。",
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history方便外部智能体审计维护动作GET dry-run 仍不写历史。",
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions外部智能体可直接判断是否值得做低风险维护清理。",
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all避免外部智能体把全部计划数误判为待执行计划数。",
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates外部智能体可拿启动包直接执行推荐恢复动作。",
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议减少外部智能体开场多次探测。",
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck便于外部智能体开场自检协议健康。",
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true外部智能体原样回放模板即可保持低 token。",
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan外部智能体可按恢复协议直接续跑。",
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session便于从会话列表直接恢复。",
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作action_templates.action_body 可原样回传执行计划。",
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
"0.1.40": "新增 assistant/history 与 history Tool记录最近批量动作和预设工作流执行摘要便于外部智能体判断进度、排障和恢复上下文。",
"0.1.39": "新增 assistant/readiness 与 readiness Tool外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示再决定是否开始执行。",
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
"0.1.37": "新增 assistant/actions 与 execute_actions Tool外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
"0.1.36": "新增 assistant/action 与 execute_action Tool外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates外部智能体可直接按返回模板继续调用不再自己拼下一步参数。",
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
"0.1.33": "新增活跃会话列表 API 与原生 Tool并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
"0.1.29": "新增 Agent影视助手 帮助 Tool并让统一智能入口在空输入或帮助语义下直接返回推荐用法降低 MP 智能助手首次调用门槛。",
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Toolsmart_entry / smart_pickMP 智能助手可直接复用飞书同款处理/选择主链。",
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool避免仍显示骨架态提示。",
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
"0.1.25": "新增 115 待处理任务标准 API查看、继续、取消便于飞书、CLI 与外部脚本直接调用。",
"0.1.24": "新增 115 待处理任务原生 Agent Tool查看、继续、取消MP 智能助手可直接调用待处理任务能力。",
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录可自动继续上次未完成的 115 操作。",
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
"0.1.13": "新增 115 扫码登录原生 Agent Tool智能助手可直接发起二维码并轮询登录状态。",
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本115 健康检查已验证可用。",
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API让 Agent影视助手 开始承接用户态能力。",
"0.1.6": "新增 Agent影视助手 自己的智能入口 API支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
"0.1.2": "新增原生 Agent Tool影巢会话搜索、会话继续选择、通用分享链接路由。",
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
"0.1.0": "首个可用版本已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
}
},
"FeishuCommandBridgeLong": {
"name": "飞书命令桥接",
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
"version": "0.5.26",
"icon": "feishucommandbridgelong.png",
"author": "liuyuexi1987",
"level": 1,
"v2": true,
"history": {
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片也支持回复检查115登录继续轮询 Agent影视助手 会话。",
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手影巢/115/夸克开始走新主干。",
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
"0.5.0": "新增处理/选择双命令与智能体 API统一分流夸克链接、115 链接与影巢搜索解锁流程。",
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong修复插件市场安装状态匹配。",
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
}
},
"HdhiveOpenApi": {
"name": "影巢 OpenAPI",
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
"version": "0.3.0",
"icon": "hdhive.ico",
"author": "liuyuexi1987",
"level": 1,
"v2": true,
"history": {
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
}
},
"QuarkShareSaver": {
"name": "夸克分享转存",
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
"version": "0.1.0",
"icon": "quark.ico",
"author": "liuyuexi1987",
"level": 1,
"v2": true,
"history": {
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
"0.3.0": "精简 Agent/MCP 暴露工具集,仅保留核心业务能力工具,移除自建计划、会话和自描述脚手架噪声;保留飞书与 HTTP 端点。",
"0.2.99": "重做 Vue 配置页与 115 扫码体验,修复扫码登录、配置保存、依赖误判、影巢网页登录 Cookie、自动刷新和 Playwright 兜底等问题。",
"0.2.74": "收口云盘搜索、转存、影巢签到和恢复链路,适配影巢 OpenAPI 新鉴权,补齐 115/夸克目录清理、Cookie 修复、远端接入和兼容检查。",
"0.2.67": "沉淀外部智能体执行契约和低 token 模板,统一 followup、error、score、command 摘要,并完善执行后追踪、恢复、维护与模板编排。",
"0.2.43": "形成 MP 原生 PT 主线与智能评分推荐,新增媒体识别、热门推荐、搜索详情、下载/订阅/控制计划、生命周期追踪、入库诊断、偏好画像和安全确认链路。",
"0.1.119": "完善请求模板协议、启动包、自检、维护和飞书入口迁移,并内置影巢签到日志与 Cookie 自动刷新,降低外部智能体接入成本。",
"0.1.79": "建立 assistant 协议层,新增 action、workflow、plans、history、readiness、recover、startup、toolbox、selfcheck 等能力,支持 compact 回执、会话恢复和安全 dry-run 计划执行。",
"0.1.34": "打通统一智能入口、原生 Agent Tool、会话状态/清理、115 扫码登录、待处理任务恢复与 115 独立直转执行层。",
"0.1.9": "首版接入夸克/115 转存、影巢搜索/解锁、盘搜/直链路由、候选分页详情、账号配额、签到兜底和健康检查。"
}
}
}

View File

@@ -99,11 +99,12 @@
"name": "媒体库服务器通知",
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
"labels": "消息通知,媒体库",
"version": "1.8.2.2",
"version": "1.8.2.3",
"icon": "mediaplay.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.8.2.3": "兼容Jellyfin ItemAdded入库Webhook事件",
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
"v1.8.2.1": "修复多集时有概率图片获取失败的问题修复emby测试通知类型接收失败的问题",
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
@@ -194,11 +195,13 @@
"name": "媒体库刮削",
"description": "定时对媒体库进行刮削,补齐缺失元数据和图片。",
"labels": "刮削",
"version": "2.1.1",
"version": "2.1.3",
"icon": "scraper.png",
"author": "jxxghp",
"level": 1,
"history": {
"v2.1.3": "修复分类路径下按文件标记识别及强制类型跨库扫描问题",
"v2.1.2": "修复分类目录被误识别导致下级媒体未刮削的问题",
"v2.1.1": "调整目录计算方法,以支持更多重命名格式",
"v2.1": "优化执行周期输入需要MoviePilot v2.2.1+",
"v2.0": "兼容MoviePilot V2 版本",
@@ -273,11 +276,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "2.16",
"version": "2.17",
"icon": "IYUU.png",
"author": "jxxghp,CKun",
"level": 2,
"history": {
"v2.17": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
"v2.16": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v2.15": "修复海豹不能辅种的问题",
"v2.14": "修复馒头不能辅种的问题",
@@ -584,11 +588,12 @@
"name": "美剧生词标注",
"description": "根据CEFR等级为英语影视剧标注高级词汇。",
"labels": "英语",
"version": "1.2.5",
"version": "1.2.6",
"icon": "LexiAnnot.png",
"author": "wumode",
"level": 1,
"history": {
"v1.2.6": "适配 MoviePilot 新版 LLM 助手",
"v1.2.5": "langchain 1.x 兼容 (主程序版本需高于 2.9.17)",
"v1.2.4": "增强数据校验",
"v1.2.3": "优化提示词",
@@ -661,12 +666,13 @@
"name": "动态企微可信IP",
"description": "修改企微应用可信IP支持Srever酱等第三方通知。验证码以结尾发送到企业微信应用",
"labels": "消息通知",
"version": "2.1.1",
"version": "2.1.2",
"icon": "Wecom_A.png",
"author": "RamenRa",
"level": 2,
"system_version": ">=2.12.0",
"history": {
"v2.1.2": "修复本地扫码获取不到验证码的问题",
"v2.1.1": "优化MP/Nas关闭期间IP变动检测不到的现象。支持IYUU通知移除AnPush v2支持在微信通知失效时用第三方发送通知 支持||Q修改IP时不发送通知 使用全局AI助手需使用/wxcode 510010的格式发送验证码",
"v2.0.1": "修复企业微信后台页面语言未稳定切换为中文导致无法匹配配置按钮的问题。",
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
@@ -686,13 +692,14 @@
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"version": "3.0.0",
"version": "3.0.1",
"icon": "invites.png",
"author": "thsrite",
"level": 2,
"release": true,
"system_version": ">=2.12.0",
"history": {
"v3.0.1": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
"v2.0.3": "增加启用浏览器仿真功能发送请求",
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
@@ -751,23 +758,14 @@
"name": "AI识别增强",
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
"version": "0.1.12",
"version": "0.1.13",
"icon": "airecognizerenhancer.png",
"author": "liuyuexi1987",
"level": 1,
"history": {
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
"0.1.13": "同步运行态能力,兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复失败样本持久化、清空和自动移除后的配置保存一致性。",
"0.1.10": "完善失败样本批量工作流,支持复查、批量建议、批量写入、低 token 摘要,并在模型异常时提供精确规则兜底。",
"0.1.4": "建立失败样本治理闭环,支持失败样本查看、摘要洞察、清理去重、保留上限,以及生成并写入 CustomIdentifiers。",
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
}
},
@@ -775,271 +773,20 @@
"name": "Agent影视助手",
"description": "龙虾agent稳定控制 MP飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
"version": "0.2.73",
"version": "0.3.0",
"icon": "agentresourceofficer.png",
"author": "liuyuexi1987",
"level": 1,
"system_version": ">2.12.4",
"history": {
"0.2.73": "整理历史标题分词依赖改用 jieba-next需要 MoviePilot >2.12.4。",
"0.2.72": "影巢自动登录兜底流程改用 CloakBrowser移除插件对 Playwright 浏览器调用的直接依赖。",
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
"0.2.63": "为 compact 顶层短命令增加执行语义字段command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred外部智能体现在可以机械判断 直接读 还是 先确认再写。",
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
"0.2.61": "为 compact 失败回执增加统一 error_summary外部智能体现在可以直接读取失败标签、建议说明以及 preferred_command / compact_commands 这样的最短恢复命令。",
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commandsmp_recent_activity 也补齐 followup_summary外部智能体可直接读取最短下一步命令。",
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose并让生命周期/执行后追踪统一返回 diagnosis_summary。",
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup而不是退回会话检查或新任务。",
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe外部智能体可以通过低 token 模板直接续接执行后追踪。",
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint方便外部智能体一跳续接执行后追踪。",
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint让外部智能体执行计划后能直接读取建议下一步。",
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
"0.2.42": "补齐 compact session/recover 协议里的 action_templates外部智能体读取会话状态或恢复入口时也能拿到完整的结构化下一步模板。",
"0.2.41": "补齐 PT 只读会话的 action_templates下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
"0.2.33": "统一 MP 原生命令前缀解析下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary避免外部智能体误读上下文。",
"0.2.30": "细化评分风险结构hard_risk_reasons 表示真正阻断自动化的风险risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
"0.2.28": "插件展示名统一改为 Agent影视助手并同步仓库文档、Skill 文案和兼容插件引用。",
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id再确认执行。",
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
"0.2.19": "新增 MP 搜索结果详情入口MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
"0.2.11": "MP 下载/订阅命令支持无空格自然写法例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”自然语言写入默认生成 plan_id确认后才执行。",
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”飞书与智能体可不用结构化 mode 参数。",
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
"0.2.05": "新增低 token score_summary帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status并在未初始化时优先提示保存偏好。",
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state避免把已禁用但仍加载的旧插件误判为冲突。",
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议方便判断是否能从旧桥接迁移。",
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health支持内置智能助手检查飞书入口状态。",
"0.1.108": "内置可选飞书入口 Channel并为 assistant 回执补充 write_effect/error_code 标准字段。",
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
"0.1.106": "assistant/startup 会带 recommended_request_templates外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message方便外部智能体在写入前提示用户确认。",
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
"0.1.101": "推荐调用会带 url_template外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey避免外部智能体误用 Bearer 鉴权。",
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
"0.1.98": "recommended_recipe_detail 会带 first_call直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail直接给出推荐流程的首个模板、确认模板和写入模板外部智能体可直接照此编排。",
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason外部智能体不必再自己挑选最适合的 recipe。",
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds自检也会验证这些汇总特征。",
"0.1.94": "assistant/request_templates 回执会带场景化 recipes外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence直接给出推荐调用顺序外部智能体可以少做一层启动编排。",
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_secondsexecution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates方便外部智能体决定缓存策略。",
"0.1.91": "assistant/request_templates 支持 include_templates=false可只返回模板名、无效项和执行策略进一步减少 token。",
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1startup/toolbox 也携带 request_templates_schema_version方便外部智能体做兼容判断。",
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
"0.1.87": "request_templates 每个模板都会带 description外部智能体可以直接判断模板用途减少额外解释和 token 消耗。",
"0.1.86": "request_templates 每个模板都会带 tool_args区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit方便结构化智能体直接用 body 请求过滤模板。",
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names原生 Tool 同步支持 names 参数。",
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool外部智能体可只拉请求模板而不拉完整启动包。",
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body外部智能体无需猜维护调用方式。",
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history方便外部智能体审计维护动作GET dry-run 仍不写历史。",
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions外部智能体可直接判断是否值得做低风险维护清理。",
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all避免外部智能体把全部计划数误判为待执行计划数。",
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates外部智能体可拿启动包直接执行推荐恢复动作。",
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议减少外部智能体开场多次探测。",
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck便于外部智能体开场自检协议健康。",
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true外部智能体原样回放模板即可保持低 token。",
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan外部智能体可按恢复协议直接续跑。",
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session便于从会话列表直接恢复。",
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作action_templates.action_body 可原样回传执行计划。",
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
"0.1.40": "新增 assistant/history 与 history Tool记录最近批量动作和预设工作流执行摘要便于外部智能体判断进度、排障和恢复上下文。",
"0.1.39": "新增 assistant/readiness 与 readiness Tool外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示再决定是否开始执行。",
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
"0.1.37": "新增 assistant/actions 与 execute_actions Tool外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
"0.1.36": "新增 assistant/action 与 execute_action Tool外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates外部智能体可直接按返回模板继续调用不再自己拼下一步参数。",
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
"0.1.33": "新增活跃会话列表 API 与原生 Tool并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
"0.1.29": "新增 Agent影视助手 帮助 Tool并让统一智能入口在空输入或帮助语义下直接返回推荐用法降低 MP 智能助手首次调用门槛。",
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Toolsmart_entry / smart_pickMP 智能助手可直接复用飞书同款处理/选择主链。",
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool避免仍显示骨架态提示。",
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
"0.1.25": "新增 115 待处理任务标准 API查看、继续、取消便于飞书、CLI 与外部脚本直接调用。",
"0.1.24": "新增 115 待处理任务原生 Agent Tool查看、继续、取消MP 智能助手可直接调用待处理任务能力。",
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录可自动继续上次未完成的 115 操作。",
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
"0.1.13": "新增 115 扫码登录原生 Agent Tool智能助手可直接发起二维码并轮询登录状态。",
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本115 健康检查已验证可用。",
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API让 Agent影视助手 开始承接用户态能力。",
"0.1.6": "新增 Agent影视助手 自己的智能入口 API支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
"0.1.2": "新增原生 Agent Tool影巢会话搜索、会话继续选择、通用分享链接路由。",
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
"0.1.0": "首个可用版本已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
}
},
"FeishuCommandBridgeLong": {
"name": "飞书命令桥接",
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
"version": "0.5.26",
"icon": "feishucommandbridgelong.png",
"author": "liuyuexi1987",
"level": 1,
"history": {
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片也支持回复检查115登录继续轮询 Agent影视助手 会话。",
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手影巢/115/夸克开始走新主干。",
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
"0.5.0": "新增处理/选择双命令与智能体 API统一分流夸克链接、115 链接与影巢搜索解锁流程。",
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong修复插件市场安装状态匹配。",
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
}
},
"HdhiveOpenApi": {
"name": "影巢 OpenAPI",
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
"version": "0.3.0",
"icon": "hdhive.ico",
"author": "liuyuexi1987",
"level": 1,
"history": {
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
}
},
"QuarkShareSaver": {
"name": "夸克分享转存",
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
"version": "0.1.0",
"icon": "quark.ico",
"author": "liuyuexi1987",
"level": 1,
"history": {
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
"0.3.0": "精简 Agent/MCP 暴露工具集,仅保留核心业务能力工具,移除自建计划、会话和自描述脚手架噪声;保留飞书与 HTTP 端点。",
"0.2.99": "重做 Vue 配置页与 115 扫码体验,修复扫码登录、配置保存、依赖误判、影巢网页登录 Cookie、自动刷新和 Playwright 兜底等问题。",
"0.2.74": "收口云盘搜索、转存、影巢签到和恢复链路,适配影巢 OpenAPI 新鉴权,补齐 115/夸克目录清理、Cookie 修复、远端接入和兼容检查。",
"0.2.67": "沉淀外部智能体执行契约和低 token 模板,统一 followup、error、score、command 摘要,并完善执行后追踪、恢复、维护与模板编排。",
"0.2.43": "形成 MP 原生 PT 主线与智能评分推荐,新增媒体识别、热门推荐、搜索详情、下载/订阅/控制计划、生命周期追踪、入库诊断、偏好画像和安全确认链路。",
"0.1.119": "完善请求模板协议、启动包、自检、维护和飞书入口迁移,并内置影巢签到日志与 Cookie 自动刷新,降低外部智能体接入成本。",
"0.1.79": "建立 assistant 协议层,新增 action、workflow、plans、history、readiness、recover、startup、toolbox、selfcheck 等能力,支持 compact 回执、会话恢复和安全 dry-run 计划执行。",
"0.1.34": "打通统一智能入口、原生 Agent Tool、会话状态/清理、115 扫码登录、待处理任务恢复与 115 独立直转执行层。",
"0.1.9": "首版接入夸克/115 转存、影巢搜索/解锁、盘搜/直链路由、候选分页详情、账号配额、签到兜底和健康检查。"
}
},
"AutoAuction": {
@@ -1059,27 +806,32 @@
"name": "OIDC 认证",
"description": "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。",
"labels": "认证,OIDC,SSO",
"version": "0.1.0",
"icon": "Authelia_A.png",
"version": "0.3.1",
"icon": "Oidcauth_A.png",
"author": "ui-beam-9,jxxghp",
"level": 1,
"system_version": ">=2.13.5",
"release": true,
"history": {
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。"
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。",
"v0.2.0": "AuthPage 自动跳转 OIDC 授权,新增加载动画与错误重试;修复弹窗拦截提示及 PROXY_HOST 空值崩溃,补充配置表单指南。",
"v0.3.0": "重构双栏布局与动态背景,支持深浅主题自适应;新增绑定可视化、详情卡片及解绑确认;升级通信机制,新增特性介绍与底部信息栏,统一图标风格。",
"v0.3.1": "修复回调事件类型不匹配导致前端错误提示不准确;移除解绑方法多余检查,允许 OIDC 关闭状态下正常解绑。"
}
},
"AgentTokens": {
"name": "Agent Tokens 管理",
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
"labels": "Agent,AI,系统",
"version": "1.0.10",
"version": "1.0.12",
"icon": "agentresourceofficer.png",
"author": "jxxghp",
"level": 1,
"system_version": ">=2.13.2",
"release": true,
"history": {
"v1.0.12": "优化仪表板组件主题 token 适配和手动调整大小后的紧凑自适应布局",
"v1.0.11": "重设计仪表板组件,改为紧凑卡片式配额概览并对齐主仪表板视觉风格",
"v1.0.10": "新增供应商使用代理服务器配置,分配 Agent LLM 供应商时按配置传递代理开关",
"v1.0.9": "统一配置页和管理页内容,新增总使用进度图表卡片并优化大小屏布局",
"v1.0.8": "支持为 Agent LLM 供应商配置并传递 User-Agent",
@@ -1091,5 +843,68 @@
"v1.0.2": "修复UI界面显示不全及前端路由报错问题",
"v1.0.1": "新增 Agent Tokens 配额管理、供应商优先级切换和用量展示"
}
},
"TraktCleaner": {
"name": "Trakt 观看清理",
"description": "根据 Trakt 播放记录,自动清理下载器中已观看的种子。",
"labels": "Trakt,清理",
"version": "1.0",
"icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/trakt.png",
"author": "Guoyin-Wen",
"level": 1,
"history": {
"v1.0": "初始版本:根据 Trakt 播放记录自动清理下载器中已观看的种子"
}
},
"UpdateWeChatIp": {
"name": "动态企微可信IP",
"description": "修改企微应用可信IP,可本地扫码刷新Cookie,直接调用接口更稳定",
"labels": "消息通知",
"version": "1.0.8",
"icon": "Wecom_A.png",
"author": "书小白",
"level": 2,
"v2": true,
"history": {
"1.0.8": "完善日志输出",
"1.0.7": "插件初始化时调用一下check确定登录状态",
"1.0.6": "修复未登录时_party_cache_data为空导致UI崩溃的BUG\n图片地址优先使用MP_DOMAIN获取,如果未配置使用127.0.0.1地址\n回调解析qrcode_key时判断是否存在,不存在发送错误\n优化请求企微接口的参数",
"1.0.5": "根据Code Review结果优化代码",
"1.0.4": "增加IP更新记录查询",
"1.0.3": "cookie保活输出返回值",
"1.0.2": "支持多个应用ID",
"1.0.1": "IP更新时发送通知,增加API接口,指定更新的IP",
"1.0.0": "初始化"
}
},
"MaoyanRank": {
"name": "猫眼榜单订阅",
"description": "监控猫眼数据,自动添加订阅。",
"version": "3.1",
"icon": "https://raw.githubusercontent.com/baozaodetudou/MoviePilot-Plugins/main/icons/maoyan.jpg",
"color": "#fefefe",
"author": "逗猫",
"level": 1,
"system_version": ">=2.12.0",
"history": {
"v3.1": "优化详情页UI增加分页卡片与空值兜底避免历史记录显示None",
"v3.0": "适配MoviePilot浏览器助手修复Playwright浏览器可执行文件路径失配问题",
"v2.7": "增加优酷平台获取",
"v1.7": "更改榜单的排列组合支持多个平台同时订阅的功能",
"v1.6": "适配新的url获取",
"v1.5": "增加了条数配置选项支持1,2,3,5,7,10选择",
"v1.4": "增加支持了网络电影的订阅功能",
"v1.3": "取消猫眼榜单删除的平台",
"v1.2": "修改获取榜单的方法增加成功率",
"v1.1": "优化执行周期录入",
"v1.0": "修复使用缓存进行订阅搜索集数不全的问题",
"v0.7": "界面点击名称跳转TMDB",
"v0.6": "更改图标",
"v0.5": "更改爬取方式,提高成功率",
"v0.4": "电视剧订阅添加去重",
"v0.3": "增加平台分类",
"v0.2": "电视剧电影以及综艺的分类",
"v0.1": "初始化版本猫眼订阅功能"
}
}
}

View File

@@ -4,11 +4,13 @@
`飞书命令入口``外部智能体``盘搜``影巢``115``夸克``MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
当前版本:`0.2.71`
当前版本:`0.2.73`
当前 helper 版本:`0.1.51`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.71
当前已验证上游 MoviePilot`v2.11.4`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
如果你是第一次用这个仓库,先把这个插件跑通就够了。
@@ -19,7 +21,7 @@
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
- 你想让 `OpenClaw``Hermes``WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅` 放进同一套命令入口。
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口而是统一交给插件执行。
---
@@ -35,10 +37,10 @@
```text
盘搜搜索 片名
影巢搜索 片名
转存 片名
夸克转存 片名
搜索 片名
选择 1
下载 片名
更新检查 片名
订阅 片名
115登录
影巢签到
```
@@ -62,8 +64,8 @@
如果你的智能体客户端支持 MoviePilot 官方 MCP可以一起接。
- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、115/夸克转存、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、编号选择后的 115 / 夸克处理、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
- `MP搜索 / PT搜索 / 下载 / 订阅` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
MCP 地址通常是:
@@ -84,28 +86,21 @@ X-API-KEY=你的 MoviePilot API_TOKEN
| `盘搜搜索 <片名>` | 先查盘搜;盘搜没结果时按开关补查影巢 |
| `影巢搜索 <片名>` | 先查影巢;影巢没结果时按开关补查盘搜 |
| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 |
| `盘搜更新检查 <片名>` | 只看盘搜侧更新资源 |
| `影巢更新检查 <片名>` | 只看影巢侧更新资源 |
补充:
- `搜索 第 3 集``搜索 E03` 这类带集数线索的写法,会直接按 MP/PT 搜索,不再回退到云盘。
- `检查 大君夫人``检查大君夫人` 这类写法,会按更新检查处理;但 `检查115登录` 仍然保留为 115 登录检查
- `更新检查 xx 剧` / `检查 xx 剧` 这类带剧集意图的写法,会按 MP/PT 搜索;云盘侧更新检查请显式使用 `盘搜更新检查``影巢更新检查`
- `更新检查 <片名>``查更新 <片名>``检查 <片名>` 这些旧写法已并回搜索语义;现在直接用 `搜索 / 盘搜搜索 / 影巢搜索 / MP搜索 / PT搜索` 即可
- `检查115登录` 仍然保留为 115 登录检查,不受这次简化影响
### 转存 / 下载
### 下载
| 命令 | 作用 |
|---|---|
| `转存 <片名>` | 默认等同 `115转存 <片名>` |
| `115转存 <片名>` | 搜索后优先转存到 115 |
| `夸克转存 <片名>` | 搜索后优先转存到夸克 |
| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先找片并列出 PT 候选 |
注意:
- `转存 <片名>` 默认是 115不会自动改成夸克
- 只有明确说 `夸克转存 <片名>` 才走夸克。
- 标题级 `转存 <片名>` / `115转存 <片名>` / `夸克转存 <片名>` 已取消;搜索结果出来后按编号继续处理
- `下载 <片名>` 是 PT 下载,不是云盘转存。
- PT 搜索结果里直接回编号会立即下载。
- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。
@@ -138,7 +133,6 @@ n
- `云盘搜索` 已废弃,收到后只会提示改用 `盘搜搜索` / `影巢搜索`
- 115 转存
- 夸克转存
- 更新检查
- 编号选择、详情、翻页
- 智能建议与候选推荐

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
.aro-config[data-v-eb2e8235] {
display: flex;
flex-direction: column;
max-height: 82vh;
}
.aro-toolbar[data-v-eb2e8235] {
flex: 0 0 auto;
}
.aro-body[data-v-eb2e8235] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 16px;
}
.aro-inner[data-v-eb2e8235] {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.aro-intro[data-v-eb2e8235] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.06);
color: rgb(var(--v-theme-on-surface));
line-height: 1.5;
}
.aro-card-head[data-v-eb2e8235] {
padding-bottom: 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import Config from './__federation_expose_Config-SJKIC-xp.js';
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');
const _sfc_main = {
__name: 'Page',
props: {
api: {
type: Object,
default: () => ({}),
},
initialConfig: {
type: Object,
default: () => ({}),
},
},
emits: ['save', 'close'],
setup(__props, { emit: __emit }) {
const emit = __emit;
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(Config, {
api: __props.api,
"initial-config": __props.initialConfig,
onSave: _cache[0] || (_cache[0] = payload => emit('save', payload)),
onClose: _cache[1] || (_cache[1] = $event => (emit('close')))
}, null, 8, ["api", "initial-config"]))
}
}
};
export { _sfc_main as default };

View File

@@ -1,5 +1,5 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_AppPage-CiS2QwqR.js';
import _sfc_main from './__federation_expose_Page-DAJ1MzFo.js';
true&&(function polyfill() {
const relList = document.createElement("link").relList;

View File

@@ -0,0 +1,84 @@
const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Config":()=>{
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Config');
return __federation_import('./__federation_expose_Config-SJKIC-xp.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Page');
return __federation_import('./__federation_expose_Page-DAJ1MzFo.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;
if (typeof metaUrl === 'undefined') {
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
return;
}
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
const base = '/';
'assets';
cssFilePaths.forEach(cssPath => {
let href = '';
const baseUrl = base || curUrl;
if (baseUrl) {
const trimmer = {
trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path),
leading: (path) => (path.startsWith('/') ? path.slice(1) : path)
};
const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//');
const cleanBaseUrl = trimmer.trailing(baseUrl);
const cleanCssPath = trimmer.leading(cssPath);
const cleanCurUrl = trimmer.trailing(curUrl);
if (isAbsoluteUrl(baseUrl)) {
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
} else {
if (cleanCurUrl.includes(cleanBaseUrl)) {
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/');
} else {
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
}
}
} else {
href = cssPath;
}
if (dontAppendStylesToHead) {
const key = 'css__AgentResourceOfficer__' + exposeItemName;
window[key] = window[key] || [];
window[key].push(href);
return;
}
if (href in seen) return;
seen[href] = true;
const element = document.createElement('link');
element.rel = 'stylesheet';
element.href = href;
document.head.appendChild(element);
});
};
async function __federation_import(name) {
currentImports[name] ??= import(name);
return currentImports[name]
} const get =(module) => {
if(!moduleMap[module]) throw new Error('Can not find remote module ' + module)
return moduleMap[module]();
};
const init =(shareScope) => {
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
Object.entries(shareScope).forEach(([key, value]) => {
for (const [versionKey, versionValue] of Object.entries(value)) {
const scope = versionValue.scope || 'default';
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
const shared= globalThis.__federation_shared__[scope];
(shared[key] = shared[key]||{})[versionKey] = versionValue;
}
});
};
export { dynamicLoadingCss, get, init };

View File

@@ -0,0 +1,6 @@
<script type="module" crossorigin src="/assets/index-WG_aDWmR.js"></script>
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Config-SJKIC-xp.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Page-DAJ1MzFo.js">
<link rel="stylesheet" crossorigin href="/assets/__federation_expose_Config-DenBkx3K.css">
<div id="app"></div>

View File

@@ -15,9 +15,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
from jieba_next import cut as jieba_cut
import jieba
except Exception:
jieba_cut = None
jieba = None
for _site_path in (
"/usr/local/lib/python3.12/site-packages",
@@ -35,49 +35,39 @@ _LARK_IMPORT_LOCK = threading.Lock()
_LARK_AUTO_INSTALL_ATTEMPTED = False
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
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
def _optional_import(module_name: str, attr_name: str) -> Any:
try:
module = importlib.import_module(module_name)
return getattr(module, attr_name)
except Exception:
return None
DownloadChain = _optional_import("app.chain.download", "DownloadChain")
DownloadHistoryOper = _optional_import("app.db.downloadhistory_oper", "DownloadHistoryOper")
DownloadHistory = _optional_import("app.db.models.downloadhistory", "DownloadHistory")
TransferHistory = _optional_import("app.db.models.transferhistory", "TransferHistory")
MediaChain = _optional_import("app.chain.media", "MediaChain")
SearchChain = _optional_import("app.chain.search", "SearchChain")
SiteOper = _optional_import("app.db.site_oper", "SiteOper")
SubscribeChain = _optional_import("app.chain.subscribe", "SubscribeChain")
SubscribeHelper = _optional_import("app.helper.subscribe", "SubscribeHelper")
SubscribeOper = _optional_import("app.db.subscribe_oper", "SubscribeOper")
SystemConfigOper = _optional_import("app.db.systemconfig_oper", "SystemConfigOper")
eventmanager = _optional_import("app.core.event", "eventmanager")
MetaInfo = _optional_import("app.core.metainfo", "MetaInfo")
PluginManager = _optional_import("app.core.plugin", "PluginManager")
Scheduler = _optional_import("app.scheduler", "Scheduler")
EventType = _optional_import("app.schemas.types", "EventType")
SystemConfigKey = _optional_import("app.schemas.types", "SystemConfigKey")
TorrentStatus = _optional_import("app.schemas.types", "TorrentStatus")
media_type_to_agent = _optional_import("app.schemas.types", "media_type_to_agent")
RequestUtils = _optional_import("app.utils.http", "RequestUtils")
StringUtils = _optional_import("app.utils.string", "StringUtils")
try:
from app.log import logger
except Exception:
class _FallbackLogger:
@staticmethod
def info(message: str) -> None:
@@ -1350,9 +1340,9 @@ class FeishuChannel:
status_bool = self._transfer_status_bool(status)
title_text = str(title or "").strip()
search_text = title_text
if title_text and jieba_cut is not None:
if title_text and jieba is not None:
try:
search_text = "%".join(jieba_cut(title_text, HMM=False))
search_text = "%".join(jieba.cut(title_text, HMM=False))
except Exception:
search_text = title_text
@@ -1449,18 +1439,33 @@ class FeishuChannel:
return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。"
meta = MetaInfo(keyword)
try:
save_path = ""
if self.plugin is not None:
save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip()
subscribe_kwargs = {
"title": keyword,
"year": meta.year,
"mtype": meta.type,
"season": meta.begin_season,
"username": "agentresourceofficer-feishu",
"exist_ok": True,
"message": False,
}
if save_path:
subscribe_kwargs["save_path"] = save_path
sid, message = SubscribeChain().add(
title=keyword,
year=meta.year,
mtype=meta.type,
season=meta.begin_season,
username="agentresourceofficer-feishu",
exist_ok=True,
message=False,
**subscribe_kwargs,
)
if not sid:
return f"订阅失败:{keyword}\n原因:{message}"
if save_path and SubscribeOper is not None:
try:
SubscribeOper().update(sid, {"save_path": save_path})
except Exception as exc:
logger.warning(f"[AgentResourceOfficer][Feishu] 同步订阅保存路径失败sid={sid} {exc}")
lines = [f"已创建订阅:{keyword}", f"订阅ID{sid}", f"结果:{message}"]
if save_path:
lines.append(f"保存路径:{save_path}")
if immediate_search and Scheduler is not None:
Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True})
lines.append("已触发一次订阅搜索。")
@@ -1775,7 +1780,7 @@ class FeishuChannel:
"5. 转存 片名(默认 115\n"
"6. 夸克转存 片名\n"
"7. 下载 片名\n"
"8. 更新检查 片名\n"
"8. 订阅 片名\n"
"9. 选择 序号 / 详情 序号 / n\n"
"10. 115登录 / 115状态 / 115任务\n"
"11. 影巢签到 / 影巢签到日志"

View File

@@ -0,0 +1,2 @@
<div id="app"></div>
<script type="module" src="/src/main.js"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,14 @@
{
"name": "moviepilot-oidcauth-plugin",
"name": "moviepilot-agent-resource-officer-plugin",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13"
"vue": "^3.5.13",
"vuetify": "3.7.3"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",

View File

@@ -1,4 +1,4 @@
requests
cloudscraper
lark-oapi>=1.4.0
p115client==0.0.8.4.8
p115client==0.0.8.6.4

View File

@@ -0,0 +1,396 @@
"""
影巢HDHive网页方式资源搜索/解锁服务。
通过 MoviePilot 官方 app.helper.browser.PlaywrightHelpercloakbrowser 后端,
内置反检测与 FlareSolverr用账号 cookie 在影巢网页上搜索资源、解锁拿 115 链接,
不依赖影巢 OpenAPI。仅在 MoviePilot docker 容器内 headless 运行。
本模块的页面抓取/解锁 JavaScript 与流程改编自 GPL v3 项目
DDSRem-Dev/MoviePilot-Plugins (plugins.v2/p115strmhelper/helper/hdhive/browser.py)。
原仓库: https://github.com/DDSRem-Dev/MoviePilot-Plugins
本仓库同为 GPL v3。
"""
from __future__ import annotations
import concurrent.futures
import re
import time
from typing import Any, Callable, Dict, List, Optional
from app.helper.browser import PlaywrightHelper
from app.log import logger
# 改编自 DDSRem-Dev p115strmhelper browser.py::_scrape_resource_cards_js (GPL v3)
_SCRAPE_CARDS_JS = r"""
() => {
const sizeRe = /(\d+\.?\d*)\s*(TB|GB|MB|G(?!B)|M(?!B))\b/i;
const dateRe = /发布于\s*([\d/\-]+)/;
const resRe = /\b(4K|8K|2K|1080[piP]?|720[piP]?|480[piP]?)\b/;
const pointsRe = /(\d+)\s*积分/;
const candidates = [];
for (const el of document.querySelectorAll('a,div,article,li,section')) {
const t = el.innerText || '';
if (!t.includes('发布于') || !sizeRe.test(t)) continue;
if ((t.match(/发布于/g) || []).length !== 1) continue;
if (t.length < 30 || t.length > 5000) continue;
candidates.push(el);
}
const minimal = candidates.filter(
el => !candidates.some(other => other !== el && el.contains(other))
);
const metaTerms = new Set([
'4K','8K','2K','免费','官组','管理员','WEB-DL','WEBRip','BDRip','REMUX','HDTV',
'简中','繁中','简英','繁英','内封','外挂','内嵌','简日','繁日','简韩','繁韩',
'1080P','1080p','720P','720p','480P','480p','蓝光原盘','ISO'
]);
return minimal.map(card => {
const text = card.innerText || '';
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const dateMatch = text.match(dateRe);
const sizeMatch = text.match(sizeRe);
const resMatch = text.match(resRe);
const pointsMatch = text.match(pointsRe);
const isFree = text.includes('免费');
const tags = [];
if (text.includes('官组') || text.includes('管理员')) tags.push('官组');
if (isFree) tags.push('免费');
if (pointsMatch) tags.push(pointsMatch[0].trim());
const dateLineIdx = lines.findIndex(l => /发布于/.test(l));
const user = dateLineIdx > 0 ? lines[dateLineIdx - 1] : (lines[0] || '');
const titleLines = lines.filter(l => {
if (l.length < 3) return false;
if (metaTerms.has(l)) return false;
if (/^发布于/.test(l)) return false;
if (/^\d+\s*积分$/.test(l)) return false;
if (/^\d+\.?\d*\s*(T?B|G[Bi]?|M[Bi]?)$/i.test(l)) return false;
if (l === user) return false;
return true;
});
let title = titleLines
.map(l => l.replace(/^\d+\s*积分\s*/, '').trim())
.filter(Boolean).join(' ').trim();
let hrefEl = card;
while (hrefEl && hrefEl.tagName !== 'A') { hrefEl = hrefEl.parentElement; }
const href = hrefEl ? (hrefEl.getAttribute('href') || '') : '';
return {
user, posted_at: dateMatch ? dateMatch[1] : '', tags, title,
resolution: resMatch ? resMatch[1] : '',
size: sizeMatch ? (sizeMatch[1] + ' ' + sizeMatch[2].toUpperCase()) : '',
is_free: isFree,
unlock_points: isFree ? 0 : (pointsMatch ? parseInt(pointsMatch[1]) : null),
href,
};
});
}
"""
# 改编自 DDSRem-Dev p115strmhelper browser.py::unlock_resource 内 _EXTRACT_URL_JS (GPL v3)
_EXTRACT_115_URL_JS = r"""
() => {
const urlPrefixRe = /^https?:\/\/(115cdn|115)\.com\//;
for (const el of document.querySelectorAll('input')) {
const v = (el.value || '').trim();
if (urlPrefixRe.test(v)) return v;
}
for (const el of document.querySelectorAll('div, span, p, a, code')) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (urlPrefixRe.test(t)) return t;
}
const m = (document.body?.innerText || '').match(/https?:\/\/(115cdn|115)\.com\/\S+/);
return m ? m[0].replace(/\s+$/, '') : null;
}
"""
class HDHiveBrowserService:
def __init__(
self,
base_url: str = "https://hdhive.com",
cookie: str = "",
timeout: int = 30,
cookie_refresh_callback: Optional[Callable[[], str]] = None,
) -> None:
self.base_url = (base_url or "https://hdhive.com").rstrip("/")
self.cookie = (cookie or "").strip()
self.timeout = int(timeout or 30)
self.cookie_refresh_callback = cookie_refresh_callback
def is_ready(self) -> bool:
return bool(self.cookie)
@staticmethod
def _cookie_expired_result() -> Dict[str, str]:
return {"__hdhive_browser_error__": "cookie_expired"}
@staticmethod
def _is_cookie_expired_result(value: Any) -> bool:
return isinstance(value, dict) and value.get("__hdhive_browser_error__") == "cookie_expired"
def _refresh_cookie(self) -> str:
if not self.cookie_refresh_callback:
return ""
try:
cookie = self.cookie_refresh_callback()
except Exception as exc:
logger.warning(f"[HDHiveBrowser] 自动刷新 Cookie 失败: {exc}")
return ""
cookie = str(cookie or "").strip()
if cookie:
self.cookie = cookie
return cookie
def _context_cookies(self) -> List[Dict[str, str]]:
items: List[Dict[str, str]] = []
for part in str(self.cookie or "").split(";"):
if "=" not in part:
continue
name, value = part.strip().split("=", 1)
name = name.strip()
value = value.strip()
if name and value:
items.append({"name": name, "value": value, "url": f"{self.base_url}/"})
return items
def _detail_url(self, media_type: Any, tmdb_id: Any) -> str:
mt = "movie" if str(media_type).lower() in ("movie", "电影") else "tv"
return f"{self.base_url}/tmdb/{mt}/{tmdb_id}"
@staticmethod
def _normalize(card: Dict[str, Any]) -> Dict[str, Any]:
href = (card.get("href") or "").strip()
slug = href.rstrip("/").split("/")[-1] if href else ""
return {
"slug": slug,
"href": href,
"title": card.get("title", ""),
"resolution": card.get("resolution", ""),
"size": card.get("size", ""),
"is_free": bool(card.get("is_free")),
"unlock_points": card.get("unlock_points"),
"user": card.get("user", ""),
"posted_at": card.get("posted_at", ""),
"tags": card.get("tags", []),
}
def _run_browser_action(self, url: str, callback: Any) -> Any:
"""Run MoviePilot's sync Playwright helper outside the active async request loop."""
helper_timeout = max(60, self.timeout)
def _callback_with_context_cookies(page: Any) -> Any:
context_cookies = self._context_cookies()
if context_cookies:
page.context.add_cookies(context_cookies)
page.goto(url)
page.wait_for_load_state("networkidle", timeout=helper_timeout * 1000)
return callback(page)
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="hdhive-browser")
future = executor.submit(
lambda: PlaywrightHelper().action(
url,
callback=_callback_with_context_cookies,
timeout=helper_timeout,
)
)
try:
return future.result(timeout=helper_timeout + 30)
except concurrent.futures.TimeoutError as exc:
future.cancel()
raise RuntimeError(f"影巢网页操作超时({helper_timeout} 秒)") from exc
finally:
executor.shutdown(wait=False, cancel_futures=True)
def search(self, media_type: Any, tmdb_id: Any) -> List[Dict[str, Any]]:
"""打开影巢详情页抓资源卡片。失败返回 []。"""
url = self._detail_url(media_type, tmdb_id)
def _callback(page: Any) -> List[Dict[str, Any]]:
cards: List[Dict[str, Any]] = []
deadline = time.time() + 10
while time.time() < deadline:
try:
if "/login" in (page.url or ""):
return self._cookie_expired_result()
cards = page.evaluate(_SCRAPE_CARDS_JS) or []
except RuntimeError:
raise
except Exception:
cards = []
if cards:
break
page.wait_for_timeout(500)
return cards
try:
cards = self._run_browser_action(url, _callback)
except Exception as exc:
logger.warning(f"[HDHiveBrowser] 搜索失败({url}): {exc}")
return []
if self._is_cookie_expired_result(cards):
raise RuntimeError("cookie 失效,被重定向到登录页")
return [self._normalize(c) for c in (cards or []) if c.get("href")]
def unlock(self, slug: str) -> Dict[str, Any]:
"""解锁资源,返回 {'url','already_owned'}。失败抛 RuntimeError。"""
if not slug:
raise RuntimeError("缺少资源 slug")
url = f"{self.base_url}/resource/115/{slug}"
def _callback(page: Any) -> Dict[str, Any]:
captured: Dict[str, Optional[str]] = {"url": None}
def _on_response(response: Any) -> None:
try:
if response.status != 200:
return
if "json" not in response.headers.get("content-type", ""):
return
body = response.json()
if not isinstance(body, dict):
return
data = body.get("data") or {}
if not isinstance(data, dict):
return
for key in ("full_url", "url", "link", "resource_url"):
val = data.get(key)
if val and re.search(r"(115cdn|115)\.com", str(val)):
captured["url"] = str(val).strip()
break
except Exception:
pass
page.on("response", _on_response)
if "/login" in (page.url or ""):
return self._cookie_expired_result()
confirm = page.get_by_text("确定解锁", exact=True)
existing: Optional[str] = None
has_confirm = False
deadline = time.time() + 15
while time.time() < deadline:
try:
existing = page.evaluate(_EXTRACT_115_URL_JS)
except Exception:
existing = None
if existing:
break
try:
if confirm.first.is_visible():
has_confirm = True
break
except Exception:
pass
page.wait_for_timeout(500)
if existing:
return {"url": existing, "already_owned": True}
if not has_confirm:
raise RuntimeError(f"未找到「确定解锁」按钮或链接URL: {page.url}")
confirm.first.click()
deadline = time.time() + 20
while time.time() < deadline:
if captured["url"]:
return {"url": captured["url"], "already_owned": False}
if re.search(r"(115cdn|115)\.com", page.url or ""):
return {"url": page.url, "already_owned": False}
try:
extracted = page.evaluate(_EXTRACT_115_URL_JS)
except Exception:
extracted = None
if extracted:
return {"url": extracted, "already_owned": False}
page.wait_for_timeout(500)
raise RuntimeError(f"解锁后未获取 115 链接URL: {page.url}")
result = self._run_browser_action(url, _callback)
if self._is_cookie_expired_result(result):
raise RuntimeError("cookie 失效,被重定向到登录页")
return result
# ----- 与 HDHiveOpenApiService 对齐的兼容接口(返回 (ok, result, message) 三元组) -----
@staticmethod
def _norm_media_type(media_type: Any) -> str:
mt = str(media_type or "").strip().lower()
if mt in ("movie", "电影"):
return "movie"
if mt in ("tv", "电视剧"):
return "tv"
return mt
def search_resources(self, media_type: Any, tmdb_id: Any) -> tuple:
"""与 HDHiveOpenApiService.search_resources 同签名/同返回结构(网页方式)。"""
mt = self._norm_media_type(media_type)
tid = str(tmdb_id or "").strip()
query = {"media_type": mt, "tmdb_id": tid}
if mt not in ("movie", "tv"):
return False, {"ok": False, "message": "媒体类型必须是 movie 或 tv", "query": query, "data": []}, "媒体类型必须是 movie 或 tv"
if not tid:
return False, {"ok": False, "message": "TMDB ID 不能为空", "query": query, "data": []}, "TMDB ID 不能为空"
if not self.is_ready() and not self._refresh_cookie():
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "query": query, "data": []}, "影巢网页 Cookie 未配置"
try:
items = self.search(mt, tid)
except Exception as exc:
message = str(exc)
if "cookie 失效" in message and self._refresh_cookie():
try:
items = self.search(mt, tid)
except Exception as retry_exc:
message = str(retry_exc)
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
else:
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
data = [
{
"slug": it.get("slug", ""),
"title": it.get("title", ""),
"name": it.get("title", ""),
"unlock_points": it.get("unlock_points"),
"size": it.get("size", ""),
"resolution": it.get("resolution", ""),
"is_free": it.get("is_free", False),
"user": it.get("user", ""),
"posted_at": it.get("posted_at", ""),
"tags": it.get("tags", []),
"source": "hdhive_browser",
}
for it in items
]
msg = "success" if data else "影巢网页方式未找到资源"
result = {
"ok": bool(data),
"message": msg,
"query": query,
"data": data,
"meta": {"total": len(data)},
"source": "hdhive_browser",
}
return bool(data), result, msg
def unlock_resource(self, slug: str) -> tuple:
"""与 HDHiveOpenApiService.unlock_resource 同签名/同返回结构(网页方式)。"""
slug = (slug or "").strip()
if not slug:
return False, {"ok": False, "message": "slug 不能为空", "slug": "", "data": {}}, "slug 不能为空"
if not self.is_ready() and not self._refresh_cookie():
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "slug": slug, "data": {}}, "影巢网页 Cookie 未配置"
try:
res = self.unlock(slug)
except Exception as exc:
message = str(exc)
if "cookie 失效" in message and self._refresh_cookie():
try:
res = self.unlock(slug)
except Exception as retry_exc:
message = str(retry_exc)
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
else:
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
link = (res.get("url") or "").strip()
data = {"full_url": link, "url": link, "pan_type": "115"}
msg = "success" if link else "影巢网页方式解锁失败"
return bool(link), {"ok": bool(link), "message": msg, "slug": slug, "data": data, "source": "hdhive_browser"}, msg

View File

@@ -8,6 +8,11 @@ from zoneinfo import ZoneInfo
import requests
try:
from app.helper.browser import PlaywrightHelper
except Exception:
PlaywrightHelper = None
try:
from app.chain.media import MediaChain
except Exception:
@@ -32,17 +37,35 @@ class HDHiveOpenApiService:
_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"
# Meta endpoints that only require app-level X-API-Key auth.
_META_ENDPOINT_PREFIXES: Tuple[str, ...] = (
"/api/open/ping",
"/api/open/quota",
"/api/open/usage",
"/api/open/usage/today",
)
# Refresh endpoint path per HDHive documented OpenAPI contract.
_REFRESH_ENDPOINT = "/api/public/openapi/oauth/refresh"
def __init__(
self,
*,
api_key: str = "",
base_url: str = "https://hdhive.com",
timeout: int = 30,
openapi_user_token: str = "",
openapi_refresh_token: str = "",
) -> 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.openapi_user_token = self.normalize_text(openapi_user_token)
self.openapi_refresh_token = self.normalize_text(openapi_refresh_token)
self._login_action_id = ""
self._in_refresh_retry = False
def _is_meta_endpoint(self, path: str) -> bool:
return any(path.startswith(prefix) for prefix in self._META_ENDPOINT_PREFIXES)
@staticmethod
def safe_int(value: Any, default: int) -> int:
@@ -187,15 +210,29 @@ class HDHiveOpenApiService:
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout: Optional[int] = None,
require_user_auth: Optional[bool] = None,
) -> Tuple[bool, Dict[str, Any], str, int]:
if not self.api_key:
return False, {}, "未配置影巢 API Key", 400
# Auto-detect: meta endpoints don't need user auth; business endpoints do.
needs_user_auth = require_user_auth if require_user_auth is not None else not self._is_meta_endpoint(path)
if needs_user_auth and not self.openapi_user_token:
return False, {}, (
"当前影巢 OpenAPI 业务接口需要用户授权令牌Bearer Token"
"但未配置 hdhive_openapi_user_token。请先在插件配置中填入 OpenAPI 用户 Access Token"
"或通过 OAuth 流程获取后填入。"
), 401
headers = self.base_headers()
if needs_user_auth and self.openapi_user_token:
headers["Authorization"] = f"Bearer {self.openapi_user_token}"
try:
response = requests.request(
method=method.upper(),
url=self.api_url(path),
headers=self.base_headers(),
headers=headers,
params=params,
json=payload if payload is not None else None,
timeout=timeout or self.timeout,
@@ -216,6 +253,25 @@ class HDHiveOpenApiService:
if response.ok and isinstance(result, dict) and result.get("success", True):
return True, result, "", response.status_code
# If a business request fails with 401/403 and we have a refresh token, try once.
if (
needs_user_auth
and response.status_code in (401, 403)
and self.openapi_refresh_token
and not self._in_refresh_retry
):
refresh_ok = self._try_refresh_user_token()
if refresh_ok:
self._in_refresh_retry = True
try:
return self.request(
method, path,
params=params, payload=payload, timeout=timeout,
require_user_auth=True,
)
finally:
self._in_refresh_retry = False
message = ""
if isinstance(result, dict):
message = (
@@ -228,6 +284,40 @@ class HDHiveOpenApiService:
message = f"HTTP {response.status_code}"
return False, result if isinstance(result, dict) else {}, message, response.status_code
def _try_refresh_user_token(self) -> bool:
if not self.openapi_refresh_token:
return False
try:
response = requests.post(
url=self.api_url(self._REFRESH_ENDPOINT),
headers=self.base_headers(),
json={"refresh_token": self.openapi_refresh_token},
timeout=self.timeout,
proxies=getattr(settings, "PROXY", None) if settings is not None else None,
)
if response.status_code != 200:
return False
data = response.json()
if not isinstance(data, dict) or not data.get("success", True):
return False
meta = data.get("data") if isinstance(data.get("data"), dict) else {}
new_access = self.normalize_text(meta.get("access_token") or meta.get("token"))
new_refresh = self.normalize_text(meta.get("refresh_token")) or self.openapi_refresh_token
if not new_access:
return False
self.openapi_user_token = new_access
self.openapi_refresh_token = new_refresh
return True
except Exception:
return False
def auth_status(self) -> Dict[str, Any]:
return {
"api_key_configured": bool(self.api_key),
"user_token_configured": bool(self.openapi_user_token),
"refresh_token_configured": bool(self.openapi_refresh_token),
}
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")
@@ -533,13 +623,21 @@ class HDHiveOpenApiService:
@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()
normalized = {str(key or "").strip(): str(value or "").strip() for key, value in (cookies or {}).items()}
token_cookie = normalized.get("token", "")
if not token_cookie:
return ""
cookie_items = [f"token={token_cookie}"]
if csrf_cookie:
cookie_items.append(f"csrf_access_token={csrf_cookie}")
preferred_order = ["hdh_sa_token", "token", "refresh_token", "csrf_access_token", "hdh_uid"]
cookie_items: List[str] = []
seen: set[str] = set()
for name in preferred_order:
value = normalized.get(name, "")
if value:
cookie_items.append(f"{name}={value}")
seen.add(name)
for name, value in normalized.items():
if name and value and name not in seen:
cookie_items.append(f"{name}={value}")
return "; ".join(cookie_items)
@classmethod
@@ -719,77 +817,92 @@ class HDHiveOpenApiService:
else:
server_action_message = "未解析到登录 Action"
try:
from cloakbrowser import launch_context
except Exception:
return False, "", server_action_message or "自动登录失败,且 CloakBrowser 不可用"
if PlaywrightHelper is None:
return False, "", server_action_message or "自动登录失败,且 MoviePilot PlaywrightHelper 不可用"
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
context = None
try:
context = launch_context(headless=True, proxy=proxy)
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
def _login_with_page(page: Any) -> List[Dict[str, Any]]:
for selector in [
"input[name='username']",
"input[name='email']",
"input[type='email']",
"input[placeholder*='邮箱']",
"input[placeholder*='email']",
"input[placeholder*='用户名']",
]:
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")
if page.query_selector(selector):
page.fill(selector, username)
break
except Exception:
page.keyboard.press("Enter")
continue
for selector in [
"input[name='password']",
"input[type='password']",
"input[placeholder*='密码']",
]:
try:
page.wait_for_load_state("networkidle", timeout=10000)
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.click("button")
except Exception:
try:
page.click("button")
except Exception:
pass
cookies = context.cookies()
finally:
if context:
context.close()
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
deadline = self.tz_now().timestamp() + 15
while self.tz_now().timestamp() < deadline:
try:
cookies = page.context.cookies()
if any(str(item.get("name") or "") == "token" and item.get("value") for item in cookies):
return cookies
except Exception:
pass
try:
if "/login" not in (page.url or ""):
page.wait_for_timeout(1000)
except Exception:
pass
try:
page.wait_for_timeout(500)
except Exception:
break
try:
return page.context.cookies()
except Exception:
return []
try:
proxy_config = getattr(settings, "PROXY", None) if settings is not None else None
cookies = PlaywrightHelper().action(
login_url,
callback=_login_with_page,
proxies=proxy_config,
headless=True,
timeout=max(30, self.timeout),
) or []
except Exception as exc:
return False, "", f"CloakBrowser 自动登录失败: {exc}"
return False, "", f"PlaywrightHelper 自动登录失败: {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, "CloakBrowser 登录成功"
return True, cookie_string, "PlaywrightHelper 登录成功"
return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie"
@classmethod

View File

@@ -0,0 +1,735 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CLIENT_TYPES, cloneConfig, maskSecret, unwrapResponse } from '../provider'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'AgentResourceOfficer',
},
initialConfig: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['save', 'close'])
const config = ref({})
const message = reactive({ text: '', type: 'info' })
const showCookie = ref(false)
const showFeishuSecret = ref(false)
const showHdhiveApiKey = ref(false)
const showHdhiveAccessToken = ref(false)
const showHdhiveRefreshToken = ref(false)
const showHdhiveCookie = ref(false)
const showHdhivePassword = ref(false)
const saving = ref(false)
const healthLoading = ref(false)
const health = ref(null)
const qr = reactive({
show: false,
loading: false,
error: '',
qrcode: '',
uid: '',
time: '',
sign: '',
tips: '请使用 115 客户端扫描二维码登录',
status: '等待扫码',
clientType: 'alipaymini',
timer: null,
requestId: 0,
checking: false,
})
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentResourceOfficer'}`)
const p115ReadyText = computed(() => {
if (!health.value) return config.value.p115_cookie ? '已配置 Cookie' : '未检测'
if (health.value.p115_ready) return '115 可用'
return health.value.message || '115 未就绪'
})
function enableChip(value) {
return value
? { text: '已启用', color: 'success' }
: { text: '未启用', color: 'grey' }
}
function showMessage(text, type = 'info') {
message.text = text
message.type = type
if (text) {
setTimeout(() => {
if (message.text === text) message.text = ''
}, 3500)
}
}
async function persistConfig({ silent = false } = {}) {
saving.value = true
try {
const response = await withTimeout(
props.api.post(`${pluginBase.value}/config/save`, cloneConfig(config.value)),
12000,
'保存配置超时,请稍后重试'
)
const result = unwrapResponse(response)
if (!result?.success) {
throw new Error(result?.message || '保存配置失败')
}
if (result.data) {
config.value = cloneConfig(result.data)
}
emit('save', cloneConfig(config.value))
if (!silent) showMessage(result.message || '配置已保存', 'success')
return true
} catch (err) {
if (!silent) showMessage(err?.message || '保存配置失败', 'error')
return false
} finally {
saving.value = false
}
}
function saveConfig() {
persistConfig()
}
async function copyText(value, label) {
try {
await navigator.clipboard.writeText(String(value || ''))
showMessage(`${label} 已复制`, 'success')
} catch (err) {
showMessage('复制失败,请手动复制', 'error')
}
}
function clearQrTimer() {
if (qr.timer) {
clearInterval(qr.timer)
qr.timer = null
}
}
function applyQrData(data) {
qr.qrcode = data?.qrcode || ''
qr.uid = data?.uid || ''
qr.time = data?.time || ''
qr.sign = data?.sign || ''
qr.tips = data?.tips || '请使用 115 客户端扫描二维码登录'
qr.status = '等待扫码'
}
function withTimeout(promise, ms, message) {
let timeoutId
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId))
}
async function requestQrCode() {
const requestId = qr.requestId + 1
qr.requestId = requestId
qr.loading = true
qr.error = ''
qr.qrcode = ''
qr.uid = ''
qr.time = ''
qr.sign = ''
clearQrTimer()
try {
const response = await withTimeout(
props.api.get(`${pluginBase.value}/p115/ui/qrcode?client_type=${encodeURIComponent(qr.clientType)}`),
12000,
'获取二维码超时,请稍后重试'
)
if (requestId !== qr.requestId || !qr.show) return
const result = unwrapResponse(response)
if (!result?.success || !result?.data) {
throw new Error(result?.message || '获取二维码失败')
}
applyQrData(result.data)
qr.timer = setInterval(() => checkQrCode(requestId), 3000)
} catch (err) {
if (requestId !== qr.requestId) return
qr.error = err?.message || '获取二维码失败'
qr.status = '二维码获取失败'
} finally {
if (requestId === qr.requestId) {
qr.loading = false
}
}
}
async function checkQrCode(requestId = qr.requestId) {
if (!qr.show || !qr.uid || !qr.time || !qr.sign) return
if (requestId !== qr.requestId || qr.checking) return
qr.checking = true
try {
const query = new URLSearchParams({
uid: qr.uid,
time: qr.time,
sign: qr.sign,
client_type: qr.clientType,
})
const response = await withTimeout(
props.api.get(`${pluginBase.value}/p115/ui/qrcode/check?${query.toString()}`),
10000,
'检查二维码状态超时'
)
if (requestId !== qr.requestId || !qr.show) return
const result = unwrapResponse(response)
const data = result?.data || {}
if (!result?.success) {
if (data.status === 'expired') {
clearQrTimer()
qr.status = '二维码已失效'
qr.error = result?.message || '二维码已失效,请刷新'
}
return
}
if (data.status === 'waiting') qr.status = '等待扫码'
if (data.status === 'scanned') qr.status = '已扫码,请在设备上确认'
if (data.status === 'expired') {
clearQrTimer()
qr.status = '二维码已失效'
qr.error = '二维码已失效,请刷新'
}
if (data.status === 'success') {
clearQrTimer()
qr.status = '登录成功'
if (data.cookie_saved) {
config.value.p115_client_type = qr.clientType
if (data.cookie) config.value.p115_cookie = data.cookie
await persistConfig({ silent: true })
}
showMessage('115 登录成功Cookie 已自动保存。', 'success')
setTimeout(() => {
qr.show = false
}, 1800)
await loadP115Health()
}
} catch (err) {
console.error('检查 115 二维码状态失败:', err)
} finally {
if (requestId === qr.requestId) {
qr.checking = false
}
}
}
function openQrDialog() {
qr.show = true
qr.error = ''
qr.status = '等待扫码'
qr.clientType = config.value.p115_client_type || 'alipaymini'
requestQrCode()
}
function closeQrDialog() {
clearQrTimer()
qr.requestId += 1
qr.loading = false
qr.checking = false
qr.show = false
}
async function refreshQrCode() {
qr.error = ''
await requestQrCode()
}
async function changeQrClientType(value) {
if (!value || value === qr.clientType) return
qr.clientType = value
qr.error = ''
await requestQrCode()
}
async function loadP115Health() {
if (!props.api?.get) return
healthLoading.value = true
try {
const response = await props.api.get(`${pluginBase.value}/p115/ui/health`)
const result = unwrapResponse(response)
if (result?.success) {
health.value = result.data || null
}
} catch (err) {
health.value = { p115_ready: false, message: err?.message || '检测失败' }
} finally {
healthLoading.value = false
}
}
async function loadLatestConfig() {
if (!props.api?.get) return false
try {
const response = await withTimeout(
props.api.get(`${pluginBase.value}/config/get`),
12000,
'加载配置超时'
)
const result = unwrapResponse(response)
if (result?.success && result.data) {
config.value = cloneConfig(result.data)
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
return true
}
} catch (err) {
console.error('加载 Agent影视助手 配置失败:', err)
}
return false
}
onMounted(async () => {
config.value = cloneConfig(props.initialConfig)
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
await loadLatestConfig()
loadP115Health()
})
onBeforeUnmount(clearQrTimer)
</script>
<template>
<div class="aro-config">
<VToolbar density="comfortable" color="transparent" class="aro-toolbar">
<VIcon icon="mdi-robot-outline" color="primary" class="ms-3 me-2" />
<div class="text-h6">Agent影视助手配置</div>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" :loading="healthLoading" title="刷新 115 状态" @click="loadP115Health" />
<VBtn icon="mdi-content-save" variant="text" color="success" :loading="saving" title="保存配置" @click="saveConfig" />
<VBtn icon="mdi-close" variant="text" title="关闭" @click="emit('close')" />
</VToolbar>
<VDivider />
<div class="aro-body">
<div class="aro-inner">
<VAlert v-if="message.text" :type="message.type" variant="tonal" density="compact" closable class="mb-3">
{{ message.text }}
</VAlert>
<div class="aro-intro text-body-2 mb-3">
<VIcon icon="mdi-rocket-launch-outline" size="small" color="primary" class="me-1" />
<span>快速开始先启用插件并配置 MP/PT再按需开启影巢盘搜与飞书入口完整说明见</span>
<a href="https://github.com/liuyuexi1987/MoviePilot-Plugins" target="_blank" rel="noopener" class="text-primary text-decoration-none font-weight-medium">主页文档</a>
</div>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-toggle-switch" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">基础设置</VCardTitle>
<VCardSubtitle class="text-caption">启用插件通知与调试开关</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.enabled).color" size="small" variant="tonal">{{ enableChip(config.enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" md="4">
<VSwitch v-model="config.enabled" label="启用插件" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.notify" label="发送通知" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.debug" label="调试日志" color="warning" density="compact" hide-details />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-movie-search-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">MP/PT 策略</VCardTitle>
<VCardSubtitle class="text-caption">首选主线原生搜索/订阅/下载评分仅影响未保存偏好的新会话</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.mp_pt_enabled).color" size="small" variant="tonal">{{ enableChip(config.mp_pt_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.mp_pt_enabled" label="启用 MP/PT" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_pt_min_seeders" label="最低做种数" type="number" placeholder="3" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_confirm_score_threshold" label="建议确认分" type="number" placeholder="70" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_auto_ingest_score_threshold" label="自动入库分" type="number" placeholder="90" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.assistant_default_auto_ingest_enabled" label="高分自动入库" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="6" md="9">
<VTextField v-model="config.mp_download_save_path" label="PT 下载保存路径(可选)" placeholder="默认留空;需要时填 local:/downloads 等" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-cloud-lock-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">115 扫码登录</VCardTitle>
<VCardSubtitle class="text-caption">扫码写入 Cookie手填仅作兜底</VCardSubtitle>
<template #append>
<VChip :color="health?.p115_ready ? 'success' : 'warning'" size="small" variant="tonal">{{ p115ReadyText }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense align="center">
<VCol cols="12" sm="6" md="4">
<VTextField v-model="config.p115_default_path" label="115 默认目录" placeholder="/待整理" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="6" md="4">
<VSelect v-model="config.p115_client_type" :items="CLIENT_TYPES" item-title="title" item-value="value" label="智能体扫码默认客户端" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.p115_prefer_direct" label="优先 115 直转" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12">
<VTextField
:model-value="maskSecret(config.p115_cookie, showCookie)"
label="115 Cookie"
variant="outlined"
density="compact"
hide-details="auto"
readonly
hint="点击右侧二维码图标扫码,成功后自动保存 Cookie。"
persistent-hint
>
<template #append-inner>
<VIcon :icon="showCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showCookie = !showCookie" />
<VIcon icon="mdi-content-copy" class="me-2" size="small" :disabled="!config.p115_cookie" @click="copyText(config.p115_cookie, '115 Cookie')" />
</template>
<template #append>
<VIcon icon="mdi-qrcode-scan" :color="config.p115_cookie ? 'success' : 'primary'" title="扫码获取或更新 115 Cookie" @click="openQrDialog" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-honeycomb-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">影巢资源</VCardTitle>
<VCardSubtitle class="text-caption">资源搜索 / 解锁 / 转存积分上限填 0 不限制</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.hdhive_resource_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_resource_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.hdhive_resource_enabled" label="启用搜索/解锁" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="6" md="3">
<VSelect
v-model="config.hdhive_resource_mode"
:items="[
{ title: '网页方式', value: 'browser' },
{ title: 'OpenAPI', value: 'openapi' },
{ title: '自动(网页优先)', value: 'auto' },
]"
item-title="title"
item-value="value"
label="资源方式"
variant="outlined"
density="compact"
hide-details="auto"
/>
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_max_unlock_points" label="积分上限" type="number" placeholder="20" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_candidate_page_size" label="候选页大小" type="number" placeholder="10" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_base_url" label="影巢地址" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_default_path" label="影巢默认转存目录" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_api_key" :type="showHdhiveApiKey ? 'text' : 'password'" label="影巢 API Key" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveApiKey ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveApiKey = !showHdhiveApiKey" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_api_key" @click="copyText(config.hdhive_api_key, '影巢 API Key')" />
</template>
</VTextField>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_openapi_user_token" :type="showHdhiveAccessToken ? 'text' : 'password'" label="OpenAPI Access Token" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveAccessToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveAccessToken = !showHdhiveAccessToken" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_user_token" @click="copyText(config.hdhive_openapi_user_token, '影巢 Access Token')" />
</template>
</VTextField>
</VCol>
<VCol cols="12">
<VTextField v-model="config.hdhive_openapi_refresh_token" :type="showHdhiveRefreshToken ? 'text' : 'password'" label="OpenAPI Refresh Token可选" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveRefreshToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveRefreshToken = !showHdhiveRefreshToken" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_refresh_token" @click="copyText(config.hdhive_openapi_refresh_token, '影巢 Refresh Token')" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-calendar-check-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">影巢签到</VCardTitle>
<VCardSubtitle class="text-caption">OpenAPI 优先网页 Cookie 兜底 Cron 自动签到</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.hdhive_checkin_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_checkin_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_enabled" label="启用签到" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_gambler_mode" label="默认赌狗签到" color="warning" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_once" label="保存后立即运行" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_auto_login" label="自动刷新 Cookie" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_cron" label="签到 Cron" placeholder="0 8 * * *" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_username" label="影巢用户名/邮箱" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_password" :type="showHdhivePassword ? 'text' : 'password'" label="影巢密码" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhivePassword ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showHdhivePassword = !showHdhivePassword" />
</template>
</VTextField>
</VCol>
<VCol cols="12">
<VTextField
v-model="config.hdhive_checkin_cookie"
:type="showHdhiveCookie ? 'text' : 'password'"
label="影巢网页 Cookie非 Premium 兜底)"
variant="outlined"
density="compact"
hide-details="auto"
>
<template #append-inner>
<VIcon :icon="showHdhiveCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveCookie = !showHdhiveCookie" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_checkin_cookie" @click="copyText(config.hdhive_checkin_cookie, '影巢 Cookie')" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-magnify-scan" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">盘搜</VCardTitle>
<VCardSubtitle class="text-caption">聚合公开网盘分享地址需容器视角可访问</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.pansou_enabled).color" size="small" variant="tonal">{{ enableChip(config.pansou_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="3" md="3">
<VSwitch v-model="config.pansou_enabled" label="启用盘搜" color="success" density="compact" hide-details />
</VCol>
<VCol cols="8" sm="6" md="6">
<VTextField v-model="config.pansou_base_url" label="盘搜 API 地址" placeholder="http://host.docker.internal:805" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="4" sm="3" md="3">
<VTextField v-model="config.pansou_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-message-badge-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">飞书入口</VCardTitle>
<VCardSubtitle class="text-caption">内置飞书机器人入口与会话白名单</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.feishu_enabled).color" size="small" variant="tonal">{{ enableChip(config.feishu_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="4" md="4">
<VSwitch v-model="config.feishu_enabled" label="启用飞书入口" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="4" md="4">
<VSwitch v-model="config.feishu_allow_all" label="允许所有会话" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="4" md="4">
<VSwitch v-model="config.feishu_reply_enabled" label="发送飞书回复" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.feishu_app_id" label="飞书 App ID" placeholder="cli_xxxxxxxxx" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField :type="showFeishuSecret ? 'text' : 'password'" v-model="config.feishu_app_secret" label="飞书 App Secret" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showFeishuSecret ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showFeishuSecret = !showFeishuSecret" />
</template>
</VTextField>
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" class="py-0">
<div class="text-caption text-medium-emphasis">未允许所有会话时仅下列白名单中的群聊或用户可触发飞书命令</div>
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
<VTextarea v-model="config.feishu_allowed_chat_ids" label="允许的群聊 Chat ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
<VTextarea v-model="config.feishu_allowed_user_ids" label="允许的用户 Open ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
</div>
</div>
<VDialog v-model="qr.show" max-width="450" @update:model-value="value => !value && closeQrDialog()">
<VCard>
<VCardTitle class="text-subtitle-1 d-flex align-center px-3 py-2 bg-primary-lighten-5">
<VIcon icon="mdi-qrcode" color="primary" size="small" class="me-2" />
115网盘扫码登录
</VCardTitle>
<VCardText class="text-center py-4">
<VAlert v-if="qr.error" type="error" density="compact" variant="tonal" closable class="mb-3 mx-3">
{{ qr.error }}
</VAlert>
<div v-if="qr.loading" class="d-flex flex-column align-center py-3">
<VProgressCircular indeterminate color="primary" class="mb-3" />
<div>正在获取二维码...</div>
</div>
<div v-else-if="qr.qrcode" class="d-flex flex-column align-center">
<div class="mb-2 font-weight-medium">请选择扫码方式</div>
<VChipGroup :model-value="qr.clientType" class="mb-3" mandatory selected-class="primary" @update:model-value="changeQrClientType">
<VChip v-for="item in CLIENT_TYPES" :key="item.value" :value="item.value" variant="outlined" color="primary" size="small">
{{ item.label }}
</VChip>
</VChipGroup>
<div class="d-flex flex-column align-center mb-3">
<VCard flat class="border pa-2 mb-2">
<img :src="qr.qrcode" width="220" height="220" alt="115 登录二维码" />
</VCard>
<div class="text-body-2 text-grey mb-1">{{ qr.tips }}</div>
<div class="text-subtitle-2 font-weight-medium text-primary">{{ qr.status }}</div>
</div>
<VBtn color="primary" variant="tonal" size="small" class="mb-2" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">
刷新二维码
</VBtn>
</div>
<div v-else class="d-flex flex-column align-center py-3">
<VIcon icon="mdi-qrcode-off" size="64" color="grey" class="mb-3" />
<div class="text-subtitle-1">二维码获取失败</div>
<div class="text-body-2 text-grey">请点击刷新按钮重试</div>
</div>
</VCardText>
<VDivider />
<VCardActions class="px-3 py-2">
<VBtn color="grey" variant="text" size="small" prepend-icon="mdi-close" @click="closeQrDialog">关闭</VBtn>
<VSpacer />
<VBtn color="primary" variant="text" size="small" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">刷新二维码</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
<style scoped>
.aro-config {
display: flex;
flex-direction: column;
max-height: 82vh;
}
.aro-toolbar {
flex: 0 0 auto;
}
.aro-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 16px;
}
.aro-inner {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.aro-intro {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.06);
color: rgb(var(--v-theme-on-surface));
line-height: 1.5;
}
.aro-card-head {
padding-bottom: 0;
}
.aro-card :deep(.v-card-item__append) {
align-self: center;
}
.aro-card :deep(.v-card-subtitle) {
opacity: 0.7;
white-space: normal;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup>
import Config from './Config.vue'
defineProps({
api: {
type: Object,
default: () => ({}),
},
initialConfig: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['save', 'close'])
</script>
<template>
<Config :api="api" :initial-config="initialConfig" @save="payload => emit('save', payload)" @close="emit('close')" />
</template>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import Page from './components/Page.vue'
createApp(Page).mount('#app')

View File

@@ -0,0 +1,26 @@
export const CLIENT_TYPES = [
{ title: '支付宝', label: '支付宝', value: 'alipaymini' },
{ title: '微信', label: '微信', value: 'wechatmini' },
{ title: '安卓', label: '安卓', value: '115android' },
{ title: 'iOS', label: 'iOS', value: '115ios' },
{ title: '网页', label: '网页', value: 'web' },
{ title: 'PAD', label: 'PAD', value: '115ipad' },
{ title: 'TV', label: 'TV', value: 'tv' },
]
export function cloneConfig(config) {
return JSON.parse(JSON.stringify(config || {}))
}
export function unwrapResponse(response) {
if (!response) return response
if (Object.prototype.hasOwnProperty.call(response, 'success')) return response
if (Object.prototype.hasOwnProperty.call(response, 'data')) return response.data
return response
}
export function maskSecret(value, visible) {
const text = String(value || '')
if (visible || !text) return text
return '•'.repeat(Math.min(Math.max(text.length, 8), 24))
}

View File

@@ -0,0 +1,62 @@
"""HDHiveBrowserService 纯函数测试(无需 pytest直接 python3 运行)。
绕开 AgentResourceOfficer 包(其 __init__.py 依赖 MoviePilot app.*
桩掉 app.helper.browser / app.log 后直接加载 services/hdhive_browser.py。
"""
import os
import sys
import types
for _name in ("app", "app.helper", "app.helper.browser", "app.log"):
sys.modules.setdefault(_name, types.ModuleType(_name))
sys.modules["app.helper.browser"].PlaywrightHelper = object # type: ignore[attr-defined]
sys.modules["app.log"].logger = types.SimpleNamespace( # type: ignore[attr-defined]
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
debug=lambda *a, **k: None,
)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "services"))
import hdhive_browser # noqa: E402
HDHiveBrowserService = hdhive_browser.HDHiveBrowserService
def test_detail_url_movie_and_tv():
svc = HDHiveBrowserService(base_url="https://hdhive.com/", cookie="token=abc")
assert svc._detail_url("movie", 123) == "https://hdhive.com/tmdb/movie/123"
assert svc._detail_url("电影", 123) == "https://hdhive.com/tmdb/movie/123"
assert svc._detail_url("tv", 9) == "https://hdhive.com/tmdb/tv/9"
assert svc._detail_url("电视剧", 9) == "https://hdhive.com/tmdb/tv/9"
def test_is_ready_requires_cookie():
assert HDHiveBrowserService(cookie="token=abc").is_ready() is True
assert HDHiveBrowserService(cookie="").is_ready() is False
def test_normalize_extracts_slug_from_href():
svc = HDHiveBrowserService(cookie="token=abc")
raw = {
"href": "/resource/115/abc-uuid-123/",
"title": "电影标题",
"resolution": "1080P",
"size": "10 GB",
"is_free": False,
"unlock_points": 20,
"user": "u",
"posted_at": "2026/01/01",
"tags": ["官组"],
}
out = svc._normalize(raw)
assert out["slug"] == "abc-uuid-123"
assert out["unlock_points"] == 20
assert out["title"] == "电影标题"
if __name__ == "__main__":
test_detail_url_movie_and_tv()
test_is_ready_requires_cookie()
test_normalize_extracts_slug_from_href()
print("ALL PASS")

View File

@@ -6,13 +6,11 @@ export default defineConfig({
plugins: [
vue(),
federation({
name: 'OidcAuth',
name: 'AgentResourceOfficer',
filename: 'remoteEntry.js',
exposes: {
'./AuthPage': './src/components/AuthPage.vue',
'./AppPage': './src/components/AppPage.vue',
'./Page': './src/components/AppPage.vue',
'./Config': './src/components/AppPage.vue',
'./Config': './src/components/Config.vue',
'./Page': './src/components/Page.vue',
},
shared: {
vue: {
@@ -41,6 +39,16 @@ export default defineConfig({
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
root.walkRules(rule => {
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
})
},
},
],
},
},

View File

@@ -24,7 +24,7 @@ class AgentTokens(_PluginBase):
plugin_name = "Agent Tokens 管理"
plugin_desc = "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。"
plugin_icon = "agentresourceofficer.png"
plugin_version = "1.0.10"
plugin_version = "1.0.12"
plugin_author = "jxxghp"
author_url = "https://github.com/jxxghp"
plugin_config_prefix = "agenttokens_"
@@ -117,21 +117,21 @@ class AgentTokens(_PluginBase):
"""
return [{"key": "usage", "name": "Agent Tokens 管理"}] if self.get_state() else []
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]:
"""
返回 Vue 仪表板组件的布局与标题配置。
"""
if not self.get_state():
return None
return (
{"cols": 12, "md": 6},
{"cols": 12, "sm": 6, "md": 4},
{
"title": "Agent Tokens 管理",
"subtitle": "LLM 配额使用情况",
"refresh": 30,
"border": True,
},
[],
None,
)
def get_sidebar_nav(self) -> List[Dict[str, Any]]:

View File

@@ -1,13 +1,5 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { f as formatTokens, P as PROVIDER_TYPE_OPTIONS, d as createProvider, b as buildProviderRows, a as buildProviderSummary, g as getNextProviderPriority, n as normalizeProvider } from './provider-DJcqUg7E.js';
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
import { _ as _export_sfc, f as formatTokens, P as PROVIDER_TYPE_OPTIONS, d as createProvider, b as buildProviderRows, a as buildProviderSummary, g as getNextProviderPriority, n as normalizeProvider } from './_plugin-vue_export-helper-B_eZRIX_.js';
const {createElementVNode:_createElementVNode$3,openBlock:_openBlock$4,createElementBlock:_createElementBlock$2,createCommentVNode:_createCommentVNode$2,renderList:_renderList$1,Fragment:_Fragment$1,resolveComponent:_resolveComponent$4,createVNode:_createVNode$4,toDisplayString:_toDisplayString$4,createTextVNode:_createTextVNode$4,withCtx:_withCtx$4,unref:_unref$4,createBlock:_createBlock$4} = await importShared('vue');
@@ -988,4 +980,4 @@ return (_ctx, _cache) => {
};
const AgentTokensManager = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-a6c1ea54"]]);
export { AgentTokensManager as A, _export_sfc as _ };
export { AgentTokensManager as A };

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { A as AgentTokensManager } from './AgentTokensManager-BU-kAtj6.js';
import { u as unwrapResponse } from './provider-DJcqUg7E.js';
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
import { u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { A as AgentTokensManager } from './AgentTokensManager-BU-kAtj6.js';
import { c as cloneConfig } from './provider-DJcqUg7E.js';
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
import { c as cloneConfig } from './_plugin-vue_export-helper-B_eZRIX_.js';
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');

View File

@@ -0,0 +1,198 @@
.agenttokens-dashboard-widget[data-v-cd87a760] {
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
inline-size: 100%;
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
}
.agenttokens-dashboard-card[data-v-cd87a760] {
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.agenttokens-dashboard-card__header[data-v-cd87a760] {
flex: 0 0 auto;
padding-block-end: 8px;
}
.agenttokens-dashboard-card__title[data-v-cd87a760] {
font-size: 1rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agenttokens-dashboard-card__body[data-v-cd87a760] {
flex: 1 1 auto;
min-block-size: 0;
overflow: auto;
overscroll-behavior: contain;
padding-block-start: 8px;
}
.agenttokens-dashboard-card__actions[data-v-cd87a760] {
flex: 0 0 auto;
min-block-size: 40px;
padding: 4px 12px;
}
.agenttokens-dashboard-state[data-v-cd87a760] {
block-size: 100%;
min-block-size: 0;
display: flex;
align-items: center;
justify-content: center;
}
.agenttokens-dashboard-content[data-v-cd87a760] {
display: flex;
flex-direction: column;
gap: 12px;
min-block-size: 0;
}
.agenttokens-dashboard-summary[data-v-cd87a760] {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 14px;
}
.agenttokens-dashboard-summary__percent[data-v-cd87a760] {
font-size: 0.95rem;
font-weight: 700;
}
.agenttokens-dashboard-summary__body[data-v-cd87a760] {
min-inline-size: 0;
}
.agenttokens-dashboard-summary__count[data-v-cd87a760] {
margin-block: 2px 8px;
font-size: 1.7rem;
font-weight: 700;
line-height: 1.1;
}
.agenttokens-dashboard-summary__count span[data-v-cd87a760] {
color: var(--agenttokens-muted-color);
font-size: 1rem;
font-weight: 600;
}
.agenttokens-dashboard-metrics[data-v-cd87a760] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.agenttokens-dashboard-metric[data-v-cd87a760] {
min-block-size: 54px;
border: 1px solid var(--agenttokens-divider-color);
border-radius: 6px;
background: var(--agenttokens-soft-surface);
padding: 8px 10px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.agenttokens-dashboard-metric[data-v-cd87a760]:hover {
background: var(--agenttokens-soft-surface-hover);
}
.agenttokens-dashboard-metric span[data-v-cd87a760] {
display: block;
color: var(--agenttokens-muted-color);
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-metric strong[data-v-cd87a760] {
display: block;
margin-block-start: 4px;
font-size: 0.95rem;
line-height: 1.2;
overflow-wrap: anywhere;
}
.agenttokens-dashboard-list[data-v-cd87a760] {
display: flex;
flex-direction: column;
gap: 2px;
min-block-size: 0;
}
.agenttokens-dashboard-provider[data-v-cd87a760] {
min-block-size: 34px;
border-radius: 6px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding-inline: 2px;
}
.agenttokens-dashboard-provider[data-v-cd87a760]:hover {
background: var(--agenttokens-soft-surface);
}
.agenttokens-dashboard-provider__main[data-v-cd87a760] {
min-inline-size: 0;
}
.agenttokens-dashboard-provider__name[data-v-cd87a760],
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agenttokens-dashboard-provider__name[data-v-cd87a760] {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
color: var(--agenttokens-muted-color);
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-provider__tokens[data-v-cd87a760] {
color: var(--agenttokens-muted-color);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.agenttokens-dashboard-empty[data-v-cd87a760] {
min-block-size: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--agenttokens-muted-color);
font-size: 0.82rem;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body[data-v-cd87a760] {
padding-block: 6px 10px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content[data-v-cd87a760] {
gap: 8px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary[data-v-cd87a760] {
gap: 10px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count[data-v-cd87a760] {
margin-block-end: 6px;
font-size: 1.35rem;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric[data-v-cd87a760] {
min-block-size: 46px;
padding: 6px 8px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider[data-v-cd87a760] {
min-block-size: 30px;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header[data-v-cd87a760] {
padding-block: 10px 4px;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary[data-v-cd87a760] {
grid-template-columns: auto minmax(0, 1fr);
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count[data-v-cd87a760] {
margin-block: 0 4px;
font-size: 1.15rem;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions[data-v-cd87a760] {
justify-content: flex-end;
min-block-size: 34px;
}
@media (max-width: 480px) {
.agenttokens-dashboard-metrics[data-v-cd87a760] {
grid-template-columns: 1fr;
}
}

View File

@@ -1,130 +0,0 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { f as formatTokens, u as unwrapResponse } from './provider-DJcqUg7E.js';
const {createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,resolveComponent:_resolveComponent,createVNode:_createVNode,unref:_unref,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock,createElementBlock:_createElementBlock,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock} = await importShared('vue');
const _hoisted_1 = { class: "agenttokens-dashboard" };
const _hoisted_2 = { class: "d-flex align-center mb-3" };
const _hoisted_3 = { class: "text-h5" };
const _hoisted_4 = { class: "text-caption text-medium-emphasis mb-3" };
const _hoisted_5 = { class: "text-caption" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'Dashboard',
props: {
api: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
},
setup(__props) {
const props = __props;
const loading = ref(false);
const status = ref({ providers: [], summary: {} });
let timer = null;
const summary = computed(() => status.value.summary || {});
const providers = computed(() => status.value.providers || []);
// 读取仪表板所需的精简状态。
async function loadStatus() {
if (!props.allowRefresh) return
loading.value = true;
try {
const response = await props.api.get('plugin/AgentTokens/status');
status.value = unwrapResponse(response) || status.value;
} finally {
loading.value = false;
}
}
onMounted(() => {
loadStatus();
timer = window.setInterval(loadStatus, 30000);
});
onUnmounted(() => {
if (timer) {
window.clearInterval(timer);
}
});
return (_ctx, _cache) => {
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
const _component_VIcon = _resolveComponent("VIcon");
const _component_VListItem = _resolveComponent("VListItem");
const _component_VList = _resolveComponent("VList");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("div", _hoisted_2, [
_createElementVNode("div", null, [
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-subtitle-2" }, "Agent Tokens 管理", -1)),
_createElementVNode("div", _hoisted_3, _toDisplayString(summary.value.available_count || 0) + " / " + _toDisplayString(summary.value.enabled_count || 0), 1)
]),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "mdi-refresh",
variant: "text",
size: "small",
loading: loading.value,
onClick: loadStatus
}, null, 8, ["loading"])
]),
_createVNode(_component_VProgressLinear, {
"model-value": summary.value.total_limit ? Math.min((summary.value.total_used || 0) * 100 / summary.value.total_limit, 100) : 0,
color: "primary",
height: "8",
rounded: "",
class: "mb-3"
}, null, 8, ["model-value"]),
_createElementVNode("div", _hoisted_4, _toDisplayString(_unref(formatTokens)(summary.value.total_used)) + " / " + _toDisplayString(summary.value.total_limit ? _unref(formatTokens)(summary.value.total_limit) : '不限'), 1),
_createVNode(_component_VList, {
density: "compact",
class: "py-0"
}, {
default: _withCtx(() => [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(providers.value.slice(0, 4), (row) => {
return (_openBlock(), _createBlock(_component_VListItem, {
key: row.id,
title: row.name,
subtitle: row.model
}, {
prepend: _withCtx(() => [
_createVNode(_component_VIcon, {
color: row.usage?.exhausted ? 'error' : 'success',
size: "small"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'), 1)
]),
_: 2
}, 1032, ["color"])
]),
append: _withCtx(() => [
_createElementVNode("span", _hoisted_5, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
]),
_: 2
}, 1032, ["title", "subtitle"]))
}), 128))
]),
_: 1
})
]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,429 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { _ as _export_sfc, f as formatTokens, u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
const {resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,createBlock:_createBlock,createElementVNode:_createElementVNode,unref:_unref,renderList:_renderList,Fragment:_Fragment,normalizeClass:_normalizeClass} = await importShared('vue');
const _hoisted_1 = {
key: 0,
class: "agenttokens-dashboard-state"
};
const _hoisted_2 = {
key: 2,
class: "agenttokens-dashboard-content"
};
const _hoisted_3 = { class: "agenttokens-dashboard-summary" };
const _hoisted_4 = { class: "agenttokens-dashboard-summary__percent" };
const _hoisted_5 = { class: "agenttokens-dashboard-summary__body" };
const _hoisted_6 = { class: "agenttokens-dashboard-summary__count" };
const _hoisted_7 = {
key: 0,
class: "agenttokens-dashboard-metrics"
};
const _hoisted_8 = { class: "agenttokens-dashboard-metric" };
const _hoisted_9 = { class: "agenttokens-dashboard-metric" };
const _hoisted_10 = { class: "agenttokens-dashboard-metric" };
const _hoisted_11 = {
key: 1,
class: "agenttokens-dashboard-list"
};
const _hoisted_12 = { class: "agenttokens-dashboard-provider__main" };
const _hoisted_13 = { class: "agenttokens-dashboard-provider__name" };
const _hoisted_14 = { class: "agenttokens-dashboard-provider__model" };
const _hoisted_15 = { class: "agenttokens-dashboard-provider__tokens" };
const _hoisted_16 = {
key: 2,
class: "agenttokens-dashboard-empty"
};
const _hoisted_17 = {
key: 3,
class: "agenttokens-dashboard-state text-caption text-disabled"
};
const _hoisted_18 = {
key: 0,
class: "text-caption text-disabled"
};
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'Dashboard',
props: {
api: {
type: Object,
default: () => ({}),
},
config: {
type: Object,
default: () => ({ attrs: {} }),
},
allowRefresh: {
type: Boolean,
default: true,
},
refreshInterval: {
type: Number,
default: 0,
},
},
setup(__props) {
const props = __props;
const loading = ref(false);
const error = ref('');
const initialDataLoaded = ref(false);
const lastRefreshedAt = ref(null);
const widgetRef = ref(null);
const widgetSize = ref({ inline: 0, block: 0 });
const status = ref({ providers: [], summary: {} });
let timer = null;
let resizeObserver = null;
const attrs = computed(() => props.config?.attrs || {});
const summary = computed(() => status.value.summary || {});
const providers = computed(() => status.value.providers || []);
const totalUsed = computed(() => Number(summary.value.total_used || 0));
const totalLimit = computed(() => Number(summary.value.total_limit || 0));
const remainingTokens = computed(() => {
if (totalLimit.value <= 0) return null
return Math.max(totalLimit.value - totalUsed.value, 0)
});
const usagePercent = computed(() => {
if (totalLimit.value <= 0) return 0
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
});
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'));
const progressColor = computed(() => {
if (totalLimit.value <= 0) return 'primary'
if (usagePercent.value >= 90) return 'error'
if (usagePercent.value >= 70) return 'warning'
return 'success'
});
const isCompact = computed(() => (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
));
const isMini = computed(() => (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
));
const gaugeSize = computed(() => {
if (isMini.value) return 52
if (isCompact.value) return 68
return 84
});
const gaugeWidth = computed(() => {
if (isMini.value) return 5
if (isCompact.value) return 6
return 8
});
const showMetrics = computed(() => !isMini.value);
const visibleProviderLimit = computed(() => {
if (isMini.value) return 0
if (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
) {
return 1
}
if (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
) {
return 2
}
return 3
});
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value));
// 兼容宿主传入的数字或字符串刷新间隔。
const refreshSeconds = computed(() => {
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0);
return Number.isFinite(seconds) ? seconds : 0
});
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理');
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况');
const cardFlat = computed(() => attrs.value.border === false);
const widgetClasses = computed(() => ({
'agenttokens-dashboard-widget--compact': isCompact.value,
'agenttokens-dashboard-widget--mini': isMini.value,
}));
const lastRefreshedTime = computed(() => {
if (!lastRefreshedAt.value) return ''
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
})
});
// 读取 Agent Tokens 仪表板状态。
async function loadStatus() {
if (!props.api?.get) {
error.value = 'API 未就绪';
return
}
loading.value = true;
error.value = '';
try {
const response = await props.api.get('plugin/AgentTokens/status');
status.value = unwrapResponse(response) || status.value;
initialDataLoaded.value = true;
lastRefreshedAt.value = Date.now();
} catch (err) {
error.value = err?.message || '获取数据失败';
} finally {
loading.value = false;
}
}
// 启动宿主传入或插件配置中的自动刷新。
function startRefreshTimer() {
if (refreshSeconds.value <= 0) return
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000);
}
// 清理仪表板自动刷新计时器。
function stopRefreshTimer() {
if (!timer) return
window.clearInterval(timer);
timer = null;
}
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
function observeWidgetSize() {
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) return
widgetSize.value = {
inline: entry.contentRect.width,
block: entry.contentRect.height,
};
});
resizeObserver.observe(widgetRef.value);
}
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
function stopWidgetSizeObserver() {
if (!resizeObserver) return
resizeObserver.disconnect();
resizeObserver = null;
}
onMounted(() => {
observeWidgetSize();
loadStatus();
startRefreshTimer();
});
onUnmounted(() => {
stopWidgetSizeObserver();
stopRefreshTimer();
});
return (_ctx, _cache) => {
const _component_VIcon = _resolveComponent("VIcon");
const _component_VAvatar = _resolveComponent("VAvatar");
const _component_VCardTitle = _resolveComponent("VCardTitle");
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
const _component_VCardItem = _resolveComponent("VCardItem");
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
const _component_VCardText = _resolveComponent("VCardText");
const _component_VDivider = _resolveComponent("VDivider");
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VCardActions = _resolveComponent("VCardActions");
const _component_VCard = _resolveComponent("VCard");
return (_openBlock(), _createElementBlock("div", {
ref_key: "widgetRef",
ref: widgetRef,
class: _normalizeClass(["agenttokens-dashboard-widget", widgetClasses.value])
}, [
_createVNode(_component_VCard, {
flat: cardFlat.value,
loading: loading.value,
class: "agenttokens-dashboard-card"
}, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, { class: "agenttokens-dashboard-card__header" }, {
prepend: _withCtx(() => [
_createVNode(_component_VAvatar, {
color: "primary",
variant: "tonal",
size: "36"
}, {
default: _withCtx(() => [
_createVNode(_component_VIcon, {
icon: "mdi-key-chain",
size: "20"
})
]),
_: 1
})
]),
default: _withCtx(() => [
_createVNode(_component_VCardTitle, { class: "agenttokens-dashboard-card__title" }, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(cardTitle.value), 1)
]),
_: 1
}),
_createVNode(_component_VCardSubtitle, null, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(cardSubtitle.value), 1)
]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, { class: "agenttokens-dashboard-card__body" }, {
default: _withCtx(() => [
(loading.value && !initialDataLoaded.value)
? (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createVNode(_component_VProgressCircular, {
indeterminate: "",
color: "primary",
size: "28"
})
]))
: (error.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "error",
variant: "tonal",
density: "compact",
class: "text-caption"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(error.value), 1)
]),
_: 1
}))
: (initialDataLoaded.value)
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
_createElementVNode("div", _hoisted_3, [
_createVNode(_component_VProgressCircular, {
"model-value": usagePercent.value,
color: progressColor.value,
"bg-color": "surface",
size: gaugeSize.value,
width: gaugeWidth.value
}, {
default: _withCtx(() => [
_createElementVNode("span", _hoisted_4, _toDisplayString(usagePercentText.value), 1)
]),
_: 1
}, 8, ["model-value", "color", "size", "width"]),
_createElementVNode("div", _hoisted_5, [
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
_createElementVNode("div", _hoisted_6, [
_createTextVNode(_toDisplayString(summary.value.available_count || 0) + " ", 1),
_createElementVNode("span", null, "/ " + _toDisplayString(summary.value.enabled_count || 0), 1)
]),
_createVNode(_component_VProgressLinear, {
"model-value": usagePercent.value,
color: progressColor.value,
height: "6",
rounded: ""
}, null, 8, ["model-value", "color"])
])
]),
(showMetrics.value)
? (_openBlock(), _createElementBlock("div", _hoisted_7, [
_createElementVNode("div", _hoisted_8, [
_cache[1] || (_cache[1] = _createElementVNode("span", null, "累计", -1)),
_createElementVNode("strong", null, _toDisplayString(_unref(formatTokens)(totalUsed.value)), 1)
]),
_createElementVNode("div", _hoisted_9, [
_cache[2] || (_cache[2] = _createElementVNode("span", null, "额度", -1)),
_createElementVNode("strong", null, _toDisplayString(totalLimit.value > 0 ? _unref(formatTokens)(totalLimit.value) : '不限'), 1)
]),
_createElementVNode("div", _hoisted_10, [
_cache[3] || (_cache[3] = _createElementVNode("span", null, "剩余", -1)),
_createElementVNode("strong", null, _toDisplayString(remainingTokens.value === null ? '不限' : _unref(formatTokens)(remainingTokens.value)), 1)
])
]))
: _createCommentVNode("", true),
(visibleProviders.value.length)
? (_openBlock(), _createElementBlock("div", _hoisted_11, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(visibleProviders.value, (row) => {
return (_openBlock(), _createElementBlock("div", {
key: row.id,
class: "agenttokens-dashboard-provider"
}, [
_createVNode(_component_VIcon, {
icon: row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle',
color: row.usage?.exhausted ? 'error' : 'success',
size: "16"
}, null, 8, ["icon", "color"]),
_createElementVNode("div", _hoisted_12, [
_createElementVNode("div", _hoisted_13, _toDisplayString(row.name || '未命名供应商'), 1),
_createElementVNode("div", _hoisted_14, _toDisplayString(row.model || '未配置模型'), 1)
]),
_createElementVNode("div", _hoisted_15, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
]))
}), 128))
]))
: (!providers.value.length)
? (_openBlock(), _createElementBlock("div", _hoisted_16, [
_createVNode(_component_VIcon, {
icon: "mdi-database-off-outline",
size: "18"
}),
_cache[4] || (_cache[4] = _createElementVNode("span", null, "暂无供应商", -1))
]))
: _createCommentVNode("", true)
]))
: (_openBlock(), _createElementBlock("div", _hoisted_17, " 暂无数据 "))
]),
_: 1
}),
(__props.allowRefresh)
? (_openBlock(), _createBlock(_component_VDivider, { key: 0 }))
: _createCommentVNode("", true),
(__props.allowRefresh)
? (_openBlock(), _createBlock(_component_VCardActions, {
key: 1,
class: "agenttokens-dashboard-card__actions"
}, {
default: _withCtx(() => [
(!isMini.value)
? (_openBlock(), _createElementBlock("span", _hoisted_18, _toDisplayString(lastRefreshedTime.value ? `更新于 ${lastRefreshedTime.value}` : '等待更新'), 1))
: _createCommentVNode("", true),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "",
variant: "text",
size: "small",
loading: loading.value,
onClick: loadStatus
}, {
default: _withCtx(() => [
_createVNode(_component_VIcon, {
icon: "mdi-refresh",
size: "18"
})
]),
_: 1
}, 8, ["loading"])
]),
_: 1
}))
: _createCommentVNode("", true)
]),
_: 1
}, 8, ["flat", "loading"])
], 2))
}
}
};
const Dashboard = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-cd87a760"]]);
export { Dashboard as default };

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main$1 from './__federation_expose_AppPage-B9QuFmbL.js';
import { _ as _export_sfc } from './AgentTokensManager-BU-kAtj6.js';
import _sfc_main$1 from './__federation_expose_AppPage-EV4Kchio.js';
import { _ as _export_sfc } from './_plugin-vue_export-helper-B_eZRIX_.js';
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');

View File

@@ -101,4 +101,12 @@ function buildProviderSummary(rows) {
}
}
export { PROVIDER_TYPE_OPTIONS as P, buildProviderSummary as a, buildProviderRows as b, cloneConfig as c, createProvider as d, formatTokens as f, getNextProviderPriority as g, normalizeProvider as n, unwrapResponse as u };
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { PROVIDER_TYPE_OPTIONS as P, _export_sfc as _, buildProviderSummary as a, buildProviderRows as b, cloneConfig as c, createProvider as d, formatTokens as f, getNextProviderPriority as g, normalizeProvider as n, unwrapResponse as u };

View File

@@ -1,5 +1,5 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_AppPage-B9QuFmbL.js';
import _sfc_main from './__federation_expose_AppPage-EV4Kchio.js';
true&&(function polyfill() {
const relList = document.createElement("link").relList;

View File

@@ -3,16 +3,16 @@ const currentImports = {};
let moduleMap = {
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Page-vwwFlnk-.css","AgentTokensManager-9miSzH4d.css"], false, './Page');
return __federation_import('./__federation_expose_Page-Clq-yFVB.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
return __federation_import('./__federation_expose_Page-BikS33tm.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Config":()=>{
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './Config');
return __federation_import('./__federation_expose_Config-CtodzYz-.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
return __federation_import('./__federation_expose_Config-CpvEDTaR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Dashboard":()=>{
dynamicLoadingCss([], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-CO5Mi0sE.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_Dashboard-CMoy7CAI.css"], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-DdqUAuX4.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./AppPage":()=>{
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './AppPage');
return __federation_import('./__federation_expose_AppPage-B9QuFmbL.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
return __federation_import('./__federation_expose_AppPage-EV4Kchio.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;

View File

@@ -1,7 +1,7 @@
<script type="module" crossorigin src="/assets/index-BDwGmFcM.js"></script>
<script type="module" crossorigin src="/assets/index-Cqxebwzg.js"></script>
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="/assets/provider-DJcqUg7E.js">
<link rel="modulepreload" crossorigin href="/assets/AgentTokensManager-BU-kAtj6.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-B9QuFmbL.js">
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B_eZRIX_.js">
<link rel="modulepreload" crossorigin href="/assets/AgentTokensManager-BTcJgtTd.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-EV4Kchio.js">
<link rel="stylesheet" crossorigin href="/assets/AgentTokensManager-9miSzH4d.css">
<div id="app"></div>

View File

@@ -1,7 +1,7 @@
{
"name": "moviepilot-agenttokens-plugin",
"private": true,
"version": "1.0.10",
"version": "1.0.12",
"type": "module",
"scripts": {
"build": "vite build"

View File

@@ -7,77 +7,524 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
config: {
type: Object,
default: () => ({ attrs: {} }),
},
allowRefresh: {
type: Boolean,
default: true,
},
refreshInterval: {
type: Number,
default: 0,
},
})
const loading = ref(false)
const error = ref('')
const initialDataLoaded = ref(false)
const lastRefreshedAt = ref(null)
const widgetRef = ref(null)
const widgetSize = ref({ inline: 0, block: 0 })
const status = ref({ providers: [], summary: {} })
let timer = null
let resizeObserver = null
const attrs = computed(() => props.config?.attrs || {})
const summary = computed(() => status.value.summary || {})
const providers = computed(() => status.value.providers || [])
const totalUsed = computed(() => Number(summary.value.total_used || 0))
const totalLimit = computed(() => Number(summary.value.total_limit || 0))
const remainingTokens = computed(() => {
if (totalLimit.value <= 0) return null
return Math.max(totalLimit.value - totalUsed.value, 0)
})
const usagePercent = computed(() => {
if (totalLimit.value <= 0) return 0
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
})
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'))
const progressColor = computed(() => {
if (totalLimit.value <= 0) return 'primary'
if (usagePercent.value >= 90) return 'error'
if (usagePercent.value >= 70) return 'warning'
return 'success'
})
const isCompact = computed(() => (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
))
const isMini = computed(() => (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
))
const gaugeSize = computed(() => {
if (isMini.value) return 52
if (isCompact.value) return 68
return 84
})
const gaugeWidth = computed(() => {
if (isMini.value) return 5
if (isCompact.value) return 6
return 8
})
const showMetrics = computed(() => !isMini.value)
const visibleProviderLimit = computed(() => {
if (isMini.value) return 0
if (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
) {
return 1
}
if (
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
) {
return 2
}
return 3
})
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value))
// 兼容宿主传入的数字或字符串刷新间隔。
const refreshSeconds = computed(() => {
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0)
return Number.isFinite(seconds) ? seconds : 0
})
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理')
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况')
const cardFlat = computed(() => attrs.value.border === false)
const widgetClasses = computed(() => ({
'agenttokens-dashboard-widget--compact': isCompact.value,
'agenttokens-dashboard-widget--mini': isMini.value,
}))
const lastRefreshedTime = computed(() => {
if (!lastRefreshedAt.value) return ''
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
})
})
// 读取仪表板所需的精简状态。
// 读取 Agent Tokens 仪表板状态。
async function loadStatus() {
if (!props.allowRefresh) return
if (!props.api?.get) {
error.value = 'API 未就绪'
return
}
loading.value = true
error.value = ''
try {
const response = await props.api.get('plugin/AgentTokens/status')
status.value = unwrapResponse(response) || status.value
initialDataLoaded.value = true
lastRefreshedAt.value = Date.now()
} catch (err) {
error.value = err?.message || '获取数据失败'
} finally {
loading.value = false
}
}
// 启动宿主传入或插件配置中的自动刷新。
function startRefreshTimer() {
if (refreshSeconds.value <= 0) return
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000)
}
// 清理仪表板自动刷新计时器。
function stopRefreshTimer() {
if (!timer) return
window.clearInterval(timer)
timer = null
}
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
function observeWidgetSize() {
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(entries => {
const entry = entries[0]
if (!entry) return
widgetSize.value = {
inline: entry.contentRect.width,
block: entry.contentRect.height,
}
})
resizeObserver.observe(widgetRef.value)
}
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
function stopWidgetSizeObserver() {
if (!resizeObserver) return
resizeObserver.disconnect()
resizeObserver = null
}
onMounted(() => {
observeWidgetSize()
loadStatus()
timer = window.setInterval(loadStatus, 30000)
startRefreshTimer()
})
onUnmounted(() => {
if (timer) {
window.clearInterval(timer)
}
stopWidgetSizeObserver()
stopRefreshTimer()
})
</script>
<template>
<div class="agenttokens-dashboard">
<div class="d-flex align-center mb-3">
<div>
<div class="text-subtitle-2">Agent Tokens 管理</div>
<div class="text-h5">{{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</div>
</div>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" size="small" :loading="loading" @click="loadStatus" />
</div>
<VProgressLinear
:model-value="summary.total_limit ? Math.min((summary.total_used || 0) * 100 / summary.total_limit, 100) : 0"
color="primary"
height="8"
rounded
class="mb-3"
/>
<div class="text-caption text-medium-emphasis mb-3">
{{ formatTokens(summary.total_used) }} / {{ summary.total_limit ? formatTokens(summary.total_limit) : '不限' }}
</div>
<VList density="compact" class="py-0">
<VListItem v-for="row in providers.slice(0, 4)" :key="row.id" :title="row.name" :subtitle="row.model">
<div ref="widgetRef" class="agenttokens-dashboard-widget" :class="widgetClasses">
<VCard :flat="cardFlat" :loading="loading" class="agenttokens-dashboard-card">
<VCardItem class="agenttokens-dashboard-card__header">
<template #prepend>
<VIcon :color="row.usage?.exhausted ? 'error' : 'success'" size="small">
{{ row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle' }}
</VIcon>
<VAvatar color="primary" variant="tonal" size="36">
<VIcon icon="mdi-key-chain" size="20" />
</VAvatar>
</template>
<template #append>
<span class="text-caption">{{ formatTokens(row.usage?.total_tokens) }}</span>
</template>
</VListItem>
</VList>
<VCardTitle class="agenttokens-dashboard-card__title">{{ cardTitle }}</VCardTitle>
<VCardSubtitle>{{ cardSubtitle }}</VCardSubtitle>
</VCardItem>
<VCardText class="agenttokens-dashboard-card__body">
<div v-if="loading && !initialDataLoaded" class="agenttokens-dashboard-state">
<VProgressCircular indeterminate color="primary" size="28" />
</div>
<VAlert v-else-if="error" type="error" variant="tonal" density="compact" class="text-caption">
{{ error }}
</VAlert>
<div v-else-if="initialDataLoaded" class="agenttokens-dashboard-content">
<div class="agenttokens-dashboard-summary">
<VProgressCircular
:model-value="usagePercent"
:color="progressColor"
bg-color="surface"
:size="gaugeSize"
:width="gaugeWidth"
>
<span class="agenttokens-dashboard-summary__percent">{{ usagePercentText }}</span>
</VProgressCircular>
<div class="agenttokens-dashboard-summary__body">
<div class="text-caption text-medium-emphasis">可用供应商</div>
<div class="agenttokens-dashboard-summary__count">
{{ summary.available_count || 0 }}
<span>/ {{ summary.enabled_count || 0 }}</span>
</div>
<VProgressLinear
:model-value="usagePercent"
:color="progressColor"
height="6"
rounded
/>
</div>
</div>
<div v-if="showMetrics" class="agenttokens-dashboard-metrics">
<div class="agenttokens-dashboard-metric">
<span>累计</span>
<strong>{{ formatTokens(totalUsed) }}</strong>
</div>
<div class="agenttokens-dashboard-metric">
<span>额度</span>
<strong>{{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</strong>
</div>
<div class="agenttokens-dashboard-metric">
<span>剩余</span>
<strong>{{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</strong>
</div>
</div>
<div v-if="visibleProviders.length" class="agenttokens-dashboard-list">
<div v-for="row in visibleProviders" :key="row.id" class="agenttokens-dashboard-provider">
<VIcon
:icon="row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'"
:color="row.usage?.exhausted ? 'error' : 'success'"
size="16"
/>
<div class="agenttokens-dashboard-provider__main">
<div class="agenttokens-dashboard-provider__name">{{ row.name || '未命名供应商' }}</div>
<div class="agenttokens-dashboard-provider__model">{{ row.model || '未配置模型' }}</div>
</div>
<div class="agenttokens-dashboard-provider__tokens">
{{ formatTokens(row.usage?.total_tokens) }}
</div>
</div>
</div>
<div v-else-if="!providers.length" class="agenttokens-dashboard-empty">
<VIcon icon="mdi-database-off-outline" size="18" />
<span>暂无供应商</span>
</div>
</div>
<div v-else class="agenttokens-dashboard-state text-caption text-disabled">
暂无数据
</div>
</VCardText>
<VDivider v-if="allowRefresh" />
<VCardActions v-if="allowRefresh" class="agenttokens-dashboard-card__actions">
<span v-if="!isMini" class="text-caption text-disabled">
{{ lastRefreshedTime ? `更新于 ${lastRefreshedTime}` : '等待更新' }}
</span>
<VSpacer />
<VBtn icon variant="text" size="small" :loading="loading" @click="loadStatus">
<VIcon icon="mdi-refresh" size="18" />
</VBtn>
</VCardActions>
</VCard>
</div>
</template>
<style scoped>
.agenttokens-dashboard-widget {
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
inline-size: 100%;
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
}
.agenttokens-dashboard-card {
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
min-block-size: 0;
overflow: hidden;
}
.agenttokens-dashboard-card__header {
flex: 0 0 auto;
padding-block-end: 8px;
}
.agenttokens-dashboard-card__header :deep(.v-card-item__content) {
min-inline-size: 0;
}
.agenttokens-dashboard-card__title {
font-size: 1rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agenttokens-dashboard-card__body {
flex: 1 1 auto;
min-block-size: 0;
overflow: auto;
overscroll-behavior: contain;
padding-block-start: 8px;
}
.agenttokens-dashboard-card__actions {
flex: 0 0 auto;
min-block-size: 40px;
padding: 4px 12px;
}
.agenttokens-dashboard-state {
block-size: 100%;
min-block-size: 0;
display: flex;
align-items: center;
justify-content: center;
}
.agenttokens-dashboard-content {
display: flex;
flex-direction: column;
gap: 12px;
min-block-size: 0;
}
.agenttokens-dashboard-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 14px;
}
.agenttokens-dashboard-summary__percent {
font-size: 0.95rem;
font-weight: 700;
}
.agenttokens-dashboard-summary__body {
min-inline-size: 0;
}
.agenttokens-dashboard-summary__count {
margin-block: 2px 8px;
font-size: 1.7rem;
font-weight: 700;
line-height: 1.1;
}
.agenttokens-dashboard-summary__count span {
color: var(--agenttokens-muted-color);
font-size: 1rem;
font-weight: 600;
}
.agenttokens-dashboard-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.agenttokens-dashboard-metric {
min-block-size: 54px;
border: 1px solid var(--agenttokens-divider-color);
border-radius: 6px;
background: var(--agenttokens-soft-surface);
padding: 8px 10px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.agenttokens-dashboard-metric:hover {
background: var(--agenttokens-soft-surface-hover);
}
.agenttokens-dashboard-metric span {
display: block;
color: var(--agenttokens-muted-color);
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-metric strong {
display: block;
margin-block-start: 4px;
font-size: 0.95rem;
line-height: 1.2;
overflow-wrap: anywhere;
}
.agenttokens-dashboard-list {
display: flex;
flex-direction: column;
gap: 2px;
min-block-size: 0;
}
.agenttokens-dashboard-provider {
min-block-size: 34px;
border-radius: 6px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding-inline: 2px;
}
.agenttokens-dashboard-provider:hover {
background: var(--agenttokens-soft-surface);
}
.agenttokens-dashboard-provider__main {
min-inline-size: 0;
}
.agenttokens-dashboard-provider__name,
.agenttokens-dashboard-provider__model {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agenttokens-dashboard-provider__name {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.agenttokens-dashboard-provider__model {
color: var(--agenttokens-muted-color);
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-provider__tokens {
color: var(--agenttokens-muted-color);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.agenttokens-dashboard-empty {
min-block-size: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--agenttokens-muted-color);
font-size: 0.82rem;
}
.agenttokens-dashboard-widget :deep(.v-progress-circular__underlay) {
stroke: rgba(var(--v-theme-on-surface), 0.12);
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body {
padding-block: 6px 10px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content {
gap: 8px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary {
gap: 10px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count {
margin-block-end: 6px;
font-size: 1.35rem;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric {
min-block-size: 46px;
padding: 6px 8px;
}
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider {
min-block-size: 30px;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header {
padding-block: 10px 4px;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header :deep(.v-card-subtitle) {
display: none;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary {
grid-template-columns: auto minmax(0, 1fr);
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count {
margin-block: 0 4px;
font-size: 1.15rem;
}
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions {
justify-content: flex-end;
min-block-size: 34px;
}
@media (max-width: 480px) {
.agenttokens-dashboard-metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -31,7 +31,7 @@ class DynamicWeChat(_PluginBase):
# 插件图标
plugin_icon = "Wecom_A.png"
# 插件版本
plugin_version = "2.1.1"
plugin_version = "2.1.2"
# 插件作者
plugin_author = "RamenRa"
# 作者主页
@@ -320,6 +320,7 @@ class DynamicWeChat(_PluginBase):
if not event_data or event_data.get("action") != "dynamicwechat":
return
context = None
self._qr_running = True
try:
context = self._launch_browser_context(headless=True)
page = context.new_page()
@@ -349,6 +350,7 @@ class DynamicWeChat(_PluginBase):
except Exception as e:
logger.error(f"本地扫码任务: 本地扫码失败: {e}")
finally:
self._qr_running = False
if context:
context.close()

View File

@@ -1,109 +0,0 @@
# 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.4.0
```

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
lark-oapi>=1.4.0

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ class InvitesSignin(_PluginBase):
# 插件图标
plugin_icon = "invites.png"
# 插件版本
plugin_version = "3.0.0"
plugin_version = "3.0.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -375,7 +375,7 @@ class InvitesSignin(_PluginBase):
def __signin(self):
"""药丸签到"""
# 1. 检查今日是否已签到
# 1. 本地历史只作为提示,最终以站点实时状态为准,避免旧版本误记成功后跳过真实签到
try:
history = self.get_data('history') or []
if history:
@@ -387,8 +387,7 @@ class InvitesSignin(_PluginBase):
# 获取今日日期字符串 YYYY-MM-DD
today_str = datetime.now().strftime('%Y-%m-%d')
if last_date.startswith(today_str):
logger.info(f"今日签到 ({last_date})跳过本次任务")
return
logger.info(f"本地已有今日签到记录 ({last_date})继续校验站点实时签到状态")
except Exception as e:
logger.warning(f"检查签到历史失败: {e}")
@@ -532,31 +531,160 @@ class InvitesSignin(_PluginBase):
logger.error(f"登录签到过程中发生异常: {e}")
return False
def __build_api_headers(self, csrf_token: str, referer: str = "https://invites.fun/") -> dict:
"""
构建药丸 API 请求头,贴近前端真实签到请求。
"""
return {
'accept': '*/*',
'accept-language': 'zh-CN,zh-Hans;q=0.9',
'origin': 'https://invites.fun',
'referer': referer,
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'x-csrf-token': csrf_token,
'user-agent': self._user_agent
}
@staticmethod
def __extract_checkin_state(payload: dict) -> dict:
"""
从药丸 JSON:API 用户响应中提取签到状态字段。
"""
if not isinstance(payload, dict):
return {}
data = payload.get('data')
if not isinstance(data, dict):
return {}
attrs = data.get('attributes')
if not isinstance(attrs, dict):
return {}
return {
"user_id": str(data.get('id') or ""),
"canCheckin": attrs.get('canCheckin'),
"lastCheckinTime": attrs.get('lastCheckinTime') or "",
"totalContinuousCheckIn": attrs.get('totalContinuousCheckIn'),
"money": attrs.get('money')
}
def __fetch_checkin_state(self, user_id: str, cookies: dict, csrf_token: str) -> dict:
"""
查询用户当前签到状态,用于签到前判断和签到后复核。
"""
try:
response = RequestUtils(
cookies=cookies,
headers=self.__build_api_headers(csrf_token),
proxies=self.__get_proxies()
).get_res(url=f'https://invites.fun/api/users/{user_id}')
if response is None:
logger.error("查询药丸签到状态失败:无响应")
return {}
if response.status_code != 200:
logger.error(f"查询药丸签到状态失败,状态码: {response.status_code}")
return {}
return self.__extract_checkin_state(response.json())
except Exception as e:
logger.error(f"查询药丸签到状态异常: {e}")
return {}
@staticmethod
def __is_today_checkin(state: dict) -> bool:
"""
判断签到状态是否已经落到当天。
"""
last_checkin_time = str((state or {}).get("lastCheckinTime") or "")
return bool(last_checkin_time and last_checkin_time.startswith(datetime.now().strftime('%Y-%m-%d')))
@staticmethod
def __get_response_error_message(response) -> str:
"""
从药丸接口错误响应中提取可读提示。
"""
if response is None:
return "无响应"
try:
payload = response.json()
errors = payload.get("errors") if isinstance(payload, dict) else None
if errors:
messages = []
for error in errors:
if not isinstance(error, dict):
continue
message = error.get("detail") or error.get("title") or error.get("code")
if message:
messages.append(str(message))
if messages:
return "".join(messages)
except Exception:
pass
text = getattr(response, "text", "") or ""
return text[:200] if text else f"HTTP {response.status_code}"
def __save_checkin_history(self, state: dict):
"""
保存签到历史,并按配置保留最近记录。
"""
checkin_time = str((state or {}).get("lastCheckinTime") or "") or datetime.today().strftime('%Y-%m-%d %H:%M:%S')
total_continuous_checkin = (state or {}).get("totalContinuousCheckIn")
money = (state or {}).get("money")
history = self.get_data('history') or []
checkin_day = checkin_time[:10]
if checkin_day:
history = [record for record in history if not str(record.get("date", "")).startswith(checkin_day)]
history.append({
"date": checkin_time,
"totalContinuousCheckIn": total_continuous_checkin,
"money": money
})
retain_seconds = int(self._history_days or 30) * 24 * 60 * 60
expired_timestamp = time.time() - retain_seconds
cleaned_history = []
for record in history:
try:
if datetime.strptime(record["date"], '%Y-%m-%d %H:%M:%S').timestamp() >= expired_timestamp:
cleaned_history.append(record)
except Exception:
logger.debug(f"忽略格式异常的签到历史记录: {record}")
self.save_data(key="history", value=cleaned_history)
def __notify_checkin_success(self, state: dict, already_signed: bool = False):
"""
发送签到成功或今日已签到通知。
"""
if not self._notify:
return
status_text = "✅今日已签到" if already_signed else "✅已签到"
money = (state or {}).get("money")
total_continuous_checkin = (state or {}).get("totalContinuousCheckIn")
self.post_message(
mtype=NotificationType.SiteMessage,
title="【💊药丸签到】任务完成",
text="━━━━━━━━━━━━━━\n"
f"✨ 状态:{status_text}\n"
"━━━━━━━━━━━━━━\n"
"📊 数据统计\n"
f"💊 剩余药丸:{money}\n"
f"📆 累计签到:{total_continuous_checkin}\n"
"━━━━━━━━━━━━━━\n"
f"🕐 签到时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
def __perform_checkin(self, user_id: str, cookie_str: str, csrf_token: str) -> bool:
"""执行实际的签到操作"""
try:
# 构建签到请求的headers
headers = {
'accept': '*/*',
'content-type': 'application/json; charset=UTF-8',
'origin': 'https://invites.fun',
'referer': 'https://invites.fun/',
'x-csrf-token': csrf_token,
'x-http-method-override': 'PATCH',
'user-agent': self._user_agent
}
# 构建签到请求的JSON数据
json_data = {
'data': {
'type': 'users',
'attributes': {
'canCheckin': False,
'totalContinuousCheckIn': 2, #连续签到天数
},
'id': str(user_id),
},
}
headers = self.__build_api_headers(csrf_token)
# 构建cookies - 使用安全的解析方法
cookies = self.__parse_cookie_string(cookie_str)
@@ -568,58 +696,49 @@ class InvitesSignin(_PluginBase):
# 获取代理
proxies = self.__get_proxies()
# 先查询站点实时状态,避免本地历史或旧接口响应造成误判
before_state = self.__fetch_checkin_state(user_id, cookies, csrf_token)
if before_state and before_state.get("canCheckin") is False and self.__is_today_checkin(before_state):
logger.info("药丸今日已签到,跳过重复签到")
self.__save_checkin_history(before_state)
self.__notify_checkin_success(before_state, already_signed=True)
return True
# 执行签到请求
checkin_url = f'https://invites.fun/api/users/{user_id}'
checkin_url = 'https://invites.fun/api/checkin'
response = RequestUtils(cookies=cookies, headers=headers, proxies=proxies).post_res(
checkin_url,
json=json_data
checkin_url
)
if not response or response.status_code != 200:
logger.error(f"签到请求失败,状态码: {response.status_code if response else 'None'}")
if response is None:
logger.error("签到请求失败:无响应")
return False
if response.status_code != 200:
error_message = self.__get_response_error_message(response)
logger.error(f"签到请求失败,状态码: {response.status_code},原因: {error_message}")
after_state = self.__fetch_checkin_state(user_id, cookies, csrf_token)
if after_state and after_state.get("canCheckin") is False and self.__is_today_checkin(after_state):
logger.info("药丸站点状态显示今日已签到")
self.__save_checkin_history(after_state)
self.__notify_checkin_success(after_state, already_signed=True)
return True
return False
# 解析签到响应
try:
checkin_data = response.json()
# 提取关键信息
total_continuous_checkin = checkin_data['data']['attributes']['totalContinuousCheckIn']
money = checkin_data['data']['attributes']['money']
checkin_state = self.__extract_checkin_state(checkin_data)
if not checkin_state:
logger.error("签到响应缺少用户状态数据")
return False
if checkin_state.get("canCheckin") is not False or not self.__is_today_checkin(checkin_state):
logger.error(f"签到响应未确认今日已签到: {checkin_state}")
return False
logger.info("药丸签到成功")
# 发送通知
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title="【💊药丸签到】任务完成",
text="━━━━━━━━━━━━━━\n"
"✨ 状态:✅已签到\n"
"━━━━━━━━━━━━━━\n"
"📊 数据统计\n"
f"💊 剩余药丸:{money}\n"
f"📆 累计签到:{total_continuous_checkin}\n"
"━━━━━━━━━━━━━━\n"
f"🕐 签到时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# 保存签到历史
history = self.get_data('history') or []
history.append({
"date": datetime.today().strftime('%Y-%m-%d %H:%M:%S'),
"totalContinuousCheckIn": total_continuous_checkin,
"money": money
})
# 清理超过保留天数的历史记录
thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
history = [record for record in history if
datetime.strptime(record["date"], '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
# 保存签到历史
self.save_data(key="history", value=history)
self.__notify_checkin_success(checkin_state)
self.__save_checkin_history(checkin_state)
return True
except Exception as e:

View File

@@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
# 插件图标
plugin_icon = "IYUU.png"
# 插件版本
plugin_version = "2.16"
plugin_version = "2.17"
# 插件作者
plugin_author = "jxxghp,CKun"
# 作者主页

View File

@@ -90,6 +90,15 @@ class IyuuHelper(object):
return result.get('sid_sha1')
return None
def __reseed_index(self, json_data: str, sha1: str) -> Tuple[Optional[dict], str]:
return self.__request_iyuu(url='/reseed/index/index', method='post', params={
'hash': json_data,
'sha1': sha1,
'sid_sha1': self._sid_sha1,
'timestamp': int(time.time()),
'version': self._version
})
def get_seed_info(self, info_hashs: list) -> Tuple[Optional[dict], str]:
"""
返回info_hash对应的站点id、种子id
@@ -101,13 +110,10 @@ class IyuuHelper(object):
info_hashs.sort()
json_data = json.dumps(info_hashs, separators=(',', ':'), ensure_ascii=False)
sha1 = self.get_sha1(json_data)
result, msg = self.__request_iyuu(url='/reseed/index/index', method='post', params={
'hash': json_data,
'sha1': sha1,
'sid_sha1': self._sid_sha1,
'timestamp': int(time.time()),
'version': self._version
})
result, msg = self.__reseed_index(json_data, sha1)
if msg and "站点哈希值 require" in msg:
self._sid_sha1 = self.__report_existing()
result, msg = self.__reseed_index(json_data, sha1)
return result, msg
@staticmethod

View File

@@ -1,55 +1,39 @@
import asyncio
import copy
import os
import json
import os
import queue
import re
import subprocess
import sys
import threading
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple, Optional, Literal
from typing import Any, Dict, List, Literal, Optional, Tuple
import pymediainfo
from langdetect import detect
from langchain_community.callbacks import get_openai_callback
from pysubs2 import SSAFile, SSAEvent, SSAStyle, Color, Alignment
from pysubs2 import Alignment, Color, SSAEvent, SSAStyle, SSAFile
from app.core.config import settings
from app.agent.llm.helper import LLMHelper
from app.chain.media import MediaChain
from app.core.cache import cached
from app.core.config import global_vars, settings
from app.core.context import MediaInfo
from app.core.event import Event, eventmanager
from app.helper.directory import DirectoryHelper
from app.log import logger
from app.plugins import _PluginBase
from app.core.cache import cached
from app.core.event import eventmanager, Event
from app.schemas import Response
from app.schemas.types import NotificationType, MediaType
from app.schemas import Context, Response, TransferInfo
from app.schemas.types import EventType, MediaType, NotificationType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.schemas import TransferInfo, Context
from app.schemas.types import EventType
from app.core.context import MediaInfo
from app.chain.media import MediaChain
from .agenttool import QueryAnnotationTasksTool, VocabularyAnnotatingTool
from .lexicon import Lexicon
from .schemas import (
IDGenerator,
TaskStatus,
Task,
TasksApiParams,
ProcessResult,
SegmentList,
TaskParams, SegmentStatistics,
)
from .pipeline import UNIVERSAL_POS_MAP, extract_advanced_words, llm_process_chain
from .schemas import IDGenerator, ProcessResult, SegmentList, SegmentStatistics, Task, TaskParams, TasksApiParams, \
TaskStatus, LLMConfig
from .spacyworker import SpacyWorker
from .subtitle import SubtitleProcessor, style_text
from .pipeline import (
extract_advanced_words,
llm_process_chain,
initialize_llm,
UNIVERSAL_POS_MAP,
)
from .subtitle import SubtitleHelper, SubtitleProcessor, style_text
class LexiAnnot(_PluginBase):
@@ -60,7 +44,7 @@ class LexiAnnot(_PluginBase):
# 插件图标
plugin_icon = "LexiAnnot.png"
# 插件版本
plugin_version = "1.2.5"
plugin_version = "1.2.6"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -91,7 +75,6 @@ class LexiAnnot(_PluginBase):
_ffmpeg_path: str = "ffmpeg"
_english_only = False
_when_file_trans = False
_model_temperature = ""
_custom_files = ""
_accent_color = ""
_font_scaling = ""
@@ -102,6 +85,8 @@ class LexiAnnot(_PluginBase):
_libraries: List[str] = []
_use_mp_agent: bool = False
_use_proxy: bool = False
_test_llm: bool = False
_thinking_level: str = None
# protected variables
_lexicon_repo = "https://raw.githubusercontent.com/wumode/LexiAnnot/"
@@ -137,7 +122,6 @@ class LexiAnnot(_PluginBase):
self._ffmpeg_path = config.get("ffmpeg_path") or "ffmpeg"
self._english_only = config.get("english_only")
self._when_file_trans = config.get("when_file_trans")
self._model_temperature = config.get("model_temperature") or "0.3"
self._show_phonetics = config.get("show_phonetics")
self._custom_files = config.get("custom_files") or ""
self._accent_color = config.get("accent_color")
@@ -151,6 +135,8 @@ class LexiAnnot(_PluginBase):
self._llm_provider = config.get("llm_provider") or "google"
self._use_mp_agent = config.get("use_mp_agent") or False
self._use_proxy = config.get("use_proxy") or False
self._test_llm = config.get("test_llm") or False
self._thinking_level = config.get("thinking_level") or "off"
libraries = [
library.name for library in DirectoryHelper().get_library_dirs()
@@ -158,7 +144,7 @@ class LexiAnnot(_PluginBase):
self._libraries = [
library for library in self._libraries if library in libraries
]
self._accent_color_rgb = LexiAnnot.hex_to_rgb(self._accent_color) or (255, 255, 0,)
self._accent_color_rgb = SubtitleHelper.hex_to_rgb(self._accent_color) or (255, 255, 0,)
self._color_alpha = int(self._opacity) if self._opacity and len(self._opacity) else 0
if self._delete_data:
# 删除不再保存在数据库的数据
@@ -193,6 +179,9 @@ class LexiAnnot(_PluginBase):
continue
self.add_media_file(file_path)
self._onlyonce = False
if self._test_llm:
asyncio.run_coroutine_threadsafe(self.test_llm(), global_vars.loop)
self._test_llm = False
self.__update_config()
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
@@ -679,14 +668,17 @@ class LexiAnnot(_PluginBase):
"model": "gemini_model",
"disabled": "use_mp_agent",
"label": "模型名称",
"hint": "支持手动输入",
"persistent-hint": True,
"items": [
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.5-flash",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-R1"
"gemini-2.5-flash-lite",
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash",
"deepseek-v4-flash",
"deepseek-v4-pro"
],
},
}
@@ -735,28 +727,6 @@ class LexiAnnot(_PluginBase):
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSelect",
"props": {
"model": "model_temperature",
"label": "模型温度",
"items": [
{"title": "0", "value": "0"},
{"title": "0.1", "value": "0.1"},
{"title": "0.2", "value": "0.2"},
{"title": "0.3", "value": "0.3"},
{"title": "0.4", "value": "0.4"},
{"title": "0.5", "value": "0.5"},
{"title": "1.0", "value": "1.0"},
],
},
}
],
},
{
"component": "VCol",
"props": {
@@ -777,8 +747,55 @@ class LexiAnnot(_PluginBase):
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSelect",
"props": {
"model": "thinking_level",
"label": "思考模式",
"disabled": "use_mp_agent",
"items": [
{"title": "关闭 (off)", "value": "off"},
{"title": "自动 (auto)", "value": "auto"},
{"title": "最小 (minimal)", "value": "minimal"},
{"title": "低 (low)", "value": "low"},
{"title": "中 (medium)", "value": "medium"},
{"title": "高 (high)", "value": "high"},
{"title": "极高 (max)", "value": "max"},
{"title": "超高 (xhigh)", "value": "xhigh"},
],
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 12,
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "test_llm",
"label": "测试调用",
"hint": "启用后,请在插件日志查看测试结果",
"persistent-hint": True
},
}
],
},
]
}
],
},
],
@@ -883,7 +900,6 @@ class LexiAnnot(_PluginBase):
"ffmpeg_path": "",
"english_only": True,
"when_file_trans": True,
"model_temperature": "0.3",
"custom_files": "",
"accent_color": "",
"font_scaling": "1",
@@ -896,6 +912,8 @@ class LexiAnnot(_PluginBase):
"llm_base_url": "",
"use_mp_agent": False,
"use_proxy": False,
"test_llm": False,
"thinking_level": "off"
}
def get_api(self) -> List[Dict[str, Any]]:
@@ -1046,6 +1064,25 @@ class LexiAnnot(_PluginBase):
else:
logger.debug(" No running worker thread to stop.")
async def test_llm(self):
model_config = self.get_model_config()
try:
logger.info("测试 LLM 调用...")
result = await LLMHelper.test_current_settings(
provider=model_config.provider,
model=model_config.model_name,
thinking_level=model_config.thinking_level,
use_proxy=model_config.use_proxy,
base_url=model_config.base_url,
api_key=model_config.apikey
)
if not result.get("reply_preview"):
logger.warning("LLM 响应为空")
else:
logger.info(f"LLM 返回: {result['reply_preview']}")
except Exception as err:
logger.error(f"LLM 调用出错: {str(err)}")
def delete_data(self):
# 删除词典
data_path = self.get_data_path()
@@ -1156,7 +1193,6 @@ class LexiAnnot(_PluginBase):
"ffmpeg_path": self._ffmpeg_path,
"english_only": self._english_only,
"when_file_trans": self._when_file_trans,
"model_temperature": self._model_temperature,
"show_phonetics": self._show_phonetics,
"custom_files": self._custom_files,
"accent_color": self._accent_color,
@@ -1170,6 +1206,8 @@ class LexiAnnot(_PluginBase):
"llm_base_url": self._llm_base_url,
"use_mp_agent": self._use_mp_agent,
"use_proxy": self._use_proxy,
"test_llm": self._test_llm,
"thinking_level": self._thinking_level
}
)
@@ -1310,7 +1348,7 @@ class LexiAnnot(_PluginBase):
ffmpeg_path = self._ffmpeg_path if self._ffmpeg_path else "ffmpeg"
eng_mark = ["en", "en-US", "eng", "en-GB", "english", "en-AU"]
embedded_subtitles = LexiAnnot._extract_subtitles_by_lang(path, eng_mark, ffmpeg_path)
embedded_subtitles = SubtitleHelper.extract_subtitles_by_lang(path, eng_mark, ffmpeg_path)
if not embedded_subtitles:
return ProcessResult(
status=TaskStatus.CANCELED, message="未找到嵌入式英文文本字幕"
@@ -1332,7 +1370,7 @@ class LexiAnnot(_PluginBase):
return ProcessResult(status=TaskStatus.CANCELED, message="任务已取消")
ass_subtitle = SSAFile.from_string(embedded_subtitle["subtitle"], format_="ass")
if embedded_subtitle.get("codec_id") == "S_TEXT/UTF8":
ass_subtitle = LexiAnnot.set_srt_style(ass_subtitle)
ass_subtitle = SubtitleHelper.set_srt_style(ass_subtitle)
ass_subtitle = self.__set_style(ass_subtitle)
ass_subtitle, stat = self.process_subtitles(ass_subtitle, lexi, spacy_worker, mediainfo)
if self._shutdown_event.is_set():
@@ -1498,170 +1536,6 @@ class LexiAnnot(_PluginBase):
for new_path in transfer_info.file_list_new or []:
self.add_media_file(new_path)
@staticmethod
def format_duration(ms):
total_seconds, milliseconds = divmod(ms, 1000)
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
hundredths = milliseconds // 10
return f"{hours}:{minutes:02}:{seconds:02}.{hundredths:02}"
@staticmethod
def _remove_substring(replacements: list[dict]):
new_list = []
replacements.sort(key=lambda x: x["end"] - x["start"], reverse=True)
for r in replacements:
if any((r["start"] >= new["start"] and r["end"] <= new["end"]) for new in new_list):
continue
new_list.append(r)
return new_list
@staticmethod
def replace_by_plaintext_positions(line: SSAEvent, replacements: List[dict]):
"""
使用 replacements 中的 plaintext 位置信息, 替换 line.text 中的内容。
:param line: SSAEvent line
:param replacements: [{'start': int, 'end': int, 'old_text': str, 'new_text': str}, ...]
"""
text = line.text
tag_pattern = re.compile(r"{.*?}") # 匹配 {xxx} 格式控制符
special_pattern = re.compile(r"\\[Nh]")
# 构建 plaintext 位置到 text 索引的映射
mapping = {} # plaintext_index -> text_index
p_index = 0 # 当前 plaintext 索引
t_index = 0 # 当前 text 索引
while t_index < len(text):
if text[t_index] == "{":
# 跳过格式标签
match = tag_pattern.match(text, t_index)
if match:
t_index = match.end()
continue
elif text[t_index] == "\\":
match = special_pattern.match(text, t_index)
if match:
t_index = match.end() - 1
continue
# 非格式字符
mapping[p_index] = t_index
p_index += 1
t_index += 1
replacements = LexiAnnot._remove_substring(replacements)
# 按照 mapping 执行替换(倒序替换防止位置错位)
new_text = text
for r in sorted(replacements, key=lambda x: x["start"], reverse=True):
start = mapping.get(r["start"])
end = mapping.get(r["end"] - 1)
if start is None or end is None:
continue
end += 1
new_text = new_text[:start] + r["new_text"] + new_text[end:]
line.text = new_text
@staticmethod
def analyze_ass_language(ass_file: SSAFile):
def _replace_with_spaces(_text):
"""
使用等长的空格替换文本中的 (xxx) 模式。
例如:"(Hi)" 会被替换成 " " (4个空格)
"""
pattern = r"(\([^()]*\)|\[[^\[\]]*\])"
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
styles = {}
for style in ass_file.styles:
styles[style] = {"text": [], "duration": 0, "text_size": 0, "times": 0}
for dialogue in ass_file:
style = dialogue.style
text = _replace_with_spaces(dialogue.plaintext)
sub_text = text.split("\n")
if style not in styles or not text:
continue
styles[style]["text"].extend(sub_text)
styles[style]["duration"] += dialogue.duration
styles[style]["text_size"] += len(text)
styles[style]["times"] += 1
style_language_analysis = {}
for style_name, data in styles.items():
all_text = " ".join(data["text"])
if not all_text.strip():
style_language_analysis[style_name] = None
continue
languages = []
# 对每个文本片段进行语言检测
for text_fragment in data["text"]:
try:
lang = detect(text_fragment)
languages.append(lang)
except Exception as e:
# 无法检测的文本
logger.debug(e)
pass
if languages:
language_counts = Counter(languages)
most_common_language = language_counts.most_common(1)[0]
style_language_analysis[style_name] = {
"main_language": most_common_language[0],
"proportion": most_common_language[1] / len(languages),
"duration": data["duration"],
"text_size": data["text_size"],
"times": data["times"],
}
else:
style_language_analysis[style_name] = None
return style_language_analysis
@staticmethod
def select_main_style_weighted(analysis: Dict[str, Any], known_language: str, weights = None):
"""
根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式
:params analysis: `analyze_ass_language` 函数的输出结果
:params known_language: 已知的字幕语言代码
:params weights: 各个维度的权重,权重之和应为 1
:returns: 主要字幕的样式名称,如果没有匹配的样式则返回 None
"""
if weights is None:
weights = {"times": 0.5, "text_size": 0.4, "duration": 0.1}
matching_styles = []
max_times = max([analysis.get("times", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_text_size = max([analysis.get("text_size", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_duration = max([analysis.get("duration", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
for style, analysis in analysis.items():
if not analysis:
continue
if analysis.get("main_language") == known_language:
# 跳过多语言
if analysis.get("proportion", 0) < 0.5:
continue
score = 0
score += analysis.get("times", 0) * weights.get("times", 0) / max_times
score += analysis.get("text_size", 0) * weights.get("text_size", 0) / max_text_size
score += analysis.get("duration", 0) * weights.get("duration", 0) / max_duration
matching_styles.append((style, score))
if not matching_styles:
return None
sorted_styles = sorted(matching_styles, key=lambda item: item[1], reverse=True)
return sorted_styles[0][0]
@staticmethod
def set_srt_style(ass: SSAFile) -> SSAFile:
ass.info["ScaledBorderAndShadow"] = "no"
play_res_y = int(ass.info["PlayResY"])
if "Default" in ass.styles:
ass.styles["Default"].marginv = play_res_y // 16
ass.styles["Default"].fontname = "Microsoft YaHei"
ass.styles["Default"].fontsize = play_res_y // 16
return ass
def __set_style(self, ass: SSAFile) -> SSAFile:
font_scaling = (
float(self._font_scaling)
@@ -1747,107 +1621,25 @@ class LexiAnnot(_PluginBase):
ass.styles["Annotation EXAM"] = cefr_style
return ass
@staticmethod
def hex_to_rgb(hex_color: str | None) -> tuple[int, ...] | None:
if not hex_color:
return None
pattern = r"^#[0-9a-fA-F]{6}$"
if re.match(pattern, hex_color) is None:
return None
hex_color = hex_color.lstrip("#") # 去掉前面的 #
return tuple(int(hex_color[i: i + 2], 16) for i in (0, 2, 4))
@staticmethod
def __extract_subtitle(
video_path: str,
subtitle_stream_index: str,
ffmpeg_path: str = "ffmpeg",
sub_format="ass",
) -> Optional[str]:
if sub_format not in ["srt", "ass"]:
raise ValueError("Invalid subtitle format")
try:
map_parameter = f"0:s:{subtitle_stream_index}"
command = [ffmpeg_path, "-i", video_path, "-map", map_parameter, "-f", sub_format, "-"]
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8", check=True
def get_model_config(self) -> LLMConfig:
if self._use_mp_agent:
return LLMConfig(
apikey=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
model_name=settings.LLM_MODEL,
thinking_level=settings.LLM_THINKING_LEVEL,
provider=settings.LLM_PROVIDER.lower(),
use_proxy=settings.LLM_USE_PROXY
)
return result.stdout
except FileNotFoundError:
logger.warn(f"错误:找不到视频文件 '{video_path}'")
return None
except subprocess.CalledProcessError as e:
logger.warn(f"错误:提取字幕失败。\n错误信息:{e}")
logger.warn(
f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}"
else:
return LLMConfig(
apikey=self._gemini_apikey,
base_url=self._llm_base_url,
model_name=self._gemini_model,
thinking_level=self._thinking_level,
provider=self._llm_provider.lower(),
use_proxy=self._use_proxy
)
return None
@staticmethod
def _extract_subtitles_by_lang(
video_path: str, lang: str | list = "en", ffmpeg: str = "ffmpeg"
) -> list[dict]:
"""
提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。
"""
def check_lang(track_lang: str) -> bool:
if isinstance(lang, list):
return track_lang in lang
return track_lang == lang
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS", "tx3g"]
subtitles = []
try:
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
for track in media_info.tracks:
if (
track.track_type == "Text"
and check_lang(track_lang=track.language)
and track.codec_id in supported_codec
):
subtitle_stream_index = (
track.stream_identifier
) # MediaInfo 的 stream_id 从 1 开始ffmpeg 从 0 开始
extracted_subtitle = LexiAnnot.__extract_subtitle(
video_path, subtitle_stream_index, ffmpeg
)
duration = 0
if hasattr(track, "duration"):
try:
duration = int(float(track.duration))
except (ValueError, TypeError):
pass
if extracted_subtitle:
subtitles.append(
{
"title": track.title or "",
"subtitle": extracted_subtitle,
"codec_id": track.codec_id,
"stream_id": subtitle_stream_index,
"duration": duration,
}
)
if subtitles:
# remove outliers with abnormally short duration
if len(subtitles) > 1:
durations = [sub["duration"] for sub in subtitles if sub["duration"] > 0]
if durations:
avg_duration = sum(durations) / len(durations)
subtitles = [
sub for sub in subtitles if sub["duration"] >= avg_duration * 0.2
]
if not subtitles:
logger.warn("未找到标记为英语的文本字幕流")
except FileNotFoundError:
logger.error(f"找不到视频文件 '{video_path}'")
except subprocess.CalledProcessError as e:
logger.error(f"错误:提取字幕失败。\n错误信息:{e}")
logger.error(f"FFmpeg 输出 (stderr):\n{e.stderr}")
except Exception as e:
logger.error(f"使用 MediaInfo 提取字幕时发生错误:{e}")
return subtitles
def _process_chain(
self,
@@ -1867,7 +1659,6 @@ class LexiAnnot(_PluginBase):
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]
simple_vocabulary = set(filter(lambda x: x < self._annot_level, CEFR_LEVELS))
learner_level = max(simple_vocabulary)
model_temperature = float(self._model_temperature) if self._model_temperature else 0.3
logger.info("通过 spaCy 分词...")
for seg in segments:
if self._shutdown_event.is_set():
@@ -1879,25 +1670,19 @@ class LexiAnnot(_PluginBase):
simple_level=simple_vocabulary
)
if self._gemini_available:
if self._use_mp_agent:
llm_apikey = settings.LLM_API_KEY
llm_base_url = settings.LLM_BASE_URL
llm_model_name = settings.LLM_MODEL
llm_provider = settings.LLM_PROVIDER.lower()
else:
llm_apikey = self._gemini_apikey
llm_base_url = self._llm_base_url
llm_model_name = self._gemini_model
llm_provider = self._llm_provider.lower()
llm = initialize_llm(
provider=llm_provider,
model_name=llm_model_name,
base_url=llm_base_url,
api_key=llm_apikey or '',
temperature=model_temperature,
max_retries=self._max_retries,
proxy=self._use_proxy,
)
llm_config = self.get_model_config()
llm = asyncio.run_coroutine_threadsafe(
LLMHelper.get_llm(
provider=llm_config.provider,
model=llm_config.model_name,
thinking_level=llm_config.thinking_level,
api_key=llm_config.apikey,
base_url=llm_config.base_url,
use_proxy=llm_config.use_proxy
),
global_vars.loop
).result()
segments = llm_process_chain(
lexi=lexi,
llm=llm,
@@ -1926,8 +1711,8 @@ class LexiAnnot(_PluginBase):
f"{self._accent_color_rgb[1]:02x}{self._accent_color_rgb[0]:02x}&"
) # &H00FFFFFF&
statistical_res = LexiAnnot.analyze_ass_language(ass_file)
main_style: str | None = LexiAnnot.select_main_style_weighted(statistical_res, lang)
statistical_res = SubtitleHelper.analyze_ass_language(ass_file)
main_style: str | None = SubtitleHelper.select_main_style_weighted(statistical_res, lang)
if not main_style:
logger.error("无法确定主要字幕样式")
return None, None
@@ -2004,7 +1789,7 @@ class LexiAnnot(_PluginBase):
"new_text": new_text,
}
replacements.append(replacement)
LexiAnnot.replace_by_plaintext_positions(
SubtitleHelper.replace_by_plaintext_positions(
main_processor[seg.index], replacements
)
if self._sentence_translation:

View File

@@ -4,9 +4,7 @@ import threading
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import SecretStr
from app.core.config import settings
from app.schemas import Context
from app.schemas.types import MediaType
from app.log import logger
@@ -60,59 +58,6 @@ UNIVERSAL_POS_MAP: dict[UniversalPos, str | None] = {
}
def initialize_llm(
provider: str,
api_key: str,
model_name: str,
base_url: str | None,
temperature: float = 0.1,
max_retries: int = 3,
proxy: bool = False,
) -> BaseChatModel:
"""初始化 LLM"""
if provider == "google":
if proxy:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=SecretStr(api_key),
max_retries=3,
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
temperature=settings.LLM_TEMPERATURE,
openai_proxy=settings.PROXY_HOST,
)
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=model_name,
google_api_key=api_key, # noqa
max_retries=max_retries,
temperature=temperature,
)
elif provider == "deepseek":
from langchain_deepseek import ChatDeepSeek
return ChatDeepSeek(
model=model_name,
api_key=SecretStr(api_key),
max_retries=max_retries,
temperature=temperature,
)
else:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model_name,
api_key=SecretStr(api_key),
max_retries=max_retries,
base_url=base_url,
temperature=temperature,
openai_proxy=settings.PROXY_HOST if proxy else None,
)
def convert_pos_to_spacy(pos: str):
"""
将给定的词性列表转换为 spaCy 库中使用的词性标签
@@ -727,5 +672,4 @@ def llm_process_chain(
lexi, llm, context, start, end, learner_level, media_name, translate_sentences
)
)
return SegmentList(root=segments_list)

View File

@@ -365,3 +365,12 @@ class VocabularyAnnotatingToolInput(BaseModel):
class QueryAnnotationTasksToolInput(BaseModel):
count: int = Field(default=5, description="The maximum number of returned annotation tasks")
explanation: str = Field(..., description="This is a tool for querying the latest annotation tasks in AnnotLexi")
class LLMConfig(BaseModel):
apikey: str
provider: str
model_name: str
thinking_level: str | None = Field(default=None)
base_url: str | None = Field(default=None)
use_proxy: bool = Field(default=False)

View File

@@ -1,10 +1,277 @@
import re
import subprocess
from collections import Counter
from typing import Generator, Any, overload
from pysubs2 import SSAEvent
import pymediainfo
from langdetect import detect
from pysubs2 import SSAEvent, SSAFile
from app.log import logger
from .schemas import SubtitleSegment
class SubtitleHelper:
@staticmethod
def remove_substring(replacements: list[dict]):
new_list = []
replacements.sort(key=lambda x: x["end"] - x["start"], reverse=True)
for r in replacements:
if any((r["start"] >= new["start"] and r["end"] <= new["end"]) for new in new_list):
continue
new_list.append(r)
return new_list
@staticmethod
def analyze_ass_language(ass_file: SSAFile):
def _replace_with_spaces(_text):
"""
使用等长的空格替换文本中的 (xxx) 模式。
例如:"(Hi)" 会被替换成 " " (4个空格)
"""
pattern = r"(\([^()]*\)|\[[^\[\]]*\])"
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
styles = {}
for style in ass_file.styles:
styles[style] = {"text": [], "duration": 0, "text_size": 0, "times": 0}
for dialogue in ass_file:
style = dialogue.style
text = _replace_with_spaces(dialogue.plaintext)
sub_text = text.split("\n")
if style not in styles or not text:
continue
styles[style]["text"].extend(sub_text)
styles[style]["duration"] += dialogue.duration
styles[style]["text_size"] += len(text)
styles[style]["times"] += 1
style_language_analysis = {}
for style_name, data in styles.items():
all_text = " ".join(data["text"])
if not all_text.strip():
style_language_analysis[style_name] = None
continue
languages = []
# 对每个文本片段进行语言检测
for text_fragment in data["text"]:
try:
lang = detect(text_fragment)
languages.append(lang)
except Exception as e:
# 无法检测的文本
logger.debug(e)
if languages:
language_counts = Counter(languages)
most_common_language = language_counts.most_common(1)[0]
style_language_analysis[style_name] = {
"main_language": most_common_language[0],
"proportion": most_common_language[1] / len(languages),
"duration": data["duration"],
"text_size": data["text_size"],
"times": data["times"],
}
else:
style_language_analysis[style_name] = None
return style_language_analysis
@staticmethod
def select_main_style_weighted(analysis: dict[str, Any], known_language: str, weights = None):
"""
根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式
:params analysis: `analyze_ass_language` 函数的输出结果
:params known_language: 已知的字幕语言代码
:params weights: 各个维度的权重,权重之和应为 1
:returns: 主要字幕的样式名称,如果没有匹配的样式则返回 None
"""
if weights is None:
weights = {"times": 0.5, "text_size": 0.4, "duration": 0.1}
matching_styles = []
max_times = max([analysis.get("times", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_text_size = max([analysis.get("text_size", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_duration = max([analysis.get("duration", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
for style, info in analysis.items():
if not info:
continue
if info.get("main_language") == known_language:
# 跳过多语言
if info.get("proportion", 0) < 0.5:
continue
score = 0
score += info.get("times", 0) * weights.get("times", 0) / max_times
score += info.get("text_size", 0) * weights.get("text_size", 0) / max_text_size
score += info.get("duration", 0) * weights.get("duration", 0) / max_duration
matching_styles.append((style, score))
if not matching_styles:
return None
sorted_styles = sorted(matching_styles, key=lambda item: item[1], reverse=True)
return sorted_styles[0][0]
@staticmethod
def set_srt_style(ass: SSAFile) -> SSAFile:
ass.info["ScaledBorderAndShadow"] = "no"
play_res_y = int(ass.info["PlayResY"])
if "Default" in ass.styles:
ass.styles["Default"].marginv = play_res_y // 16
ass.styles["Default"].fontname = "Microsoft YaHei"
ass.styles["Default"].fontsize = play_res_y // 16
return ass
@staticmethod
def __extract_subtitle(
video_path: str,
subtitle_stream_index: str,
ffmpeg_path: str = "ffmpeg",
sub_format="ass",
) -> str | None:
if sub_format not in ["srt", "ass"]:
raise ValueError("Invalid subtitle format")
try:
map_parameter = f"0:s:{subtitle_stream_index}"
command = [ffmpeg_path, "-i", video_path, "-map", map_parameter, "-f", sub_format, "-"]
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8", check=True
)
return result.stdout
except FileNotFoundError:
logger.warn(f"错误:找不到视频文件 '{video_path}'")
return None
except subprocess.CalledProcessError as e:
logger.warn(f"错误:提取字幕失败。\n错误信息:{e}")
logger.warn(
f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}"
)
return None
@staticmethod
def extract_subtitles_by_lang(
video_path: str, lang: str | list = "en", ffmpeg: str = "ffmpeg"
) -> list[dict]:
"""
提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。
"""
def check_lang(track_lang: str) -> bool:
if isinstance(lang, list):
return track_lang in lang
return track_lang == lang
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS", "tx3g"]
subtitles = []
try:
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
for track in media_info.tracks:
if (
track.track_type == "Text"
and check_lang(track_lang=track.language)
and track.codec_id in supported_codec
):
subtitle_stream_index = (
track.stream_identifier
) # MediaInfo 的 stream_id 从 1 开始ffmpeg 从 0 开始
extracted_subtitle = SubtitleHelper.__extract_subtitle(
video_path, subtitle_stream_index, ffmpeg
)
duration = 0
if hasattr(track, "duration"):
try:
duration = int(float(track.duration))
except (ValueError, TypeError):
pass
if extracted_subtitle:
subtitles.append(
{
"title": track.title or "",
"subtitle": extracted_subtitle,
"codec_id": track.codec_id,
"stream_id": subtitle_stream_index,
"duration": duration,
}
)
if subtitles:
# remove outliers with abnormally short duration
if len(subtitles) > 1:
durations = [sub["duration"] for sub in subtitles if sub["duration"] > 0]
if durations:
avg_duration = sum(durations) / len(durations)
subtitles = [
sub for sub in subtitles if sub["duration"] >= avg_duration * 0.2
]
if not subtitles:
logger.warn("未找到标记为英语的文本字幕流")
except FileNotFoundError:
logger.error(f"找不到视频文件 '{video_path}'")
except subprocess.CalledProcessError as e:
logger.error(f"错误:提取字幕失败。\n错误信息:{e}")
logger.error(f"FFmpeg 输出 (stderr):\n{e.stderr}")
except Exception as e:
logger.error(f"使用 MediaInfo 提取字幕时发生错误:{e}")
return subtitles
@staticmethod
def replace_by_plaintext_positions(line: SSAEvent, replacements: list[dict]):
"""
使用 replacements 中的 plaintext 位置信息, 替换 line.text 中的内容。
:param line: SSAEvent line
:param replacements: [{'start': int, 'end': int, 'old_text': str, 'new_text': str}, ...]
"""
text = line.text
tag_pattern = re.compile(r"{.*?}") # 匹配 {xxx} 格式控制符
special_pattern = re.compile(r"\\[Nh]")
# 构建 plaintext 位置到 text 索引的映射
mapping = {} # plaintext_index -> text_index
p_index = 0 # 当前 plaintext 索引
t_index = 0 # 当前 text 索引
while t_index < len(text):
if text[t_index] == "{":
# 跳过格式标签
match = tag_pattern.match(text, t_index)
if match:
t_index = match.end()
continue
elif text[t_index] == "\\":
match = special_pattern.match(text, t_index)
if match:
t_index = match.end() - 1
continue
# 非格式字符
mapping[p_index] = t_index
p_index += 1
t_index += 1
replacements = SubtitleHelper.remove_substring(replacements)
# 按照 mapping 执行替换(倒序替换防止位置错位)
new_text = text
for r in sorted(replacements, key=lambda x: x["start"], reverse=True):
start = mapping.get(r["start"])
end = mapping.get(r["end"] - 1)
if start is None or end is None:
continue
end += 1
new_text = new_text[:start] + r["new_text"] + new_text[end:]
line.text = new_text
@staticmethod
def hex_to_rgb(hex_color: str | None) -> tuple[int, ...] | None:
if not hex_color:
return None
pattern = r"^#[0-9a-fA-F]{6}$"
if re.match(pattern, hex_color) is None:
return None
hex_color = hex_color.lstrip("#") # 去掉前面的 #
return tuple(int(hex_color[i: i + 2], 16) for i in (0, 2, 4))
class SubtitleProcessor:
def __init__(self):
self._events: list[SSAEvent] = []

View File

@@ -1,7 +1,7 @@
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from typing import List, Tuple, Dict, Any
from typing import List, Tuple, Dict, Any, Optional
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
@@ -27,7 +27,7 @@ class LibraryScraper(_PluginBase):
# 插件图标
plugin_icon = "scraper.png"
# 插件版本
plugin_version = "2.1.1"
plugin_version = "2.1.3"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -51,6 +51,9 @@ class LibraryScraper(_PluginBase):
_exclude_paths = ""
# 退出事件
_event = Event()
# 刮削目标类型
_target_dir = "dir"
_target_file = "file"
def init_plugin(self, config: dict = None):
@@ -302,7 +305,7 @@ class LibraryScraper(_PluginBase):
exclude_paths = self._exclude_paths.split("\n")
# 已选择的目录
paths = self._scraper_paths.split("\n")
# 需要削的媒体文件
# 需要削的媒体目录或文件
scraper_paths = []
for path in paths:
if not path:
@@ -339,38 +342,116 @@ class LibraryScraper(_PluginBase):
if exclude_flag:
logger.debug(f"{file_path} 在排除目录中,跳过 ...")
continue
# 识别是电影还是电视剧
if not mtype:
file_meta = MetaInfoPath(file_path)
mtype = file_meta.type
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 计算重命名中的文件夹层数
rename_format_level = len(rename_format.split("/")) - 1
if rename_format_level < 1:
if mtype and not self.__match_forced_type_path(
file_path=file_path,
scraper_path=scraper_path,
mtype=mtype
):
logger.debug(f"{file_path} 不属于强制指定的{mtype.value}目录,跳过 ...")
continue
# 取相对路径的第1层目录
media_path = file_path.parents[rename_format_level - 1]
dir_item = (media_path, mtype)
if dir_item not in scraper_paths:
logger.info(f"发现目录:{dir_item}")
scraper_paths.append(dir_item)
# 识别是电影还是电视剧,强制类型只作为默认值,不污染后续文件识别结果
file_meta = MetaInfoPath(file_path)
file_mtype = mtype
if not file_mtype:
file_mtype = file_meta.type
if file_mtype == MediaType.UNKNOWN:
file_mtype = self.__infer_type_from_path(file_path=file_path, scraper_path=scraper_path)
scraper_item = self.__get_scrape_item(
file_path=file_path,
scraper_path=scraper_path,
mtype=file_mtype,
tmdbid=file_meta.tmdbid
)
if scraper_item and not self.__contains_scrape_item(scraper_paths, scraper_item):
logger.info(f"发现刮削目标:{scraper_item}")
scraper_paths.append(scraper_item)
# 开始刮削
if scraper_paths:
for item in scraper_paths:
logger.info(f"开始刮削目{item[0]} ...")
self.__scrape_dir(path=item[0], mtype=item[1])
logger.info(f"开始刮削目{item[0]} ...")
self.__scrape_path(path=item[0], mtype=item[1], target_type=item[2], tmdbid=item[3])
else:
logger.info(f"未发现需要刮削的目录")
def __scrape_dir(self, path: Path, mtype: MediaType):
@staticmethod
def __get_scrape_item(
file_path: Path,
scraper_path: Path,
mtype: MediaType,
tmdbid: Optional[int] = None
) -> Optional[Tuple[Path, MediaType, str, Optional[int]]]:
"""
削刮一个目录,该目录必须是媒体文件目录
根据扫描根目录和重命名格式,计算真正需要刮削的媒体目录
分类目录通常位于扫描根目录下方,必须用相对路径计算,否则会被误当成媒体目录。
"""
# 优先读取本地nfo文件
tmdbid = None
if mtype == MediaType.MOVIE:
if not file_path or not scraper_path or not mtype:
return None
rename_format = settings.TV_RENAME_FORMAT if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
rename_format_level = len(rename_format.strip("/").split("/")) - 1
try:
relative_path = file_path.relative_to(scraper_path)
except ValueError:
relative_path = Path(file_path.name)
if rename_format_level >= 1:
relative_parts = Path(relative_path).parts
# 重命名格式中包含几层目录,就从文件往上取几层目录;前缀分类目录不会参与计算。
if len(relative_parts) > rename_format_level:
media_path = scraper_path.joinpath(*relative_parts[:-rename_format_level])
return media_path, mtype, LibraryScraper._target_dir, tmdbid
# 扁平目录或自定义重命名格式无目录层级时,退回到单文件刮削,避免分类目录识别失败。
return file_path, mtype, LibraryScraper._target_file, tmdbid
@staticmethod
def __contains_scrape_item(scraper_paths: List[Tuple[Path, MediaType, str, Optional[int]]],
scraper_item: Tuple[Path, MediaType, str, Optional[int]]) -> bool:
"""
判断刮削目标是否已存在同一目标只刮削一次tmdbid 仅作为识别辅助信息。
"""
return any(item[:3] == scraper_item[:3] for item in scraper_paths)
@staticmethod
def __match_forced_type_path(file_path: Path, scraper_path: Path, mtype: MediaType) -> bool:
"""
强制指定媒体类型时,如果扫描根目录下同时存在“电影/电视剧”分类,则只处理匹配类型的目录。
"""
if mtype not in (MediaType.MOVIE, MediaType.TV):
return True
try:
relative_parts = file_path.relative_to(scraper_path).parts
except ValueError:
return True
media_type_parts = {MediaType.MOVIE.value, MediaType.TV.value}.intersection(relative_parts)
return not media_type_parts or mtype.value in media_type_parts
@staticmethod
def __infer_type_from_path(file_path: Path, scraper_path: Path) -> MediaType:
"""
文件名无法识别类型时,从扫描根目录下的“电影/电视剧”分类层推断媒体类型。
"""
try:
relative_parts = file_path.relative_to(scraper_path).parts
except ValueError:
relative_parts = file_path.parts
if MediaType.TV.value in relative_parts:
return MediaType.TV
if MediaType.MOVIE.value in relative_parts:
return MediaType.MOVIE
return MediaType.UNKNOWN
def __scrape_path(self, path: Path, mtype: MediaType, target_type: str = _target_dir,
tmdbid: Optional[int] = None):
"""
刮削一个媒体目录或媒体文件
"""
# 优先读取本地nfo文件文件路径中解析出的 tmdbid 作为兜底识别信息保留。
if target_type == self._target_file:
nfo_path = path.with_suffix(".nfo")
if nfo_path.exists():
tmdbid = self.__get_tmdbid_from_nfo(nfo_path)
elif mtype == MediaType.MOVIE:
# 电影
movie_nfo = path / "movie.nfo"
if movie_nfo.exists():
@@ -393,6 +474,10 @@ class LibraryScraper(_PluginBase):
meta.type = mtype
mediainfo = self.chain.recognize_media(meta=meta)
if not mediainfo:
if target_type == self._target_dir:
# 目录名无法识别时,通常是分类目录,继续尝试其中的具体媒体文件。
self.__scrape_child_files(path=path, mtype=mtype)
return
logger.warn(f"未识别到媒体信息:{path}")
return
@@ -405,13 +490,17 @@ class LibraryScraper(_PluginBase):
# 获取图片
self.chain.obtain_images(mediainfo)
# 刮削
item_path = str(path).replace("\\", "/")
if target_type == self._target_dir:
item_path = f"{item_path}/"
MediaChain().scrape_metadata(
fileitem=schemas.FileItem(
storage="local",
type="dir",
path=str(path).replace("\\", "/") + "/",
type=target_type,
path=item_path,
name=path.name,
basename=path.stem,
extension=path.suffix[1:] if target_type == self._target_file else None,
modify_time=path.stat().st_mtime,
),
mediainfo=mediainfo,
@@ -419,6 +508,26 @@ class LibraryScraper(_PluginBase):
)
logger.info(f"{path} 刮削完成")
def __scrape_child_files(self, path: Path, mtype: MediaType):
"""
分类目录无法作为单个媒体识别时,继续按目录内的媒体文件逐个刮削。
"""
child_files = SystemUtils.list_files(path, settings.RMT_MEDIAEXT)
if not child_files:
logger.warn(f"未识别到媒体信息:{path}")
return
logger.info(f"{path} 可能是分类目录,开始刮削目录内媒体文件 ...")
for child_file in child_files:
if self._event.is_set():
logger.info(f"媒体库刮削服务停止")
return
child_mtype = mtype
child_meta = MetaInfoPath(child_file)
if not child_mtype:
child_mtype = child_meta.type
self.__scrape_path(path=child_file, mtype=child_mtype, target_type=self._target_file,
tmdbid=child_meta.tmdbid)
@staticmethod
def __get_tmdbid_from_nfo(file_path: Path):
"""

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ class MediaServerMsg(_PluginBase):
# 插件图标
plugin_icon = "mediaplay.png"
# 插件版本
plugin_version = "1.8.2.2"
plugin_version = "1.8.2.3"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -67,6 +67,7 @@ class MediaServerMsg(_PluginBase):
# Webhook事件映射配置
_webhook_actions = {
"library.new": "新入库",
"ItemAdded": "新入库",
"system.notificationtest": "测试",
"playback.start": "开始播放",
"playback.stop": "停止播放",
@@ -79,6 +80,11 @@ class MediaServerMsg(_PluginBase):
"item.rate": "标记了"
}
# Jellyfin Webhook 新增媒体事件使用 ItemAdded与通用入库事件按同一类型处理。
_webhook_event_aliases = {
"ItemAdded": "library.new"
}
# 媒体服务器默认图标
_webhook_images = {
"emby": "https://emby.media/notificationicon.png",
@@ -188,7 +194,7 @@ class MediaServerMsg(_PluginBase):
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
types_options = [
{"title": "新入库", "value": "library.new"},
{"title": "新入库", "value": "library.new|ItemAdded"},
{"title": "开始播放", "value": "playback.start|media.play|PlaybackStart"},
{"title": "停止播放", "value": "playback.stop|media.stop|PlaybackStop"},
{"title": "用户标记", "value": "item.rate"},
@@ -427,7 +433,9 @@ class MediaServerMsg(_PluginBase):
# 检查事件类型是否在支持范围内
event_type = getattr(event_info, 'event', None)
if not event_type or not self._webhook_actions.get(event_type):
event_action_type = self._get_event_action_type(event_type)
event_match_types = self._get_event_match_types(event_type)
if not event_type or not self._webhook_actions.get(event_action_type):
logger.debug(f"事件类型 {event_type} 不在支持范围内")
return
@@ -437,7 +445,7 @@ class MediaServerMsg(_PluginBase):
for _type in self._types:
allowed_types.update(_type.split("|"))
if event_type not in allowed_types:
if not event_match_types.intersection(allowed_types):
logger.debug(f"事件类型 {event_type} 不在用户配置的允许范围内{allowed_types}")
logger.info(f"未开启 {event_type} 类型的消息通知")
return
@@ -460,8 +468,8 @@ class MediaServerMsg(_PluginBase):
# 通用去重:构造去重键
item_id = getattr(event_info, 'item_id', '')
if item_id:
# 使用 server_name + event_type + item_id 作为唯一标识
dedupe_key = f"{server_name}-{event_type}-{item_id}" if server_name else f"{event_type}-{item_id}"
# 使用标准化后的事件类型去重,避免同类事件别名造成重复通知。
dedupe_key = f"{server_name}-{event_action_type}-{item_id}" if server_name else f"{event_action_type}-{item_id}"
# 检查是否已处理过该事件
if dedupe_key in self.__get_elements():
logger.debug(f"检测到重复Webhook事件已处理过: {dedupe_key}")
@@ -477,7 +485,7 @@ class MediaServerMsg(_PluginBase):
if not self._aggregate_enabled:
return False
if event_type != "library.new":
if event_action_type != "library.new":
return False
item_type = getattr(event_info, 'item_type', None)
@@ -520,7 +528,7 @@ class MediaServerMsg(_PluginBase):
item_name = getattr(event_info, 'item_name', '')
message_title = ""
event_action = self._webhook_actions.get(event_type, event_type)
event_action = self._webhook_actions.get(event_action_type, event_type)
if item_type in ["TV", "SHOW"]:
message_title = f"{event_action}剧集 {item_name}"
elif item_type == "MOV":
@@ -841,7 +849,7 @@ class MediaServerMsg(_PluginBase):
if not first_event.tmdb_id:
logger.debug("tmdb_id为空使用原有逻辑发送消息")
# 使用原有逻辑构造消息
message_title = f"📺 {self._webhook_actions.get(first_event.event)}剧集:{first_event.item_name}"
message_title = f"📺 {self._get_event_action(first_event.event)}剧集:{first_event.item_name}"
message_texts = []
message_texts.append(
f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
@@ -908,7 +916,7 @@ class MediaServerMsg(_PluginBase):
except Exception as e:
logger.error(f"从json_object提取SeriesName时出错: {str(e)}")
message_title = f"📺 {self._webhook_actions.get(first_event.event, '新入库')}剧集:{show_name}"
message_title = f"📺 {self._get_event_action(first_event.event) or '新入库'}剧集:{show_name}"
if is_multiple_episodes:
message_title += f" {events_count}个文件"
@@ -1215,6 +1223,29 @@ class MediaServerMsg(_PluginBase):
logger.error(f"获取有效元素时出错: {str(e)}")
return []
def _get_event_action_type(self, event_type: Optional[str]) -> Optional[str]:
"""
获取用于消息文案和去重的标准事件类型。
"""
if event_type is None:
return None
return self._webhook_event_aliases.get(str(event_type), str(event_type))
def _get_event_match_types(self, event_type: Optional[str]) -> set:
"""
获取配置匹配时允许命中的事件类型,兼容历史配置和媒体服务器原始事件。
"""
if event_type is None:
return set()
normalized_type = self._get_event_action_type(event_type)
return {str(event_type), normalized_type}
def _get_event_action(self, event_type: Optional[str]) -> Optional[str]:
"""
获取事件对应的消息动作文案。
"""
return self._webhook_actions.get(self._get_event_action_type(event_type))
def _get_play_link(self, event_info: WebhookEventInfo) -> Optional[str]:
"""
获取媒体项目的播放链接

View File

@@ -1,2 +1,8 @@
# oidcauth plugin - source only
src/
package.json
package-lock.json
node_modules/
vite.config.js
node_modules/
index.dev.html
dist/

View File

@@ -0,0 +1,62 @@
# OidcAuth 更新日志
## v0.3.1
### 后端 `__init__.py`Code Review 修复)
- 修复回调事件类型不匹配:绑定流程错误时 `event_type` 动态设为 `oidcauth_bind_callback`,避免前端仅显示通用提示
- 移除解绑方法中多余的 `_ensure_login_ready()` 检查,允许 OIDC 关闭状态下正常解绑
---
## v0.3.0
### 前端 `AppPage.vue`(完全重构)
- **双栏布局重构**:废弃旧版单页卡片,改为左侧特性介绍 + 右侧绑定状态的现代化双栏布局
- **动态背景装饰**:渐变光斑、浮动光点动画组成的沉浸式背景层
- **三步绑定可视化**:绑定流程拆分为 3 步(跳转 IdP → 完成认证 → 自动绑定),每步支持 pending / loading / done / error 四种状态 + 动态图标
- **深色/浅色主题自适应**`MutationObserver` 监听 Vuetify 主题变化,自动切换完整深/浅配色方案
- **postMessage 通信增强**:弹窗绑定改用 `postMessage` 事件驱动 + 1s 轮询兜底,替换旧版纯轮询方式
- **已绑定详情卡片**展示绑定用户、OIDC Subject脱敏截断、认证状态绿色"有效"标识)+ 用户名备注
- **解绑确认流程**:新增两步确认(点击解绑 → 确认/取消),防止误操作
- **功能开关感知**OIDC 关闭时显式展示黄色警告条 + 绑定/解绑按钮自动 disabled
- **四个特性介绍卡片**:左侧新增单点登录、免密认证、统一账号、安全可靠四张彩色特性卡片
- **底部信息栏**:版权提示 + 插件版本号展示
### 后端 `__init__.py`
- 图标由 `Authelia_A.png` 改为 `Oidcauth_A.png`
- 版本号 `0.2.0``0.3.0`
---
## v0.2.0
### 后端 `__init__.py`
- **修复 PROXY_HOST 为空时崩溃**:所有 `proxy=settings.PROXY_HOST` 改为 `proxy=settings.PROXY_HOST or None`3 处)
- **回调 HTML 美化**:从极简白屏升级为带关闭按钮 ✕、居中排版、200ms 延迟自动关闭的友好页面
- **补充配置表单使用指南**`status()` 接口新增 `redirect_uri``masked_sub` 等字段
### 前端 `AuthPage.vue`
- **挂载后自动跳转 OIDC 授权**`onMounted` 自动调用 `checkAndStart()`,免去手动点击按钮
- **加载动画**:新增 `checking` 状态 + `VProgressCircular` 旋转指示器
- **错误重试机制**:错误时展示"重试"按钮,点击可重新发起认证
- **增强错误信息**:新增"管理员未启用 OIDC 认证"、"无法连接到认证服务"等精确提示
- **`messageReceived` 防误判**:弹窗关闭时检查是否已收到 postMessage避免误报"认证窗口已关闭"
---
## v0.1.0
### 首次发布
- **OIDC 授权码流程登录**:支持标准 OIDC Authorization Code Flow
- **账号绑定/解绑**:已登录用户可绑定 OIDC 身份,支持解绑
- **Provider 配置**:支持任意兼容标准 OIDC 协议的服务Authelia、Keycloak、Casdoor 等)
- **联邦认证界面**:基于 Vue 3 + Vite Federation 的前端组件
- **登录票据认证桥接**`create_plugin_auth_ticket` 与 MoviePilot 认证系统集成
- **图标**`plugin_icon``Authelia_A.png`
- **作者信息**`plugin_author``ui-beam-9,jxxghp`

View File

@@ -25,9 +25,11 @@ class OidcAuth(_PluginBase):
"""
plugin_name = "OIDC 认证"
plugin_desc = "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。"
plugin_icon = "Authelia_A.png"
plugin_version = "0.1.0"
plugin_desc = (
"通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。"
)
plugin_icon = "Oidcauth_A.png"
plugin_version = "0.3.1"
plugin_author = "ui-beam-9,jxxghp"
author_url = "https://github.com/ui-beam-9"
plugin_label = "认证,OIDC,SSO"
@@ -144,7 +146,7 @@ class OidcAuth(_PluginBase):
:return: 渲染模式与构建产物路径
"""
return "vue", "dist/assets"
return "vue", "assets"
def get_auth_providers(self) -> List[Dict[str, Any]]:
"""
@@ -183,6 +185,7 @@ class OidcAuth(_PluginBase):
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""
声明插件侧栏管理入口。
不设置 permission 限制,所有登录用户均可访问。
:return: 侧栏导航项
"""
@@ -194,7 +197,6 @@ class OidcAuth(_PluginBase):
"title": "OIDC 认证",
"icon": "mdi-openid",
"section": "system",
"permission": "admin",
"order": 47,
}
]
@@ -231,7 +233,9 @@ class OidcAuth(_PluginBase):
self._ensure_login_ready()
state = self._create_state(action="login")
redirect_uri = self._callback_url(request)
authorize_url = await self._build_authorize_url(redirect_uri=redirect_uri, state=state)
authorize_url = await self._build_authorize_url(
redirect_uri=redirect_uri, state=state
)
return RedirectResponse(authorize_url)
async def callback(
@@ -255,26 +259,37 @@ class OidcAuth(_PluginBase):
if error:
return self._callback_html(False, "oidc_error", error_description or error)
if not code or not state:
return self._callback_html(False, "oidc_invalid_callback", "OIDC 回调参数不完整")
return self._callback_html(
False, "oidc_invalid_callback", "OIDC 回调参数不完整"
)
state_data = self._pop_state(state)
if not state_data:
return self._callback_html(False, "oidc_invalid_state", "OIDC state 无效或已过期")
return self._callback_html(
False, "oidc_invalid_state", "OIDC state 无效或已过期"
)
action = state_data.get("action")
event_type = "oidcauth_bind_callback" if action == "bind" else "oidcauth_callback"
try:
redirect_uri = self._callback_url(request)
token_data = await self._exchange_code(code=code, redirect_uri=redirect_uri)
userinfo = await self._fetch_userinfo(token_data)
sub = str(userinfo.get("sub") or "")
if not sub:
return self._callback_html(False, "oidc_no_sub", "OIDC 用户信息缺少 sub")
action = state_data.get("action")
return self._callback_html(
False, "oidc_no_sub", "OIDC 用户信息缺少 sub", event_type=event_type
)
if action == "bind":
return self._handle_bind_callback(state_data=state_data, userinfo=userinfo, sub=sub)
return self._handle_bind_callback(
state_data=state_data, userinfo=userinfo, sub=sub
)
return self._handle_login_callback(userinfo=userinfo, sub=sub)
except Exception as err:
logger.error(f"OIDC 回调处理失败: {err}", exc_info=True)
return self._callback_html(False, "oidc_error", str(err))
return self._callback_html(False, "oidc_error", str(err), event_type=event_type)
def status(self, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
def status(
self, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
查询当前用户绑定状态和管理员配置。
@@ -290,17 +305,22 @@ class OidcAuth(_PluginBase):
},
"binding": {
"bound": bool(binding),
"sub": (binding or {}).get("sub"),
"masked_sub": self._mask_sub((binding or {}).get("sub")),
"username": (binding or {}).get("username"),
"email": (binding or {}).get("email"),
"local_username": current_user.name,
},
"plugin_version": self.plugin_version,
"is_superuser": bool(current_user.is_superuser),
}
if current_user.is_superuser:
data["config"] = self._config.copy()
return schemas.Response(success=True, data=data)
def save_config_api(self, config: dict, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
def save_config_api(
self, config: dict, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
保存 OIDC 插件配置。
@@ -314,9 +334,12 @@ class OidcAuth(_PluginBase):
self._config = normalized
self._enabled = bool(normalized.get("enabled"))
self.update_config(normalized)
return schemas.Response(success=True, data={"config": normalized})
async def test_api(self, body: dict, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
async def test_api(
self, body: dict, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
测试 OIDC Provider 发现文档。
@@ -331,11 +354,17 @@ class OidcAuth(_PluginBase):
discovery = await self._get_discovery(test_config)
missing = [
key
for key in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint")
for key in (
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
)
if not discovery.get(key)
]
if missing:
return schemas.Response(success=False, message=f"发现文档缺少端点: {', '.join(missing)}")
return schemas.Response(
success=False, message=f"发现文档缺少端点: {', '.join(missing)}"
)
return schemas.Response(success=True, message="OIDC Provider 连接正常")
except Exception as err:
return schemas.Response(success=False, message=str(err))
@@ -357,10 +386,14 @@ class OidcAuth(_PluginBase):
return schemas.Response(success=False, message="当前用户已绑定 OIDC 账号")
state = self._create_state(action="bind", user_id=current_user.id)
redirect_uri = self._callback_url(request)
authorize_url = await self._build_authorize_url(redirect_uri=redirect_uri, state=state)
authorize_url = await self._build_authorize_url(
redirect_uri=redirect_uri, state=state
)
return schemas.Response(success=True, data={"authorize_url": authorize_url})
def unbind(self, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
def unbind(
self, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
解绑当前用户的 OIDC 账号。
@@ -370,7 +403,9 @@ class OidcAuth(_PluginBase):
binding = self._get_user_binding(current_user.id)
if not binding:
return schemas.Response(success=False, message="当前用户未绑定 OIDC 账号")
self.del_data(self._sub_key(binding.get("issuer") or "", binding.get("sub") or ""))
self.del_data(
self._sub_key(binding.get("issuer") or "", binding.get("sub") or "")
)
self.del_data(self._user_key(current_user.id))
return schemas.Response(success=True)
@@ -389,9 +424,13 @@ class OidcAuth(_PluginBase):
"client_secret": str(config.get("client_secret") or ""),
"scopes": str(config.get("scopes") or "openid profile email").strip(),
"redirect_uri": str(config.get("redirect_uri") or "").strip(),
"username_claim": str(config.get("username_claim") or "preferred_username").strip(),
"username_claim": str(
config.get("username_claim") or "preferred_username"
).strip(),
"email_claim": str(config.get("email_claim") or "email").strip(),
"allow_auto_bind_by_username": bool(config.get("allow_auto_bind_by_username")),
"allow_auto_bind_by_username": bool(
config.get("allow_auto_bind_by_username")
),
}
def _is_login_ready(self) -> bool:
@@ -430,7 +469,9 @@ class OidcAuth(_PluginBase):
if issuer.endswith("/.well-known/openid-configuration")
else f"{issuer}/.well-known/openid-configuration"
)
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
async with httpx.AsyncClient(
timeout=10.0, proxy=settings.PROXY_HOST or None
) as client:
response = await client.get(discovery_url)
response.raise_for_status()
return response.json()
@@ -468,7 +509,9 @@ class OidcAuth(_PluginBase):
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise ValueError("OIDC 发现文档缺少 token_endpoint")
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
async with httpx.AsyncClient(
timeout=10.0, proxy=settings.PROXY_HOST or None
) as client:
response = await client.post(
token_endpoint,
data={
@@ -497,8 +540,12 @@ class OidcAuth(_PluginBase):
userinfo_endpoint = discovery.get("userinfo_endpoint")
if not userinfo_endpoint:
raise ValueError("OIDC 发现文档缺少 userinfo_endpoint")
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
response = await client.get(userinfo_endpoint, headers={"Authorization": f"Bearer {access_token}"})
async with httpx.AsyncClient(
timeout=10.0, proxy=settings.PROXY_HOST or None
) as client:
response = await client.get(
userinfo_endpoint, headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()
return response.json()
@@ -512,11 +559,15 @@ class OidcAuth(_PluginBase):
"""
issuer = self._config.get("issuer") or ""
binding = self.get_data(self._sub_key(issuer, sub))
user = User.get(db=None, rid=(binding or {}).get("user_id")) if binding else None
user = (
User.get(db=None, rid=(binding or {}).get("user_id")) if binding else None
)
if not user and self._config.get("allow_auto_bind_by_username"):
user = self._auto_bind_by_username(userinfo=userinfo, sub=sub)
if not user:
return self._callback_html(False, "oidc_unbound", "该 OIDC 账号尚未绑定 MoviePilot 用户")
return self._callback_html(
False, "oidc_unbound", "该 OIDC 账号尚未绑定 MoviePilot 用户"
)
if not user.is_active:
return self._callback_html(False, "user_inactive", "用户已被禁用")
ticket = create_plugin_auth_ticket(
@@ -526,7 +577,9 @@ class OidcAuth(_PluginBase):
)
return self._callback_html(True, data={"ticket": ticket})
def _handle_bind_callback(self, state_data: dict, userinfo: dict, sub: str) -> HTMLResponse:
def _handle_bind_callback(
self, state_data: dict, userinfo: dict, sub: str
) -> HTMLResponse:
"""
处理 OIDC 绑定回调。
@@ -538,17 +591,34 @@ class OidcAuth(_PluginBase):
user_id = state_data.get("user_id")
user = User.get(db=None, rid=user_id) if user_id else None
if not user or not user.is_active:
return self._callback_html(False, "bind_user_invalid", "绑定用户不存在或已禁用", event_type="oidcauth_bind_callback")
return self._callback_html(
False,
"bind_user_invalid",
"绑定用户不存在或已禁用",
event_type="oidcauth_bind_callback",
)
issuer = self._config.get("issuer") or ""
existing = self.get_data(self._sub_key(issuer, sub))
if existing and existing.get("user_id") != user.id:
return self._callback_html(False, "bind_conflict", "该 OIDC 账号已绑定其他用户", event_type="oidcauth_bind_callback")
return self._callback_html(
False,
"bind_conflict",
"该 OIDC 账号已绑定其他用户",
event_type="oidcauth_bind_callback",
)
if self._get_user_binding(user.id):
return self._callback_html(False, "already_bound", "当前用户已绑定 OIDC 账号", event_type="oidcauth_bind_callback")
return self._callback_html(
False,
"already_bound",
"当前用户已绑定 OIDC 账号",
event_type="oidcauth_bind_callback",
)
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
self.save_data(self._user_key(user.id), binding)
self.save_data(self._sub_key(issuer, sub), binding)
return self._callback_html(True, data={"bound": True}, event_type="oidcauth_bind_callback")
return self._callback_html(
True, data={"bound": True}, event_type="oidcauth_bind_callback"
)
def _auto_bind_by_username(self, userinfo: dict, sub: str) -> Optional[User]:
"""
@@ -558,7 +628,10 @@ class OidcAuth(_PluginBase):
:param sub: OIDC subject
:return: 绑定成功的用户
"""
username = str(userinfo.get(self._config.get("username_claim") or "preferred_username") or "").strip()
username = str(
userinfo.get(self._config.get("username_claim") or "preferred_username")
or ""
).strip()
if not username:
return None
user = User.get_by_name(db=None, name=username)
@@ -583,7 +656,9 @@ class OidcAuth(_PluginBase):
"user_id": user_id,
"issuer": self._config.get("issuer") or "",
"sub": sub,
"username": userinfo.get(self._config.get("username_claim") or "preferred_username"),
"username": userinfo.get(
self._config.get("username_claim") or "preferred_username"
),
"email": userinfo.get(self._config.get("email_claim") or "email"),
"updated_at": int(time.time()),
}
@@ -655,7 +730,10 @@ class OidcAuth(_PluginBase):
:return: 回调地址或默认路径
"""
return self._config.get("redirect_uri") or f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
return (
self._config.get("redirect_uri")
or f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
)
def _get_user_binding(self, user_id: int) -> Optional[dict]:
"""
@@ -729,16 +807,28 @@ class OidcAuth(_PluginBase):
payload_json = json.dumps(payload, ensure_ascii=False)
html = f"""<!doctype html>
<html>
<head><meta charset="utf-8"><title>OIDC Callback</title></head>
<head>
<meta charset="utf-8">
<title>OIDC Callback</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }}
.close-btn {{ position: fixed; top: 12px; right: 16px; width: 36px; height: 36px; border: none; background: rgba(0,0,0,0.06); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #666; transition: background 0.2s, color 0.2s; }}
.close-btn:hover {{ background: rgba(0,0,0,0.12); color: #333; }}
.msg {{ padding: 24px; text-align: center; color: #333; font-size: 16px; }}
</style>
</head>
<body>
<button class="close-btn" onclick="window.close()" title="关闭">&#x2715;</button>
<div class="msg" id="msg"></div>
<script>
(function() {{
var payload = {payload_json};
if (window.opener && !window.opener.closed) {{
window.opener.postMessage(payload, window.location.origin);
window.close();
setTimeout(function() {{ window.close(); }}, 200);
}} else {{
document.body.innerText = payload.success ? '认证成功,请关闭此窗口' : (payload.message || '认证失败');
document.getElementById('msg').innerText = payload.success ? '认证成功,请关闭此窗口' : (payload.message || '认证失败');
}}
}})();
</script>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,842 @@
.oidc-page[data-v-8a889949] {
position: relative;
height: 100vh;
max-height: 100vh;
box-sizing: border-box;
padding: 24px 32px 16px;
color: #e4e4e7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #0c0c10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 背景装饰层 */
.oidc-bg-decor[data-v-8a889949] {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
}
/* 大光斑 */
.oidc-bg-blob[data-v-8a889949] {
position: absolute;
border-radius: 50%;
}
.oidc-bg-blob-1[data-v-8a889949] {
top: -160px;
left: -160px;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(109, 40, 217, 0.18) 0%, transparent 70%);
filter: blur(120px);
}
.oidc-bg-blob-2[data-v-8a889949] {
bottom: -160px;
right: -80px;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(79, 70, 229, 0.14) 0%, transparent 70%);
filter: blur(100px);
}
/* 网格 */
.oidc-bg-grid[data-v-8a889949] {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
background-size: 48px 48px;
}
/* 浮动光点 */
.oidc-bg-orb[data-v-8a889949] {
position: absolute;
border-radius: 50%;
animation: orb-float-8a889949 var(--orb-dur, 6s) ease-in-out infinite;
animation-delay: var(--orb-delay, 0s);
}
.oidc-bg-orb-1[data-v-8a889949] {
--orb-dur: 6s;
--orb-delay: 0s;
top: 25%;
left: 10%;
width: 8px;
height: 8px;
background: rgba(167, 139, 250, 0.4);
}
.oidc-bg-orb-2[data-v-8a889949] {
--orb-dur: 8s;
--orb-delay: 1s;
top: 33%;
right: 12%;
width: 6px;
height: 6px;
background: rgba(129, 140, 248, 0.4);
}
.oidc-bg-orb-3[data-v-8a889949] {
--orb-dur: 7s;
--orb-delay: 2.5s;
bottom: 33%;
left: 20%;
width: 4px;
height: 4px;
background: rgba(196, 181, 253, 0.5);
}
@keyframes orb-float-8a889949 {
0%, 100% { transform: translateY(0); opacity: 0.4;
}
50% { transform: translateY(calc(var(--orb-range, -20px))); opacity: 0.7;
}
}
.oidc-bg-orb-1[data-v-8a889949] { --orb-range: -20px;
}
.oidc-bg-orb-2[data-v-8a889949] { --orb-range: 16px;
}
.oidc-bg-orb-3[data-v-8a889949] { --orb-range: -12px;
}
/* 主内容区 */
.oidc-main[data-v-8a889949] {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 28px;
width: 100%;
max-width: 1024px;
align-items: stretch;
}
@media (max-width: 900px) {
.oidc-main[data-v-8a889949] {
grid-template-columns: 1fr;
}
}
/* 卡片通用 */
.oidc-card[data-v-8a889949] {
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 24px;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* 左侧 */
.oidc-left-header[data-v-8a889949] {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.oidc-left-icon[data-v-8a889949] {
width: 56px;
height: 56px;
border-radius: 16px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 0 28px rgba(124, 58, 237, 0.3);
}
.oidc-left-titles[data-v-8a889949] {
min-width: 0;
}
.oidc-left-title[data-v-8a889949] {
font-size: 22px;
font-weight: 600;
color: #fff;
margin: 0;
line-height: 1.2;
letter-spacing: -0.02em;
}
.oidc-left-sub[data-v-8a889949] {
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
margin: 4px 0 0;
line-height: 1.3;
}
.oidc-left-desc[data-v-8a889949] {
font-size: 14px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.7;
margin: 0 0 16px;
max-width: 420px;
}
.oidc-left-tags[data-v-8a889949] {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 16px;
flex-wrap: wrap;
}
.oidc-left-tag[data-v-8a889949] {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 6px;
padding: 5px 12px;
line-height: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.oidc-left-tag-sep[data-v-8a889949] {
width: 20px;
height: 1px;
background: rgba(255, 255, 255, 0.15);
margin: 0 8px;
}
/* 特性卡片 */
.oidc-features[data-v-8a889949] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.feature-card[data-v-8a889949] {
display: flex;
align-items: flex-start;
gap: 10px;
border-radius: 14px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
}
.feature-card.feature-violet[data-v-8a889949] {
background: rgba(124, 58, 237, 0.06);
border-color: rgba(124, 58, 237, 0.15);
}
.feature-card.feature-blue[data-v-8a889949] {
background: rgba(59, 130, 246, 0.06);
border-color: rgba(59, 130, 246, 0.15);
}
.feature-card.feature-green[data-v-8a889949] {
background: rgba(16, 185, 129, 0.06);
border-color: rgba(16, 185, 129, 0.15);
}
.feature-card.feature-amber[data-v-8a889949] {
background: rgba(234, 179, 8, 0.06);
border-color: rgba(234, 179, 8, 0.15);
}
.feature-icon[data-v-8a889949] {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.feature-icon svg[data-v-8a889949] {
width: 16px;
height: 16px;
}
.feature-purple[data-v-8a889949] {
background: rgba(124, 58, 237, 0.12);
color: #a78bfa;
}
.feature-blue-bg[data-v-8a889949] {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
.feature-green-bg[data-v-8a889949] {
background: rgba(16, 185, 129, 0.12);
color: #34d399;
}
.feature-yellow-bg[data-v-8a889949] {
background: rgba(234, 179, 8, 0.12);
color: #facc15;
}
.feature-text[data-v-8a889949] {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.feature-title[data-v-8a889949] {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
.feature-desc[data-v-8a889949] {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.6;
}
/* 右侧 */
.oidc-right[data-v-8a889949] {
display: flex;
flex-direction: column;
}
.oidc-right-top[data-v-8a889949] {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 16px;
}
.oidc-right-bigicon[data-v-8a889949] {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 14px;
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3);
}
.oidc-right-bigicon[data-v-8a889949] svg {
width: 28px;
height: 28px;
}
.oidc-right-title[data-v-8a889949] {
font-size: 18px;
font-weight: 600;
color: #f1f1f5;
margin: 0 0 8px;
line-height: 1.3;
}
.oidc-right-sub[data-v-8a889949] {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
margin: 0;
line-height: 1.3;
}
/* 中间内容区 */
.oidc-right-body[data-v-8a889949] {
flex: 1;
}
/* 未启用警告 */
.oidc-disabled-banner[data-v-8a889949] {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 10px;
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(234, 179, 8, 0.15);
color: #eab308;
font-size: 13px;
font-weight: 500;
}
.oidc-disabled-icon[data-v-8a889949] {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* 步骤流程卡片 */
.oidc-steps[data-v-8a889949] {
display: flex;
flex-direction: column;
gap: 8px;
}
.oidc-step[data-v-8a889949] {
display: flex;
gap: 0;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
transition: background 0.2s ease, border-color 0.2s ease;
}
.oidc-step[data-v-8a889949]:hover {
background: rgba(255, 255, 255, 0.035);
border-color: rgba(255, 255, 255, 0.08);
}
.oidc-step-active[data-v-8a889949] {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.oidc-step-done-step[data-v-8a889949] {
border-color: rgba(16, 185, 129, 0.15);
background: rgba(16, 185, 129, 0.04);
}
.oidc-step-error-step[data-v-8a889949] {
border-color: rgba(239, 68, 68, 0.15);
background: rgba(239, 68, 68, 0.04);
}
.oidc-step-left[data-v-8a889949] {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 14px;
flex-shrink: 0;
padding-top: 2px;
}
.oidc-step-num[data-v-8a889949] {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.25);
transition: background 0.3s ease, box-shadow 0.3s ease;
}
.oidc-num-done[data-v-8a889949] {
background: #10b981 !important;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25) !important;
}
.oidc-num-loading[data-v-8a889949] {
background: rgba(124, 58, 237, 0.4) !important;
box-shadow: none !important;
}
.oidc-num-error[data-v-8a889949] {
background: #ef4444 !important;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25) !important;
}
/* 转圈 spinner */
.oidc-spinner[data-v-8a889949] {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: oidc-spin-8a889949 0.7s linear infinite;
}
@keyframes oidc-spin-8a889949 {
to { transform: rotate(360deg);
}
}
/* 打勾 / 打叉 图标 */
.oidc-step-check-icon[data-v-8a889949] {
width: 14px;
height: 14px;
color: #fff;
}
.oidc-step-x-icon[data-v-8a889949] {
width: 12px;
height: 12px;
color: #fff;
}
.oidc-step-right[data-v-8a889949] {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 4px;
}
.oidc-step-title[data-v-8a889949] {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.75);
line-height: 1.4;
transition: color 0.2s ease;
}
.oidc-step-desc[data-v-8a889949] {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.5;
transition: color 0.2s ease;
}
/* 已绑定 */
.oidc-bound-badge[data-v-8a889949] {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #10b981;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 999px;
padding: 4px 12px;
margin-top: 2px;
}
.oidc-dot[data-v-8a889949] {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 5px rgba(16, 185, 129, 0.4);
flex-shrink: 0;
}
.oidc-info-rows[data-v-8a889949] {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.oidc-info-row[data-v-8a889949] {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.oidc-info-row-label[data-v-8a889949] {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
flex-shrink: 0;
}
.oidc-row-icon[data-v-8a889949] {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.5;
}
.oidc-info-row-value[data-v-8a889949] {
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
margin-left: 12px;
flex: 1;
min-width: 0;
}
.oidc-info-row-status[data-v-8a889949] {
display: inline-flex;
align-items: center;
gap: 5px;
color: #10b981;
font-weight: 500;
margin-left: 12px;
}
.oidc-status-dot[data-v-8a889949] {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
}
.oidc-bound-desc[data-v-8a889949] {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
margin: 0;
line-height: 1.6;
}
/* 底部按钮区 */
.oidc-right-footer[data-v-8a889949] {
margin-top: auto;
padding-top: 12px;
}
/* 按钮 */
.oidc-btn[data-v-8a889949] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 44px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
line-height: 1;
}
.oidc-btn[data-v-8a889949]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.oidc-btn-primary[data-v-8a889949] {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.25);
}
.oidc-btn-primary[data-v-8a889949]:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4);
transform: translateY(-1px);
}
.oidc-btn-primary[data-v-8a889949]:active:not(:disabled) {
transform: translateY(0);
}
.oidc-btn-icon[data-v-8a889949] {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 解绑 */
.oidc-btn-unbind[data-v-8a889949] {
background: rgba(239, 68, 68, 0.08);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.15);
}
.oidc-btn-unbind[data-v-8a889949]:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.25);
}
.oidc-unbind-confirm-text[data-v-8a889949] {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
margin: 0 0 12px;
text-align: center;
}
.oidc-unbind-actions[data-v-8a889949] {
display: flex;
gap: 10px;
}
.oidc-unbind-actions .oidc-btn[data-v-8a889949] {
width: auto;
flex: 1;
}
.oidc-btn-outline[data-v-8a889949] {
background: transparent;
color: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.oidc-btn-outline[data-v-8a889949]:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.18);
}
.oidc-btn-danger[data-v-8a889949] {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.oidc-btn-danger[data-v-8a889949]:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.35);
}
/* 提示 */
.oidc-alert[data-v-8a889949] {
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
font-size: 13px;
text-align: left;
line-height: 1.5;
}
.oidc-alert-error[data-v-8a889949] {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.15);
}
.oidc-alert-success[data-v-8a889949] {
background: rgba(16, 185, 129, 0.1);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.15);
}
/* 底部 */
.oidc-bottom[data-v-8a889949] {
position: relative;
z-index: 1;
width: 100%;
max-width: 1024px;
margin-top: 16px;
}
.oidc-bottom-line[data-v-8a889949] {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin-bottom: 12px;
}
.oidc-bottom-content[data-v-8a889949] {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
}
.oidc-bottom-left[data-v-8a889949] {
display: inline-flex;
align-items: center;
gap: 6px;
}
.oidc-warn-icon[data-v-8a889949] {
width: 14px;
height: 14px;
flex-shrink: 0;
color: rgba(234, 179, 8, 0.6);
}
.oidc-bottom-right[data-v-8a889949] {
color: rgba(255, 255, 255, 0.2);
font-size: 11px;
}
/* 过渡 */
.oidc-fade-enter-active[data-v-8a889949],
.oidc-fade-leave-active[data-v-8a889949] {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.oidc-fade-enter-from[data-v-8a889949],
.oidc-fade-leave-to[data-v-8a889949] {
opacity: 0;
transform: translateY(-4px);
}
/* ===== 浅色主题 ===== */
.oidc-page.oidc-light[data-v-8a889949] {
color: #1f2937;
background: #f1f5f9;
}
.oidc-page.oidc-light .oidc-bg-blob-1[data-v-8a889949] {
background: radial-gradient(circle, rgba(124, 58, 237, 0.08) 0%, transparent 70%);
}
.oidc-page.oidc-light .oidc-bg-blob-2[data-v-8a889949] {
background: radial-gradient(circle, rgba(79, 70, 229, 0.06) 0%, transparent 70%);
}
.oidc-page.oidc-light .oidc-bg-grid[data-v-8a889949] {
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
}
.oidc-page.oidc-light .oidc-bg-orb-1[data-v-8a889949] { background: rgba(124, 58, 237, 0.12);
}
.oidc-page.oidc-light .oidc-bg-orb-2[data-v-8a889949] { background: rgba(79, 70, 229, 0.1);
}
.oidc-page.oidc-light .oidc-bg-orb-3[data-v-8a889949] { background: rgba(139, 92, 246, 0.14);
}
/* 卡片 */
.oidc-page.oidc-light .oidc-card[data-v-8a889949] {
background: #ffffff;
border-color: rgba(0, 0, 0, 0.06);
}
/* 左侧 */
.oidc-page.oidc-light .oidc-left-title[data-v-8a889949] { color: #111827;
}
.oidc-page.oidc-light .oidc-left-sub[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
.oidc-page.oidc-light .oidc-left-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.55);
}
.oidc-page.oidc-light .oidc-left-tag[data-v-8a889949] {
color: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.07);
}
.oidc-page.oidc-light .oidc-left-tag-sep[data-v-8a889949] { background: rgba(0, 0, 0, 0.12);
}
/* 特性卡片 */
.oidc-page.oidc-light .feature-card[data-v-8a889949] {
background: rgba(0, 0, 0, 0.015);
border-color: rgba(0, 0, 0, 0.06);
}
.oidc-page.oidc-light .feature-card.feature-violet[data-v-8a889949] {
background: rgba(124, 58, 237, 0.04);
border-color: rgba(124, 58, 237, 0.1);
}
.oidc-page.oidc-light .feature-card.feature-blue[data-v-8a889949] {
background: rgba(59, 130, 246, 0.04);
border-color: rgba(59, 130, 246, 0.1);
}
.oidc-page.oidc-light .feature-card.feature-green[data-v-8a889949] {
background: rgba(16, 185, 129, 0.04);
border-color: rgba(16, 185, 129, 0.1);
}
.oidc-page.oidc-light .feature-card.feature-amber[data-v-8a889949] {
background: rgba(234, 179, 8, 0.04);
border-color: rgba(234, 179, 8, 0.1);
}
.oidc-page.oidc-light .feature-title[data-v-8a889949] { color: rgba(0, 0, 0, 0.8);
}
.oidc-page.oidc-light .feature-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
/* 右侧 */
.oidc-page.oidc-light .oidc-right-title[data-v-8a889949] { color: #111827;
}
.oidc-page.oidc-light .oidc-right-sub[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
/* 步骤卡片 */
.oidc-page.oidc-light .oidc-step[data-v-8a889949] {
background: #f8fafc;
border-color: rgba(0, 0, 0, 0.06);
}
.oidc-page.oidc-light .oidc-step[data-v-8a889949]:hover {
background: #f1f5f9;
border-color: rgba(0, 0, 0, 0.1);
}
.oidc-page.oidc-light .oidc-step-active[data-v-8a889949] {
background: #f1f5f9;
border-color: rgba(0, 0, 0, 0.1);
}
.oidc-page.oidc-light .oidc-step-done-step[data-v-8a889949] {
border-color: rgba(16, 185, 129, 0.15);
background: rgba(16, 185, 129, 0.04);
}
.oidc-page.oidc-light .oidc-step-error-step[data-v-8a889949] {
border-color: rgba(239, 68, 68, 0.15);
background: rgba(239, 68, 68, 0.04);
}
.oidc-page.oidc-light .oidc-step-title[data-v-8a889949] { color: rgba(0, 0, 0, 0.8);
}
.oidc-page.oidc-light .oidc-step-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
.oidc-page.oidc-light .oidc-disabled-banner[data-v-8a889949] {
background: rgba(234, 179, 8, 0.06);
border-color: rgba(234, 179, 8, 0.2);
color: #b45309;
}
/* 信息行 */
.oidc-page.oidc-light .oidc-info-row[data-v-8a889949] {
background: #f8fafc;
border-color: rgba(0, 0, 0, 0.05);
}
.oidc-page.oidc-light .oidc-info-row-label[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
.oidc-page.oidc-light .oidc-info-row-value[data-v-8a889949] { color: rgba(0, 0, 0, 0.75);
}
.oidc-page.oidc-light .oidc-bound-desc[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
/* 按钮 */
.oidc-page.oidc-light .oidc-btn-outline[data-v-8a889949] {
color: rgba(0, 0, 0, 0.5);
border-color: rgba(0, 0, 0, 0.1);
}
.oidc-page.oidc-light .oidc-btn-outline[data-v-8a889949]:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.18);
}
.oidc-page.oidc-light .oidc-unbind-confirm-text[data-v-8a889949] { color: rgba(0, 0, 0, 0.45);
}
/* 底部 */
.oidc-page.oidc-light .oidc-bottom-line[data-v-8a889949] { background: rgba(0, 0, 0, 0.08);
}
.oidc-page.oidc-light .oidc-bottom-content[data-v-8a889949] { color: rgba(0, 0, 0, 0.25);
}
.oidc-page.oidc-light .oidc-bottom-right[data-v-8a889949] { color: rgba(0, 0, 0, 0.25);
}
/* spinner - 浅色下保持可读 */
.oidc-page.oidc-light .oidc-spinner[data-v-8a889949] {
border-color: rgba(0, 0, 0, 0.15);
border-top-color: #7c3aed;
}

View File

@@ -1,11 +1,19 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createVNode:_createVNode,createElementBlock:_createElementBlock} = await importShared('vue');
const {resolveComponent:_resolveComponent,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createElementBlock:_createElementBlock,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,withCtx:_withCtx} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-page" };
const _hoisted_1 = { class: "oidc-auth-page text-center" };
const _hoisted_2 = {
key: 1,
class: "text-body-2 text-medium-emphasis mb-2"
};
const _hoisted_3 = {
key: 3,
class: "text-body-2 text-medium-emphasis mb-2"
};
const {computed,onUnmounted,ref} = await importShared('vue');
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
@@ -32,9 +40,11 @@ const props = __props;
const emit = __emit;
const checking = ref(true);
const loading = ref(false);
const errorMessage = ref('');
let popupTimer = null;
let messageReceived = false;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const providerName = computed(() => props.provider?.name || 'OIDC 登录');
@@ -59,6 +69,7 @@ function clearPopupTimer() {
function handleOidcMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_callback') return
messageReceived = true;
window.removeEventListener('message', handleOidcMessage);
clearPopupTimer();
loading.value = false;
@@ -71,10 +82,32 @@ function handleOidcMessage(event) {
emit('error', { message });
}
/** 先自检 OIDC 是否已启用,再决定是否发起授权弹窗。 */
async function checkAndStart() {
checking.value = true;
errorMessage.value = '';
try {
const response = await props.api.get(`${pluginBase.value}/public/status`);
const data = response?.data !== undefined ? response.data : response;
if (!data?.enabled) {
errorMessage.value = '管理员未启用OIDC认证请联系管理员开启';
emit('error', { message: errorMessage.value });
return
}
startLogin();
} catch {
errorMessage.value = '无法连接到认证服务';
emit('error', { message: errorMessage.value });
} finally {
checking.value = false;
}
}
/** 发起 OIDC 登录授权弹窗。 */
function startLogin() {
errorMessage.value = '';
loading.value = true;
messageReceived = false;
window.addEventListener('message', handleOidcMessage);
const popup = window.open(
buildApiUrl(`${pluginBase.value}/authorize`),
@@ -92,7 +125,7 @@ function startLogin() {
if (!popup.closed) return
clearPopupTimer();
window.removeEventListener('message', handleOidcMessage);
if (loading.value) {
if (loading.value && !messageReceived) {
loading.value = false;
errorMessage.value = '认证窗口已关闭';
emit('error', { message: errorMessage.value });
@@ -100,6 +133,11 @@ function startLogin() {
}, 500);
}
/** 组件挂载后自检,通过后自动发起登录。 */
onMounted(() => {
checkAndStart();
});
/** 组件卸载时清理监听器和定时器。 */
onUnmounted(() => {
clearPopupTimer();
@@ -107,16 +145,37 @@ onUnmounted(() => {
});
return (_ctx, _cache) => {
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VBtn = _resolveComponent("VBtn");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
(checking.value)
? (_openBlock(), _createBlock(_component_VProgressCircular, {
key: 0,
indeterminate: "",
color: "primary",
class: "mb-4"
}))
: _createCommentVNode("", true),
(checking.value)
? (_openBlock(), _createElementBlock("div", _hoisted_2, "正在检查认证服务状态..."))
: (loading.value)
? (_openBlock(), _createBlock(_component_VProgressCircular, {
key: 2,
indeterminate: "",
color: "primary",
class: "mb-4"
}))
: (loading.value)
? (_openBlock(), _createElementBlock("div", _hoisted_3, "正在打开 " + _toDisplayString(providerName.value) + " 授权页面...", 1))
: _createCommentVNode("", true),
(!loading.value && !checking.value && errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 4,
type: "error",
variant: "tonal",
class: "mb-4"
class: "mb-2"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
@@ -124,29 +183,33 @@ return (_ctx, _cache) => {
_: 1
}))
: _createCommentVNode("", true),
_createVNode(_component_VBtn, {
block: "",
color: "primary",
"prepend-icon": "mdi-openid",
loading: loading.value,
onClick: startLogin
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(providerName.value), 1)
]),
_: 1
}, 8, ["loading"]),
_createVNode(_component_VBtn, {
block: "",
variant: "text",
class: "mt-2",
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
}, {
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
_createTextVNode("取消", -1)
]))]),
_: 1
})
(!loading.value && !checking.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 5,
block: "",
color: "primary",
onClick: checkAndStart
}, {
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
_createTextVNode("重试", -1)
]))]),
_: 1
}))
: _createCommentVNode("", true),
(!loading.value && !checking.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 6,
block: "",
variant: "text",
class: "mt-2",
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
}, {
default: _withCtx(() => [...(_cache[2] || (_cache[2] = [
_createTextVNode("取消", -1)
]))]),
_: 1
}))
: _createCommentVNode("", true)
]))
}
}

View File

@@ -0,0 +1,482 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createElementBlock:_createElementBlock,Fragment:_Fragment} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-config pa-4" };
const _hoisted_2 = { class: "rounded-lg border pa-4 mt-4" };
const _hoisted_3 = { class: "d-flex align-center gap-2 mb-3" };
const _hoisted_4 = { class: "d-flex gap-3 mb-2" };
const _hoisted_5 = { class: "text-body-2" };
const _hoisted_6 = {
key: 1,
class: "text-medium-emphasis"
};
const _hoisted_7 = { class: "d-flex flex-wrap gap-3 mt-4" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'ConfigPage',
props: {
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
emits: ['close'],
setup(__props, { emit: __emit }) {
const props = __props;
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const status = ref({
public: {},
});
const config = ref({
enabled: false,
provider_name: 'OIDC 登录',
issuer: '',
client_id: '',
client_secret: '',
scopes: 'openid profile email',
redirect_uri: '',
username_claim: 'preferred_username',
email_claim: 'email',
allow_auto_bind_by_username: false,
});
const copied = ref(false);
let copyTimer = null;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const displayRedirectUri = computed(() => {
const raw = status.value.public?.redirect_uri || '';
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) return raw
return `${window.location.origin}${raw}`
});
async function copyRedirectUri() {
try {
await navigator.clipboard.writeText(displayRedirectUri.value);
copied.value = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copied.value = false; }, 2000);
} catch { /* 忽略 */ }
}
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
function clearMessages() {
errorMessage.value = '';
successMessage.value = '';
}
async function loadStatus() {
loading.value = true;
clearMessages();
try {
const response = await props.api.get(`${pluginBase.value}/status`);
status.value = unwrap(response) || status.value;
if (status.value.config) {
config.value = { ...config.value, ...status.value.config };
}
} catch (error) {
errorMessage.value = error?.message || '加载失败';
} finally {
loading.value = false;
}
}
async function saveConfig() {
saving.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/config`, config.value);
const data = unwrap(response) || {};
if (data.config) {
config.value = { ...config.value, ...data.config };
}
await loadStatus();
successMessage.value = '配置已保存,即将刷新页面...';
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
errorMessage.value = error?.message || '保存失败';
} finally {
saving.value = false;
}
}
async function testConnection() {
testing.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/test`, config.value);
if (response?.success) {
successMessage.value = response.message || '连接正常';
} else {
errorMessage.value = response?.message || '连接失败';
}
} catch (error) {
errorMessage.value = error?.message || '连接失败';
} finally {
testing.value = false;
}
}
onMounted(loadStatus);
onUnmounted(() => {
clearTimeout(copyTimer);
});
return (_ctx, _cache) => {
const _component_VCardTitle = _resolveComponent("VCardTitle");
const _component_VCardItem = _resolveComponent("VCardItem");
const _component_VSwitch = _resolveComponent("VSwitch");
const _component_VTextField = _resolveComponent("VTextField");
const _component_VCol = _resolveComponent("VCol");
const _component_VRow = _resolveComponent("VRow");
const _component_VIcon = _resolveComponent("VIcon");
const _component_VChip = _resolveComponent("VChip");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VCardText = _resolveComponent("VCardText");
const _component_VCard = _resolveComponent("VCard");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createVNode(_component_VCard, { loading: loading.value }, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
_createTextVNode("OIDC Provider 配置", -1)
]))]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.enabled,
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((config.value.enabled) = $event)),
label: "启用 OIDC 登录",
color: "primary",
class: "mb-2"
}, null, 8, ["modelValue"]),
(config.value.enabled)
? (_openBlock(), _createElementBlock(_Fragment, { key: 0 }, [
_createVNode(_component_VRow, null, {
default: _withCtx(() => [
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.provider_name,
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((config.value.provider_name) = $event)),
label: "入口名称",
"prepend-inner-icon": "mdi-openid"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.issuer,
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((config.value.issuer) = $event)),
label: "Issuer",
placeholder: "https://idp.example.com",
"prepend-inner-icon": "mdi-web"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_id,
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((config.value.client_id) = $event)),
label: "Client ID",
"prepend-inner-icon": "mdi-identifier"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_secret,
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((config.value.client_secret) = $event)),
label: "Client Secret",
type: "password",
"prepend-inner-icon": "mdi-key"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.scopes,
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((config.value.scopes) = $event)),
label: "Scopes",
placeholder: "openid profile email",
"prepend-inner-icon": "mdi-format-list-checks"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.redirect_uri,
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((config.value.redirect_uri) = $event)),
label: "回调地址覆盖",
placeholder: "留空自动生成",
"prepend-inner-icon": "mdi-call-made"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.username_claim,
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((config.value.username_claim) = $event)),
label: "用户名 Claim",
"prepend-inner-icon": "mdi-account"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.email_claim,
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((config.value.email_claim) = $event)),
label: "邮箱 Claim",
"prepend-inner-icon": "mdi-email"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.allow_auto_bind_by_username,
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((config.value.allow_auto_bind_by_username) = $event)),
label: "允许按用户名 Claim 自动绑定已有用户",
color: "primary"
}, null, 8, ["modelValue"])
]),
_: 1
})
]),
_: 1
}),
_createElementVNode("div", _hoisted_2, [
_createElementVNode("div", _hoisted_3, [
_createVNode(_component_VIcon, {
size: "20",
color: "primary"
}, {
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
_createTextVNode("mdi-information-outline", -1)
]))]),
_: 1
}),
_cache[12] || (_cache[12] = _createElementVNode("span", { class: "text-subtitle-2 font-weight-medium" }, "使用指南", -1))
]),
_cache[17] || (_cache[17] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
_createElementVNode("div", {
class: "text-medium-emphasis",
style: {"min-width":"16px"}
}, "1."),
_createElementVNode("div", { class: "text-body-2" }, "在您的 OIDC 提供商(如 Keycloak、Authentik、Okta 等)中创建一个客户端,协议类型选择 \"OAuth2/OpenID Provider\",授权流程使用 \"Authorize Application\"。")
], -1)),
_createElementVNode("div", _hoisted_4, [
_cache[16] || (_cache[16] = _createElementVNode("div", {
class: "text-medium-emphasis",
style: {"min-width":"16px"}
}, "2.", -1)),
_createElementVNode("div", _hoisted_5, [
_cache[15] || (_cache[15] = _createTextVNode(" 将回调地址设置为: ", -1)),
(displayRedirectUri.value)
? (_openBlock(), _createBlock(_component_VChip, {
key: 0,
color: "info",
variant: "tonal",
size: "small",
class: "cursor-pointer ml-1",
onClick: copyRedirectUri
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(displayRedirectUri.value) + " ", 1),
(copied.value)
? (_openBlock(), _createBlock(_component_VIcon, {
key: 0,
end: "",
size: "14",
color: "success"
}, {
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode("mdi-check", -1)
]))]),
_: 1
}))
: (_openBlock(), _createBlock(_component_VIcon, {
key: 1,
end: "",
size: "14"
}, {
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
_createTextVNode("mdi-content-copy", -1)
]))]),
_: 1
}))
]),
_: 1
}))
: (_openBlock(), _createElementBlock("span", _hoisted_6, "加载中..."))
])
]),
_cache[18] || (_cache[18] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
_createElementVNode("div", {
class: "text-medium-emphasis",
style: {"min-width":"16px"}
}, "3."),
_createElementVNode("div", { class: "text-body-2" }, [
_createTextVNode(" 填写签发者 URL、客户端 ID 和客户端密钥,保存设置。 "),
_createElementVNode("div", { class: "text-medium-emphasis text-caption mt-1" }, [
_createTextVNode("如果 IdP 与 MoviePilot 不在同一网络、需要指定不同的回调地址,可在「回调地址覆盖」中手动填写完整地址(如 "),
_createElementVNode("code", { class: "text-caption" }, "https://another-domain.com/api/v1/plugin/OidcAuth/callback"),
_createTextVNode("),正常情况下留空即可。")
])
])
], -1)),
_cache[19] || (_cache[19] = _createElementVNode("div", { class: "d-flex gap-3 mb-2" }, [
_createElementVNode("div", {
class: "text-medium-emphasis",
style: {"min-width":"16px"}
}, "4."),
_createElementVNode("div", { class: "text-body-2" }, "保存后登录页面将显示 OIDC 登录按钮。")
], -1)),
_cache[20] || (_cache[20] = _createElementVNode("div", { class: "d-flex gap-3" }, [
_createElementVNode("div", {
class: "text-medium-emphasis",
style: {"min-width":"16px"}
}, "5."),
_createElementVNode("div", { class: "text-body-2" }, "已登录用户可在左侧菜单「OIDC 认证」中绑定/解绑 OIDC 账号。")
], -1))
])
], 64))
: _createCommentVNode("", true),
_createElementVNode("div", _hoisted_7, [
_createVNode(_component_VBtn, {
color: "primary",
"prepend-icon": "mdi-content-save",
loading: saving.value,
onClick: saveConfig
}, {
default: _withCtx(() => [...(_cache[21] || (_cache[21] = [
_createTextVNode("保存", -1)
]))]),
_: 1
}, 8, ["loading"]),
(config.value.enabled)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 0,
color: "info",
variant: "tonal",
"prepend-icon": "mdi-connection",
loading: testing.value,
onClick: testConnection
}, {
default: _withCtx(() => [...(_cache[22] || (_cache[22] = [
_createTextVNode("测试连接", -1)
]))]),
_: 1
}, 8, ["loading"]))
: _createCommentVNode("", true)
]),
(errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "error",
variant: "tonal",
class: "mt-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
(successMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 2,
type: "success",
variant: "tonal",
class: "mt-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(successMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true)
]),
_: 1
})
]),
_: 1
}, 8, ["loading"])
]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,406 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {createElementVNode:_createElementVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,createTextVNode:_createTextVNode,toDisplayString:_toDisplayString,createCommentVNode:_createCommentVNode,createBlock:_createBlock} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-page pa-4" };
const _hoisted_2 = {
key: 0,
class: "text-success"
};
const _hoisted_3 = {
key: 1,
class: "text-medium-emphasis"
};
const _hoisted_4 = { class: "d-flex flex-wrap gap-3 align-center" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'Page',
props: {
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
emits: ['close', 'switch'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
const loading = ref(false);
const binding = ref(false);
const bindErrorMessage = ref('');
const bindSuccessMessage = ref('');
const status = ref({ public: {}, binding: {} });
let bindPopupTimer = null;
let bindMessageReceived = false;
let bindPollingLock = false;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const isBound = computed(() => Boolean(status.value.binding?.bound));
const isAdmin = computed(() => status.value.is_superuser);
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
async function loadStatus() {
loading.value = true;
try {
const response = await props.api.get(`${pluginBase.value}/status`);
status.value = unwrap(response) || status.value;
} catch (error) {
bindErrorMessage.value = error?.message || '加载失败';
} finally {
loading.value = false;
}
}
function clearBindPopupTimer() {
if (bindPopupTimer) {
clearInterval(bindPopupTimer);
bindPopupTimer = null;
}
}
async function handleBindMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_bind_callback') return
bindMessageReceived = true;
window.removeEventListener('message', handleBindMessage);
clearBindPopupTimer();
binding.value = false;
if (event.data.success) {
await loadStatus();
bindSuccessMessage.value = 'OIDC 账号已绑定';
bindErrorMessage.value = '';
} else {
bindErrorMessage.value = event.data?.message || '绑定失败';
}
}
async function bindAccount() {
binding.value = true;
bindErrorMessage.value = '';
bindSuccessMessage.value = '';
bindMessageReceived = false;
bindPollingLock = false;
try {
const response = await props.api.post(`${pluginBase.value}/bind/start`, {});
const authorizeUrl = response?.data?.authorize_url;
if (!response?.success || !authorizeUrl) {
throw new Error(response?.message || '无法发起绑定')
}
window.addEventListener('message', handleBindMessage);
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80');
if (!popup) {
window.removeEventListener('message', handleBindMessage);
throw new Error('浏览器阻止了认证弹窗')
}
bindPopupTimer = setInterval(async () => {
// 防止上一次轮询还未完成
if (bindPollingLock) return
bindPollingLock = true;
try {
// 弹窗未关闭时偷偷检查绑定状态PostMessage 可能因 opener 丢失而失效)
if (!popup.closed && !bindMessageReceived) {
await loadStatus();
if (isBound.value) {
// 绑定已生效,关闭弹窗并标记成功
bindMessageReceived = true;
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
binding.value = false;
bindSuccessMessage.value = 'OIDC 账号已绑定';
bindErrorMessage.value = '';
try { popup.close(); } catch (_) { /* 忽略跨域关闭错误 */ }
return
}
return
}
if (!popup.closed) return
// 弹窗已关闭
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
if (!binding.value) return
binding.value = false;
if (bindMessageReceived) return
// postMessage 丢失,重试轮询状态(最多 6 次,每次间隔 1.5 秒)
for (let attempt = 0; attempt < 6; attempt++) {
await loadStatus();
if (isBound.value) {
bindSuccessMessage.value = 'OIDC 账号已绑定';
bindErrorMessage.value = '';
return
}
if (attempt < 5) {
await new Promise(r => setTimeout(r, 1500));
}
}
bindErrorMessage.value = '绑定失败:未检测到绑定状态,请重试';
} finally {
bindPollingLock = false;
}
}, 1000);
} catch (error) {
binding.value = false;
bindErrorMessage.value = error?.message || '绑定失败';
}
}
async function unbindAccount() {
binding.value = true;
bindErrorMessage.value = '';
bindSuccessMessage.value = '';
try {
const response = await props.api.post(`${pluginBase.value}/unbind`, {});
if (response?.success) {
await loadStatus();
bindSuccessMessage.value = 'OIDC 账号已解绑';
bindErrorMessage.value = '';
} else {
bindErrorMessage.value = response?.message || '解绑失败';
}
} catch (error) {
bindErrorMessage.value = error?.message || '解绑失败';
} finally {
binding.value = false;
}
}
onMounted(loadStatus);
onUnmounted(() => {
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
});
return (_ctx, _cache) => {
const _component_VAvatar = _resolveComponent("VAvatar");
const _component_VCardTitle = _resolveComponent("VCardTitle");
const _component_VIcon = _resolveComponent("VIcon");
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
const _component_VCardItem = _resolveComponent("VCardItem");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VCardText = _resolveComponent("VCardText");
const _component_VCard = _resolveComponent("VCard");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(status.value.public?.enabled)
? (_openBlock(), _createBlock(_component_VCard, {
key: 0,
loading: loading.value,
class: "mb-4"
}, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
prepend: _withCtx(() => [
_createVNode(_component_VAvatar, {
color: "primary",
size: "40"
}, {
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
_createElementVNode("svg", {
viewBox: "0 0 1024 1024",
width: "24",
height: "24",
fill: "white",
xmlns: "http://www.w3.org/2000/svg"
}, [
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
], -1)
]))]),
_: 1
})
]),
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[2] || (_cache[2] = [
_createTextVNode("OIDC 账号绑定", -1)
]))]),
_: 1
}),
_createVNode(_component_VCardSubtitle, null, {
default: _withCtx(() => [
(isBound.value)
? (_openBlock(), _createElementBlock("span", _hoisted_2, [
_createVNode(_component_VIcon, {
size: "14",
color: "success",
class: "mr-1"
}, {
default: _withCtx(() => [...(_cache[3] || (_cache[3] = [
_createTextVNode("mdi-check-circle", -1)
]))]),
_: 1
}),
_createTextVNode(" 已绑定 " + _toDisplayString(status.value.binding?.sub || status.value.binding?.masked_sub), 1)
]))
: (_openBlock(), _createElementBlock("span", _hoisted_3, "当前账号尚未绑定 OIDC"))
]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
_createElementVNode("div", _hoisted_4, [
(!isBound.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 0,
color: "primary",
loading: binding.value,
onClick: bindAccount
}, {
prepend: _withCtx(() => [...(_cache[4] || (_cache[4] = [
_createElementVNode("svg", {
viewBox: "0 0 1024 1024",
width: "20",
height: "20",
fill: "currentColor",
xmlns: "http://www.w3.org/2000/svg"
}, [
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
], -1)
]))]),
default: _withCtx(() => [
_cache[5] || (_cache[5] = _createTextVNode(" 绑定 OIDC 账号 ", -1))
]),
_: 1
}, 8, ["loading"]))
: (_openBlock(), _createBlock(_component_VBtn, {
key: 1,
color: "error",
variant: "tonal",
"prepend-icon": "mdi-link-off",
loading: binding.value,
onClick: unbindAccount
}, {
default: _withCtx(() => [...(_cache[6] || (_cache[6] = [
_createTextVNode(" 解绑 OIDC 账号 ", -1)
]))]),
_: 1
}, 8, ["loading"])),
(isAdmin.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 2,
color: "primary",
variant: "tonal",
"prepend-icon": "mdi-cog",
onClick: _cache[0] || (_cache[0] = $event => (emit('switch')))
}, {
default: _withCtx(() => [...(_cache[7] || (_cache[7] = [
_createTextVNode(" 配置 ", -1)
]))]),
_: 1
}))
: _createCommentVNode("", true)
]),
(bindErrorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 0,
type: "error",
variant: "tonal",
class: "mt-3"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(bindErrorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
(bindSuccessMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "success",
variant: "tonal",
class: "mt-3"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(bindSuccessMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true)
]),
_: 1
})
]),
_: 1
}, 8, ["loading"]))
: (_openBlock(), _createBlock(_component_VCard, {
key: 1,
class: "mb-4"
}, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
prepend: _withCtx(() => [
_createVNode(_component_VAvatar, {
color: "grey-lighten-2",
size: "40"
}, {
default: _withCtx(() => [...(_cache[8] || (_cache[8] = [
_createElementVNode("svg", {
viewBox: "0 0 1024 1024",
width: "24",
height: "24",
fill: "#9E9E9E",
xmlns: "http://www.w3.org/2000/svg"
}, [
_createElementVNode("path", { d: "M468.064 866.08v91.616c-81.408-7.168-155.328-25.376-221.792-54.656-66.432-29.28-118.752-66.496-156.96-111.68C51.104 746.176 32 697.536 32 645.408c0-50.016 17.952-97.056 53.856-141.184 35.904-44.096 84.992-80.8 147.328-110.08s132.224-48.576 209.728-57.856v92.128c-77.504 13.568-141.152 40.352-190.976 80.352-49.824 40-74.72 85.536-74.72 136.64 0 54.272 27.584 101.952 82.752 143.04 55.168 41.056 124.544 66.944 208.096 77.632zM992 587.008l-19.808-208.928-75.008 42.304c-72.864-44.288-158.752-72.32-257.696-84.096v92.128c57.504 10.368 107.488 28.032 150.016 53.056l-78.752 44.48L992 587.008z" }),
_createElementVNode("path", { d: "M613.792 889.152l-145.728 68.576V137.536l145.728-71.264v822.88z" })
], -1)
]))]),
_: 1
})
]),
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[9] || (_cache[9] = [
_createTextVNode("OIDC 认证", -1)
]))]),
_: 1
}),
_createVNode(_component_VCardSubtitle, { class: "text-medium-emphasis" }, {
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
_createTextVNode("OIDC 认证尚未启用", -1)
]))]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
_createElementVNode("p", { class: "text-body-2 text-medium-emphasis" }, "请联系管理员在插件设置中配置 OIDC Provider。", -1)
]))]),
_: 1
})
]),
_: 1
}))
]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,418 @@
const buildIdentifier = "[0-9A-Za-z-]+";
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
const numericIdentifier = "0|[1-9]\\d*";
const numericIdentifierLoose = "[0-9]+";
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
const gtlt = "((?:<|>)?=?)";
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
const loneTilde = "(?:~>?)";
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
const loneCaret = "(?:\\^)";
const caretTrim = `(\\s*)${loneCaret}\\s+`;
const star = "(<|>)?=?\\s*\\*";
const caret = `^${loneCaret}${xRangePlain}$`;
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
const tilde = `^${loneTilde}${xRangePlain}$`;
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
function parseRegex(source) {
return new RegExp(source);
}
function isXVersion(version) {
return !version || version.toLowerCase() === "x" || version === "*";
}
function pipe(...fns) {
return (x) => {
return fns.reduce((v, f) => f(v), x);
};
}
function extractComparator(comparatorString) {
return comparatorString.match(parseRegex(comparator));
}
function combineVersion(major, minor, patch, preRelease2) {
const mainVersion2 = `${major}.${minor}.${patch}`;
if (preRelease2) {
return `${mainVersion2}-${preRelease2}`;
}
return mainVersion2;
}
function parseHyphen(range) {
return range.replace(
parseRegex(hyphenRange),
(_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
if (isXVersion(fromMajor)) {
from = "";
} else if (isXVersion(fromMinor)) {
from = `>=${fromMajor}.0.0`;
} else if (isXVersion(fromPatch)) {
from = `>=${fromMajor}.${fromMinor}.0`;
} else {
from = `>=${from}`;
}
if (isXVersion(toMajor)) {
to = "";
} else if (isXVersion(toMinor)) {
to = `<${+toMajor + 1}.0.0-0`;
} else if (isXVersion(toPatch)) {
to = `<${toMajor}.${+toMinor + 1}.0-0`;
} else if (toPreRelease) {
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
} else {
to = `<=${to}`;
}
return `${from} ${to}`.trim();
}
);
}
function parseComparatorTrim(range) {
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
}
function parseTildeTrim(range) {
return range.replace(parseRegex(tildeTrim), "$1~");
}
function parseCaretTrim(range) {
return range.replace(parseRegex(caretTrim), "$1^");
}
function parseCarets(range) {
return range.trim().split(/\s+/).map((rangeVersion) => {
return rangeVersion.replace(
parseRegex(caret),
(_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
if (major === "0") {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else {
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
}
} else if (preRelease2) {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
}
} else {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
}
}
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
}
}
);
}).join(" ");
}
function parseTildes(range) {
return range.trim().split(/\s+/).map((rangeVersion) => {
return rangeVersion.replace(
parseRegex(tilde),
(_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else if (preRelease2) {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
}
);
}).join(" ");
}
function parseXRanges(range) {
return range.split(/\s+/).map((rangeVersion) => {
return rangeVersion.trim().replace(
parseRegex(xRange),
(ret, gtlt2, major, minor, patch, preRelease2) => {
const isXMajor = isXVersion(major);
const isXMinor = isXMajor || isXVersion(minor);
const isXPatch = isXMinor || isXVersion(patch);
if (gtlt2 === "=" && isXPatch) {
gtlt2 = "";
}
preRelease2 = "";
if (isXMajor) {
if (gtlt2 === ">" || gtlt2 === "<") {
return "<0.0.0-0";
} else {
return "*";
}
} else if (gtlt2 && isXPatch) {
if (isXMinor) {
minor = 0;
}
patch = 0;
if (gtlt2 === ">") {
gtlt2 = ">=";
if (isXMinor) {
major = +major + 1;
minor = 0;
patch = 0;
} else {
minor = +minor + 1;
patch = 0;
}
} else if (gtlt2 === "<=") {
gtlt2 = "<";
if (isXMinor) {
major = +major + 1;
} else {
minor = +minor + 1;
}
}
if (gtlt2 === "<") {
preRelease2 = "-0";
}
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
} else if (isXMinor) {
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
} else if (isXPatch) {
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return ret;
}
);
}).join(" ");
}
function parseStar(range) {
return range.trim().replace(parseRegex(star), "");
}
function parseGTE0(comparatorString) {
return comparatorString.trim().replace(parseRegex(gte0), "");
}
function compareAtom(rangeAtom, versionAtom) {
rangeAtom = +rangeAtom || rangeAtom;
versionAtom = +versionAtom || versionAtom;
if (rangeAtom > versionAtom) {
return 1;
}
if (rangeAtom === versionAtom) {
return 0;
}
return -1;
}
function comparePreRelease(rangeAtom, versionAtom) {
const { preRelease: rangePreRelease } = rangeAtom;
const { preRelease: versionPreRelease } = versionAtom;
if (rangePreRelease === void 0 && !!versionPreRelease) {
return 1;
}
if (!!rangePreRelease && versionPreRelease === void 0) {
return -1;
}
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
return 0;
}
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
const rangeElement = rangePreRelease[i];
const versionElement = versionPreRelease[i];
if (rangeElement === versionElement) {
continue;
}
if (rangeElement === void 0 && versionElement === void 0) {
return 0;
}
if (!rangeElement) {
return 1;
}
if (!versionElement) {
return -1;
}
return compareAtom(rangeElement, versionElement);
}
return 0;
}
function compareVersion(rangeAtom, versionAtom) {
return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom);
}
function eq(rangeAtom, versionAtom) {
return rangeAtom.version === versionAtom.version;
}
function compare(rangeAtom, versionAtom) {
switch (rangeAtom.operator) {
case "":
case "=":
return eq(rangeAtom, versionAtom);
case ">":
return compareVersion(rangeAtom, versionAtom) < 0;
case ">=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
case "<":
return compareVersion(rangeAtom, versionAtom) > 0;
case "<=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
case void 0: {
return true;
}
default:
return false;
}
}
function parseComparatorString(range) {
return pipe(
parseCarets,
parseTildes,
parseXRanges,
parseStar
)(range);
}
function parseRange(range) {
return pipe(
parseHyphen,
parseComparatorTrim,
parseTildeTrim,
parseCaretTrim
)(range.trim()).split(/\s+/).join(" ");
}
function satisfy(version, range) {
if (!version) {
return false;
}
const parsedRange = parseRange(range);
const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" ");
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
const extractedVersion = extractComparator(version);
if (!extractedVersion) {
return false;
}
const [
,
versionOperator,
,
versionMajor,
versionMinor,
versionPatch,
versionPreRelease
] = extractedVersion;
const versionAtom = {
version: combineVersion(
versionMajor,
versionMinor,
versionPatch,
versionPreRelease
),
major: versionMajor,
minor: versionMinor,
patch: versionPatch,
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".")
};
for (const comparator2 of comparators) {
const extractedComparator = extractComparator(comparator2);
if (!extractedComparator) {
return false;
}
const [
,
rangeOperator,
,
rangeMajor,
rangeMinor,
rangePatch,
rangePreRelease
] = extractedComparator;
const rangeAtom = {
operator: rangeOperator,
version: combineVersion(
rangeMajor,
rangeMinor,
rangePatch,
rangePreRelease
),
major: rangeMajor,
minor: rangeMinor,
patch: rangePatch,
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".")
};
if (!compare(rangeAtom, versionAtom)) {
return false;
}
}
return true;
}
// eslint-disable-next-line no-undef
const moduleMap = {};
const moduleCache = Object.create(null);
async function importShared(name, shareScope = 'default') {
return moduleCache[name]
? new Promise((r) => r(moduleCache[name]))
: (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name)
}
async function getSharedFromRuntime(name, shareScope) {
let module = null;
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
const versionObj = globalThis.__federation_shared__[shareScope][name];
const requiredVersion = moduleMap[name]?.requiredVersion;
const hasRequiredVersion = !!requiredVersion;
if (hasRequiredVersion) {
const versionKey = Object.keys(versionObj).find((version) =>
satisfy(version, requiredVersion)
);
if (versionKey) {
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
} else {
console.log(
`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`
);
}
} else {
const versionKey = Object.keys(versionObj)[0];
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
}
}
if (module) {
return flattenModule(module, name)
}
}
async function getSharedFromLocal(name) {
if (moduleMap[name]?.import) {
let module = await (await moduleMap[name].get())();
return flattenModule(module, name)
} else {
console.error(
`consumer config import=false,so cant use callback shared module`
);
}
}
function flattenModule(module, name) {
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
if (typeof module.default === 'function') {
Object.keys(module).forEach((key) => {
if (key !== 'default') {
module.default[key] = module[key];
}
});
moduleCache[name] = module.default;
return module.default
}
if (module.default) module = Object.assign({}, module.default, module);
moduleCache[name] = module;
return module
}
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };

View File

@@ -0,0 +1,44 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import AppPage from './__federation_expose_AppPage-BuslU8xE.js';
true&&(function polyfill() {
const relList = document.createElement("link").relList;
if (relList && relList.supports && relList.supports("modulepreload")) {
return;
}
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
processPreload(link);
}
new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "childList") {
continue;
}
for (const node of mutation.addedNodes) {
if (node.tagName === "LINK" && node.rel === "modulepreload")
processPreload(node);
}
}
}).observe(document, { childList: true, subtree: true });
function getFetchOpts(link) {
const fetchOpts = {};
if (link.integrity) fetchOpts.integrity = link.integrity;
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
if (link.crossOrigin === "use-credentials")
fetchOpts.credentials = "include";
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
else fetchOpts.credentials = "same-origin";
return fetchOpts;
}
function processPreload(link) {
if (link.ep)
return;
link.ep = true;
const fetchOpts = getFetchOpts(link);
fetch(link.href, fetchOpts);
}
}());
const {createApp} = await importShared('vue');
createApp(AppPage).mount('#app');

View File

@@ -3,16 +3,16 @@ const currentImports = {};
let moduleMap = {
"./AuthPage":()=>{
dynamicLoadingCss([], false, './AuthPage');
return __federation_import('./__federation_expose_AuthPage-BlxZvRi5.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
return __federation_import('./__federation_expose_AuthPage-ByDbUb5c.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./AppPage":()=>{
dynamicLoadingCss([], false, './AppPage');
return __federation_import('./__federation_expose_AppPage-CiS2QwqR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_AppPage-CCcTxdR8.css"], false, './AppPage');
return __federation_import('./__federation_expose_AppPage-BuslU8xE.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Page":()=>{
dynamicLoadingCss([], false, './Page');
return __federation_import('${__federation_expose_./Page}').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
return __federation_import('./__federation_expose_Page-B5ZFHZ5P.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Config":()=>{
dynamicLoadingCss([], false, './Config');
return __federation_import('${__federation_expose_./Config}').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
return __federation_import('./__federation_expose_Config-CHWKv43_.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;

View File

@@ -1,515 +0,0 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createVNode:_createVNode,createElementVNode:_createElementVNode,createElementBlock:_createElementBlock} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-admin pa-4" };
const _hoisted_2 = { class: "d-flex flex-wrap gap-3 mt-2" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AppPage',
props: {
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
setup(__props) {
const props = __props;
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
const binding = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const status = ref({
public: {},
binding: {},
config: null,
is_superuser: false,
});
const config = ref({
enabled: false,
provider_name: 'OIDC 登录',
issuer: '',
client_id: '',
client_secret: '',
scopes: 'openid profile email',
redirect_uri: '',
username_claim: 'preferred_username',
email_claim: 'email',
allow_auto_bind_by_username: false,
});
let bindPopupTimer = null;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const isAdmin = computed(() => Boolean(status.value.is_superuser));
const isBound = computed(() => Boolean(status.value.binding?.bound));
/** 从 API 响应中解出 data 字段。 */
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
/** 清理提示信息。 */
function clearMessages() {
errorMessage.value = '';
successMessage.value = '';
}
/** 从服务端加载插件状态、配置和绑定信息。 */
async function loadStatus() {
loading.value = true;
clearMessages();
try {
const response = await props.api.get(`${pluginBase.value}/status`);
status.value = unwrap(response) || status.value;
if (status.value.config) {
config.value = { ...config.value, ...status.value.config };
}
} catch (error) {
errorMessage.value = error?.message || '加载失败';
} finally {
loading.value = false;
}
}
/** 保存管理员配置。 */
async function saveConfig() {
saving.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/config`, config.value);
const data = unwrap(response) || {};
if (data.config) {
config.value = { ...config.value, ...data.config };
}
successMessage.value = '配置已保存';
await loadStatus();
} catch (error) {
errorMessage.value = error?.message || '保存失败';
} finally {
saving.value = false;
}
}
/** 测试 OIDC Provider 发现文档。 */
async function testConnection() {
testing.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/test`, config.value);
if (response?.success) {
successMessage.value = response.message || '连接正常';
} else {
errorMessage.value = response?.message || '连接失败';
}
} catch (error) {
errorMessage.value = error?.message || '连接失败';
} finally {
testing.value = false;
}
}
/** 清理绑定弹窗轮询。 */
function clearBindPopupTimer() {
if (bindPopupTimer) {
clearInterval(bindPopupTimer);
bindPopupTimer = null;
}
}
/** 处理绑定回调消息。 */
function handleBindMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_bind_callback') return
window.removeEventListener('message', handleBindMessage);
clearBindPopupTimer();
binding.value = false;
if (event.data.success) {
successMessage.value = 'OIDC 账号已绑定';
loadStatus();
return
}
errorMessage.value = event.data?.message || '绑定失败';
}
/** 发起账号绑定。 */
async function bindAccount() {
binding.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/bind/start`, {});
const authorizeUrl = response?.data?.authorize_url;
if (!response?.success || !authorizeUrl) {
throw new Error(response?.message || '无法发起绑定')
}
window.addEventListener('message', handleBindMessage);
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80');
if (!popup) {
window.removeEventListener('message', handleBindMessage);
throw new Error('浏览器阻止了认证弹窗')
}
bindPopupTimer = setInterval(() => {
if (!popup.closed) return
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
if (binding.value) {
binding.value = false;
loadStatus();
}
}, 500);
} catch (error) {
binding.value = false;
errorMessage.value = error?.message || '绑定失败';
}
}
/** 解绑当前账号。 */
async function unbindAccount() {
binding.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/unbind`, {});
if (response?.success) {
successMessage.value = 'OIDC 账号已解绑';
await loadStatus();
} else {
errorMessage.value = response?.message || '解绑失败';
}
} catch (error) {
errorMessage.value = error?.message || '解绑失败';
} finally {
binding.value = false;
}
}
/** 组件挂载时加载状态。 */
onMounted(loadStatus);
/** 组件卸载时清理绑定监听器。 */
onUnmounted(() => {
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
});
return (_ctx, _cache) => {
const _component_VAlert = _resolveComponent("VAlert");
const _component_VCardTitle = _resolveComponent("VCardTitle");
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
const _component_VCardItem = _resolveComponent("VCardItem");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VCardText = _resolveComponent("VCardText");
const _component_VCard = _resolveComponent("VCard");
const _component_VSwitch = _resolveComponent("VSwitch");
const _component_VCol = _resolveComponent("VCol");
const _component_VTextField = _resolveComponent("VTextField");
const _component_VRow = _resolveComponent("VRow");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 0,
type: "error",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
(successMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "success",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(successMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
_createVNode(_component_VCard, {
loading: loading.value,
class: "mb-4"
}, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
_createTextVNode("OIDC 账号绑定", -1)
]))]),
_: 1
}),
(isBound.value)
? (_openBlock(), _createBlock(_component_VCardSubtitle, { key: 0 }, {
default: _withCtx(() => [
_createTextVNode("已绑定 " + _toDisplayString(status.value.binding?.masked_sub), 1)
]),
_: 1
}))
: (_openBlock(), _createBlock(_component_VCardSubtitle, { key: 1 }, {
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
_createTextVNode("当前账号尚未绑定 OIDC", -1)
]))]),
_: 1
}))
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
(!isBound.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 0,
color: "primary",
"prepend-icon": "mdi-openid",
loading: binding.value,
onClick: bindAccount
}, {
default: _withCtx(() => [...(_cache[12] || (_cache[12] = [
_createTextVNode(" 绑定 OIDC 账号 ", -1)
]))]),
_: 1
}, 8, ["loading"]))
: (_openBlock(), _createBlock(_component_VBtn, {
key: 1,
color: "error",
variant: "tonal",
"prepend-icon": "mdi-link-off",
loading: binding.value,
onClick: unbindAccount
}, {
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode(" 解绑 OIDC 账号 ", -1)
]))]),
_: 1
}, 8, ["loading"]))
]),
_: 1
})
]),
_: 1
}, 8, ["loading"]),
(isAdmin.value)
? (_openBlock(), _createBlock(_component_VCard, { key: 2 }, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
_createTextVNode("OIDC Provider 配置", -1)
]))]),
_: 1
}),
_createVNode(_component_VCardSubtitle, null, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(status.value.public?.redirect_uri), 1)
]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
_createVNode(_component_VRow, null, {
default: _withCtx(() => [
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.enabled,
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((config.value.enabled) = $event)),
label: "启用 OIDC 登录",
color: "primary"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.provider_name,
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((config.value.provider_name) = $event)),
label: "入口名称",
"prepend-inner-icon": "mdi-openid"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.issuer,
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((config.value.issuer) = $event)),
label: "Issuer",
placeholder: "https://idp.example.com",
"prepend-inner-icon": "mdi-web"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_id,
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((config.value.client_id) = $event)),
label: "Client ID",
"prepend-inner-icon": "mdi-identifier"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_secret,
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((config.value.client_secret) = $event)),
label: "Client Secret",
type: "password",
"prepend-inner-icon": "mdi-key"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.scopes,
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((config.value.scopes) = $event)),
label: "Scopes",
placeholder: "openid profile email",
"prepend-inner-icon": "mdi-format-list-checks"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.redirect_uri,
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((config.value.redirect_uri) = $event)),
label: "回调地址覆盖",
placeholder: "留空自动生成",
"prepend-inner-icon": "mdi-call-made"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.username_claim,
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((config.value.username_claim) = $event)),
label: "用户名 Claim",
"prepend-inner-icon": "mdi-account"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.email_claim,
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((config.value.email_claim) = $event)),
label: "邮箱 Claim",
"prepend-inner-icon": "mdi-email"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.allow_auto_bind_by_username,
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((config.value.allow_auto_bind_by_username) = $event)),
label: "允许按用户名 Claim 自动绑定已有用户",
color: "primary"
}, null, 8, ["modelValue"])
]),
_: 1
})
]),
_: 1
}),
_createElementVNode("div", _hoisted_2, [
_createVNode(_component_VBtn, {
color: "primary",
"prepend-icon": "mdi-content-save",
loading: saving.value,
onClick: saveConfig
}, {
default: _withCtx(() => [...(_cache[15] || (_cache[15] = [
_createTextVNode("保存", -1)
]))]),
_: 1
}, 8, ["loading"]),
_createVNode(_component_VBtn, {
color: "info",
variant: "tonal",
"prepend-icon": "mdi-connection",
loading: testing.value,
onClick: testConnection
}, {
default: _withCtx(() => [...(_cache[16] || (_cache[16] = [
_createTextVNode("测试连接", -1)
]))]),
_: 1
}, 8, ["loading"])
])
]),
_: 1
})
]),
_: 1
}))
: _createCommentVNode("", true)
]))
}
}
};
export { _sfc_main as default };

View File

@@ -1,4 +0,0 @@
<script type="module" crossorigin src="/assets/index-CKX1jWaN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-CiS2QwqR.js">
<div id="app"></div>

View File

@@ -1,2 +1,5 @@
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" crossorigin src="./assets/index-Cqb41JMs.js"></script>
<link rel="modulepreload" crossorigin href="./assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="./assets/__federation_expose_AppPage-BuslU8xE.js">
<link rel="stylesheet" crossorigin href="./assets/__federation_expose_AppPage-CCcTxdR8.css">
<div id="app"></div>

View File

@@ -1,262 +0,0 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
})
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const binding = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const status = ref({
public: {},
binding: {},
config: null,
is_superuser: false,
})
const config = ref({
enabled: false,
provider_name: 'OIDC 登录',
issuer: '',
client_id: '',
client_secret: '',
scopes: 'openid profile email',
redirect_uri: '',
username_claim: 'preferred_username',
email_claim: 'email',
allow_auto_bind_by_username: false,
})
let bindPopupTimer = null
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`)
const isAdmin = computed(() => Boolean(status.value.is_superuser))
const isBound = computed(() => Boolean(status.value.binding?.bound))
/** 从 API 响应中解出 data 字段。 */
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
/** 清理提示信息。 */
function clearMessages() {
errorMessage.value = ''
successMessage.value = ''
}
/** 从服务端加载插件状态、配置和绑定信息。 */
async function loadStatus() {
loading.value = true
clearMessages()
try {
const response = await props.api.get(`${pluginBase.value}/status`)
status.value = unwrap(response) || status.value
if (status.value.config) {
config.value = { ...config.value, ...status.value.config }
}
} catch (error) {
errorMessage.value = error?.message || '加载失败'
} finally {
loading.value = false
}
}
/** 保存管理员配置。 */
async function saveConfig() {
saving.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/config`, config.value)
const data = unwrap(response) || {}
if (data.config) {
config.value = { ...config.value, ...data.config }
}
successMessage.value = '配置已保存'
await loadStatus()
} catch (error) {
errorMessage.value = error?.message || '保存失败'
} finally {
saving.value = false
}
}
/** 测试 OIDC Provider 发现文档。 */
async function testConnection() {
testing.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/test`, config.value)
if (response?.success) {
successMessage.value = response.message || '连接正常'
} else {
errorMessage.value = response?.message || '连接失败'
}
} catch (error) {
errorMessage.value = error?.message || '连接失败'
} finally {
testing.value = false
}
}
/** 清理绑定弹窗轮询。 */
function clearBindPopupTimer() {
if (bindPopupTimer) {
clearInterval(bindPopupTimer)
bindPopupTimer = null
}
}
/** 处理绑定回调消息。 */
function handleBindMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_bind_callback') return
window.removeEventListener('message', handleBindMessage)
clearBindPopupTimer()
binding.value = false
if (event.data.success) {
successMessage.value = 'OIDC 账号已绑定'
loadStatus()
return
}
errorMessage.value = event.data?.message || '绑定失败'
}
/** 发起账号绑定。 */
async function bindAccount() {
binding.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/bind/start`, {})
const authorizeUrl = response?.data?.authorize_url
if (!response?.success || !authorizeUrl) {
throw new Error(response?.message || '无法发起绑定')
}
window.addEventListener('message', handleBindMessage)
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80')
if (!popup) {
window.removeEventListener('message', handleBindMessage)
throw new Error('浏览器阻止了认证弹窗')
}
bindPopupTimer = setInterval(() => {
if (!popup.closed) return
clearBindPopupTimer()
window.removeEventListener('message', handleBindMessage)
if (binding.value) {
binding.value = false
loadStatus()
}
}, 500)
} catch (error) {
binding.value = false
errorMessage.value = error?.message || '绑定失败'
}
}
/** 解绑当前账号。 */
async function unbindAccount() {
binding.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/unbind`, {})
if (response?.success) {
successMessage.value = 'OIDC 账号已解绑'
await loadStatus()
} else {
errorMessage.value = response?.message || '解绑失败'
}
} catch (error) {
errorMessage.value = error?.message || '解绑失败'
} finally {
binding.value = false
}
}
/** 组件挂载时加载状态。 */
onMounted(loadStatus)
/** 组件卸载时清理绑定监听器。 */
onUnmounted(() => {
clearBindPopupTimer()
window.removeEventListener('message', handleBindMessage)
})
</script>
<template>
<div class="oidc-auth-admin pa-4">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mb-4">{{ errorMessage }}</VAlert>
<VAlert v-if="successMessage" type="success" variant="tonal" class="mb-4">{{ successMessage }}</VAlert>
<VCard :loading="loading" class="mb-4">
<VCardItem>
<VCardTitle>OIDC 账号绑定</VCardTitle>
<VCardSubtitle v-if="isBound">已绑定 {{ status.binding?.masked_sub }}</VCardSubtitle>
<VCardSubtitle v-else>当前账号尚未绑定 OIDC</VCardSubtitle>
</VCardItem>
<VCardText>
<VBtn v-if="!isBound" color="primary" prepend-icon="mdi-openid" :loading="binding" @click="bindAccount">
绑定 OIDC 账号
</VBtn>
<VBtn v-else color="error" variant="tonal" prepend-icon="mdi-link-off" :loading="binding" @click="unbindAccount">
解绑 OIDC 账号
</VBtn>
</VCardText>
</VCard>
<VCard v-if="isAdmin">
<VCardItem>
<VCardTitle>OIDC Provider 配置</VCardTitle>
<VCardSubtitle>{{ status.public?.redirect_uri }}</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="config.enabled" label="启用 OIDC 登录" color="primary" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.provider_name" label="入口名称" prepend-inner-icon="mdi-openid" />
</VCol>
<VCol cols="12">
<VTextField v-model="config.issuer" label="Issuer" placeholder="https://idp.example.com" prepend-inner-icon="mdi-web" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.client_id" label="Client ID" prepend-inner-icon="mdi-identifier" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.client_secret" label="Client Secret" type="password" prepend-inner-icon="mdi-key" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.scopes" label="Scopes" placeholder="openid profile email" prepend-inner-icon="mdi-format-list-checks" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.redirect_uri" label="回调地址覆盖" placeholder="留空自动生成" prepend-inner-icon="mdi-call-made" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.username_claim" label="用户名 Claim" prepend-inner-icon="mdi-account" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.email_claim" label="邮箱 Claim" prepend-inner-icon="mdi-email" />
</VCol>
<VCol cols="12">
<VSwitch v-model="config.allow_auto_bind_by_username" label="允许按用户名 Claim 自动绑定已有用户" color="primary" />
</VCol>
</VRow>
<div class="d-flex flex-wrap gap-3 mt-2">
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="saveConfig">保存</VBtn>
<VBtn color="info" variant="tonal" prepend-icon="mdi-connection" :loading="testing" @click="testConnection">测试连接</VBtn>
</div>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -1,106 +0,0 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
provider: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
})
const emit = defineEmits(['authenticated', 'error', 'close'])
const loading = ref(false)
const errorMessage = ref('')
let popupTimer = null
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`)
const providerName = computed(() => props.provider?.name || 'OIDC 登录')
/** 拼接 API 路径为可用于 window.open 的 URL。 */
function buildApiUrl(path) {
const base = props.api?.defaults?.baseURL || '/api/v1/'
const normalizedBase = base.endsWith('/') ? base : `${base}/`
const normalizedPath = String(path || '').replace(/^\/+/, '')
return `${normalizedBase}${normalizedPath}`
}
/** 关闭弹窗轮询并清理状态。 */
function clearPopupTimer() {
if (popupTimer) {
clearInterval(popupTimer)
popupTimer = null
}
}
/** 处理 OIDC 回调窗口发回的认证消息。 */
function handleOidcMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_callback') return
window.removeEventListener('message', handleOidcMessage)
clearPopupTimer()
loading.value = false
if (event.data.success && event.data.data?.ticket) {
emit('authenticated', { ticket: event.data.data.ticket })
return
}
const message = event.data?.message || 'OIDC 认证失败'
errorMessage.value = message
emit('error', { message })
}
/** 发起 OIDC 登录授权弹窗。 */
function startLogin() {
errorMessage.value = ''
loading.value = true
window.addEventListener('message', handleOidcMessage)
const popup = window.open(
buildApiUrl(`${pluginBase.value}/authorize`),
'moviepilot_oidc_login',
'width=600,height=720,left=200,top=80',
)
if (!popup) {
loading.value = false
window.removeEventListener('message', handleOidcMessage)
errorMessage.value = '浏览器阻止了认证弹窗'
emit('error', { message: errorMessage.value })
return
}
popupTimer = setInterval(() => {
if (!popup.closed) return
clearPopupTimer()
window.removeEventListener('message', handleOidcMessage)
if (loading.value) {
loading.value = false
errorMessage.value = '认证窗口已关闭'
emit('error', { message: errorMessage.value })
}
}, 500)
}
/** 组件卸载时清理监听器和定时器。 */
onUnmounted(() => {
clearPopupTimer()
window.removeEventListener('message', handleOidcMessage)
})
</script>
<template>
<div class="oidc-auth-page">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mb-4">
{{ errorMessage }}
</VAlert>
<VBtn block color="primary" prepend-icon="mdi-openid" :loading="loading" @click="startLogin">
{{ providerName }}
</VBtn>
<VBtn block variant="text" class="mt-2" @click="emit('close')">取消</VBtn>
</div>
</template>

View File

@@ -1,4 +0,0 @@
import { createApp } from 'vue'
import AppPage from './components/AppPage.vue'
createApp(AppPage).mount('#app')

View File

@@ -1,45 +0,0 @@
# 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
- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克`
这类轻插件更适合做“稳定执行层”:
- 智能体负责理解意图和补参数
- 插件负责真正转存

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,821 @@
import random
import re
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
from urllib.parse import urlparse, parse_qs
import requests
from apscheduler.triggers.cron import CronTrigger
from fastapi.responses import FileResponse
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType
class UpdateWeChatIp(_PluginBase):
# 插件在界面中的展示名称
plugin_name = "动态企微可信IP"
# 插件描述
plugin_desc = "修改企微应用可信IP可本地扫码刷新Cookie"
# 插件图标
plugin_icon = "Wecom_A.png"
# 插件版本,必须和 package.v2.json 中保持一致
plugin_version = "1.0.8"
# 作者信息
plugin_author = "书小白"
author_url = "https://github.com/thshu/MoviePilot-Plugins"
# 配置项前缀,建议保持唯一,避免与其他插件冲突
plugin_config_prefix = "UpdateWeChatIp_"
# 插件加载顺序,数值越小越早
plugin_order = 50
# 插件可见权限级别
auth_level = 1
# 运行时状态字段
_enabled = False
_se = None
_qrcode_key = None
_tl_key = None
_captcha = {}
_wwrtx_sid = None
_party_cache_data = None
_app_id = ""
_ip = None
_is_login = False
onlyonce = False
_cron = ""
_UpdateLogKey = 'UpdateLog'
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn",
'http://v4.666666.host:66/ip', 'https://ipv4.ddnspod.com', 'https://v4.66666.host:66/ip',
'https://4.ipw.cn', 'https://ip.3322.net', 'https://6.66666.host:66/ip']
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
_headers = {
'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
'Accept-Encoding': "gzip, deflate, br, zstd",
'pragma': "no-cache",
'cache-control': "no-cache",
'sec-ch-ua-platform': "\"Windows\"",
'x-requested-with': "XMLHttpRequest",
'sec-ch-ua': "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"",
'sec-ch-ua-mobile': "?0",
'sec-fetch-site': "same-origin",
'sec-fetch-mode': "cors",
'sec-fetch-dest': "empty",
'referer': "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/login_qrcode",
'accept-language': "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7",
'priority': "u=1, i",
}
def init_plugin(self, config: dict = None):
"""根据当前配置初始化插件。"""
config = config or {}
self._enabled = bool(config.get("_enabled"))
self._wwrtx_sid = config.get("_wwrtx_sid")
self._app_id = config.get("_app_id")
self._cron = config.get("_cron")
self._party_cache_data = config.get("_party_cache_data")
self._se = requests.Session()
self._se.cookies.set('wwrtx.sid', self._wwrtx_sid)
def _save_current_config(self):
self._login_success()
def get_state(self) -> bool:
"""返回插件当前是否启用。"""
return self._enabled
def get_service(self) -> List[Dict[str, Any]]:
if self._enabled and self._cron:
return [
{
"id": self.__class__.__name__,
"name": f"{self.__class__.__name__}_{self.plugin_name}服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.check,
"kwargs": {}
},
]
return []
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
注册插件远程命令
"""
return [{
"cmd": "/update_wechat_ip",
"event": EventType.PluginAction,
"desc": "获取企业微信二维码",
"category": "获取企业微信二维码",
"data": {
"action": "update_wechat_ip"
}
}
]
@eventmanager.register(EventType.PluginAction)
def command_action(self, event: Event):
"""
远程命令响应
"""
event_data = event.event_data
if not event_data or event_data.get("action") not in [i['data']['action'] for i in self.get_command()]:
return
# 获取用户信息
channel = event_data.get("channel")
arg_str = event_data.get("arg_str")
source = event_data.get("source")
user = event_data.get("user")
if arg_str is not None:
if arg_str == '扫码完成':
self._login(channel, user)
elif len(re.findall('[0-9]', arg_str)) == 6:
self._captcha[self._qrcode_key] = arg_str
self._confirm_captcha(self._tl_key, self._captcha.get(self._qrcode_key))
self._wwrtx_sid = self._se.cookies.get_dict().get('wwrtx.sid')
if self._party_cache():
self._login_success()
self.post_message(
channel=channel,
title="登录成功",
userid=user,
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
)
else:
self.post_message(
channel=channel,
title="登录失败",
userid=user,
text=f"登录失败,返回值:{self._party_cache_data}",
)
else:
self.post_message(
channel=channel,
title="无效的输入",
userid=user,
content="无效的输入",
)
else:
# 初始化变量
self._qrcode_key = None
self._tl_key = None
self._captcha = {}
self._qrcode_key = self._get_key()
image_url = self._qrcode(self._qrcode_key)
self.post_message(
channel=channel,
title="登录二维码",
text='\n'.join(
[
"请选择要执行的操作:",
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 扫码完成\n```"
]
),
userid=user,
buttons=[[{"text": f'扫码完成',
"callback_data": f"[PLUGIN]{self.__class__.__name__}|扫码完成|{self._qrcode_key}"}]],
image=image_url
)
@eventmanager.register(EventType.MessageAction)
def message_action(self, event: Event):
"""
处理消息按钮回调
"""
event_data = event.event_data
if not event_data:
return
# 检查是否为本插件的回调
plugin_id = event_data.get("plugin_id")
if plugin_id != self.__class__.__name__:
return
# 获取回调数据
channel = event_data.get("channel")
source = event_data.get("source")
userid = event_data.get("userid")
# 获取原始消息ID和聊天ID用于直接更新原消息
original_message_id = event_data.get("original_message_id")
original_chat_id = event_data.get("original_chat_id")
callback_text = event_data.get("text", "")
if "|" not in callback_text:
self.post_message(
channel=channel,
title="登录失败",
userid=userid,
text=f"未获取到本地登录对应的qrcode_key",
)
return
text, qrcode_key = callback_text.split("|", 1)
if text == "扫码完成":
self._qrcode_key = qrcode_key
self._login(channel, userid)
if text == "输入完毕":
self._confirm_captcha(self._tl_key, self._captcha.get(self._qrcode_key))
if self._party_cache():
self._login_success()
self.post_message(
channel=channel,
title="登录成功",
userid=userid,
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
)
else:
self.post_message(
channel=channel,
title="登录失败",
userid=userid,
text=f"登录失败,返回值:{self._party_cache_data}",
)
elif len(re.findall('[0-9]', text)) != 0:
if qrcode_key not in self._captcha.keys():
self._captcha[qrcode_key] = ""
self._captcha[qrcode_key] += text
self.post_message(
channel=channel,
title="短信验证码",
userid=userid,
buttons=self._get_buttons(),
text='\n'.join(
[
"触发验证码:",
f"验证码内容:{self._captcha[qrcode_key]}\n"
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 验证码内容\n```"
]
),
original_message_id=original_message_id,
original_chat_id=original_chat_id
)
else:
self.post_message(
channel=channel,
title="无效的输入",
userid=userid,
content="无效的输入",
)
def get_api(self) -> List[Dict[str, Any]]:
"""没有插件 API 时直接返回空列表。"""
return [
{
"path": "/img/{uuid}",
"endpoint": self.get_img,
"methods": ["GET"],
# 前端插件页面通过 api 模块调用时,通常使用 bear
"auth": "apikey",
"summary": "获取图片",
"description": "获取图片",
},
{
"path": "/UpdateIP",
"endpoint": self.UpdateIp,
"methods": ["GET"],
# 前端插件页面通过 api 模块调用时,通常使用 bear
"auth": "apikey",
"summary": "更新企业微信IP白名单",
"description": "更新企业微信IP白名单,需要传递查询参数,参数名为:ip",
},
]
def UpdateIp(self, ip):
self._ip = ip
self._save_ip_config()
def get_img(self, uuid):
save_path: Path = self.get_data_path() / f"WeChatQr.jpg"
return FileResponse(
save_path,
media_type="image/jpeg"
)
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""返回配置页 JSON 和默认配置模型。"""
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': 'onlyonce',
'label': '立即检测一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': '_cron',
'label': '[必填]检测周期',
'placeholder': '*/10 * * * *'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': '_app_id',
'label': '[必填]应用ID',
'rows': 1,
'placeholder': '输入应用ID,多个使用(,)英文逗号隔开,在企业微信应用页面URL末尾获取'
}
}
]
}
]
}
]
}
], {
"_enabled": False,
"_wwrtx_sid": "",
"_app_id": "",
"_party_cache_data": {},
"_cron": '*/10 * * * *'
}
def get_page(self) -> List[dict]:
"""返回详情页 JSON。"""
# ---------- 获取并排序更新日志 ----------
raw_data = self.get_data(self._UpdateLogKey) or []
update_log: List[UpdateLogDto] = [UpdateLogDto.from_dict(i) for i in raw_data]
data_list = sorted(update_log, key=lambda x: x.UpdateTime, reverse=True)
update_log_trs = [
{
"component": "tr",
"props": {"class": "text-sm"},
"content": [
{
"component": "td",
"props": {
"style": {"color": "red"} if not data.status else {}
},
"text": "成功" if data.status else "失败",
},
{"component": "td", "text": data.app_id},
{"component": "td", "text": data.ip},
{"component": "td", "text": data.result},
{"component": "td",
"text": data.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if data.UpdateTime else ""},
],
}
for data in data_list
]
# ---------- 安全获取 party 名称 ----------
party_cache = self._party_cache_data or {}
party_list = party_cache.get("party_list", {}).get("list") or [{}]
party_name = party_list[0].get("name", "未知")
# ---------- 构建页面结构 ----------
return [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
# 顶部状态标题
{
"component": "div",
"props": {
"style": {
"display": "flex",
"justifyContent": "center",
"alignItems": "center",
"flexDirection": "column",
"gap": "10px",
"marginBottom": "20px", # 增加与表格的间距
}
},
"content": [
{
"component": "div",
"text": f"{party_name}已登录" if self._is_login else "登录失效",
"props": {
"style": {
"fontSize": "22px",
"fontWeight": "bold",
"color": "#ffffff",
"backgroundColor": "#9B50FF",
"padding": "8px 16px",
"borderRadius": "5px",
"textAlign": "center",
"display": "inline-block",
}
},
}
],
},
# 日志表格
{
"component": "VTable",
"props": {"hover": True},
"content": [
{
"component": "thead",
"props": {"class": "text-no-wrap"},
"content": [
{
"component": "th",
"props": {"class": "text-start ps-4"},
"text": "状态",
},
{
"component": "th",
"props": {"class": "text-start ps-4"},
"text": "appId",
},
{
"component": "th",
"props": {"class": "text-start ps-4"},
"text": "更新IP",
},
{
"component": "th",
"props": {"class": "text-start ps-4"},
"text": "返回值",
},
{
"component": "th",
"props": {"class": "text-start ps-4"},
"text": "更新时间",
},
],
},
{
"component": "tbody",
"content": update_log_trs,
},
],
},
],
}
],
}
]
def stop_service(self):
"""没有后台任务时可以留空。"""
pass
def _get_key(self):
logger.info("开始获取登录二维码key")
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/get_key"
current_ts = int(time.time() * 1000)
params = {
'r': str(random.random()),
'login_type': "login_admin",
'callback': f"wwqrloginCallback_{current_ts}",
'redirect_uri': "https://work.weixin.qq.com/wework_admin/loginpage_wx?_r=234&redirect_uri=https%3A%2F%2Fwork.weixin.qq.com%2Fwework_admin%2Fframe&url_hash=%23%2Fapps#/apps",
'crossorigin': "1"
}
response = self._se.get(url, params=params, headers=self._headers)
logger.info(f"获取登录二维码key成功,返回值:{response.text}")
return response.json().get('data', {}).get('qrcode_key')
def _qrcode(self, key) -> str:
logger.info("开始获取登录二维码图片")
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/qrcode"
params = {
'qrcode_key': key,
'login_type': "login_admin"
}
response = self._se.get(url, params=params, headers=self._headers)
logger.info("登录二维码图片获取成功")
img_path: Path = self.get_data_path() / f"WeChatQr.jpg"
img_path.write_bytes(response.content)
logger.info(f"登录二维码已写入文件,路径:{img_path}")
uri = f"/api/v1/plugin/{self.__class__.__name__}/img/{uuid.uuid4().__str__().replace('-', '')}?apikey={settings.API_TOKEN}"
img_url = settings.MP_DOMAIN(uri) or f"http://127.0.0.1:{settings.PORT}{uri}"
logger.info(f"构建二维码地址为:{img_url}")
return img_url
def _check(self, key) -> Dict:
logger.info(f"开始获取扫码结果")
for _ in range(2):
url = "https://work.weixin.qq.com/wework_admin/wwqrlogin/mng/check"
params = {
'qrcode_key': key,
'status': "QRCODE_SCAN_ING"
}
response = self._se.get(url, params=params, headers=self._headers)
data = response.json().get('data', {})
logger.info(f"扫码结果获取完成:{response.text}")
if data.get("status") == "QRCODE_SCAN_SUCC":
return data
time.sleep(1)
logger.info(f"获取扫码结果超时")
return None
def _loginpage_wx(self, key, code) -> requests.Response:
logger.info(f"开始登录")
url = "https://work.weixin.qq.com/wework_admin/loginpage_wx"
params = {
'_r': "234",
'redirect_uri': "https://work.weixin.qq.com/wework_admin/frame",
'url_hash': "#/apps",
'code': code,
'auth_redirect_time': "1780446137000",
'getauth_time': "1780446137000",
'wwqrlogin': "1",
'qrcode_key': key,
'auth_source': "SOURCE_FROM_WEWORK",
'confirm_type': "0"
}
response = self._se.get(url, params=params, headers=self._headers)
logger.info(f"登录完成,返回值:{response.text}")
return response
def _confirm_captcha(self, tl_key, captcha):
logger.info(f"开始提交验证码")
_url = "https://work.weixin.qq.com/wework_admin/mobile_confirm/confirm_captcha?ajax=1&f=json&d2st="
_data = {
"captcha": captcha,
"tl_key": tl_key
}
res = self._se.post(_url, json=_data, headers=self._headers)
logger.info(f"提交验证码返回值:{res.text}")
res = self._se.get(f"https://work.weixin.qq.com/wework_admin/login/choose_corp?tl_key={tl_key}")
logger.info(f"choose_corp接口返回值:{res.text}")
def _party_cache(self):
logger.info(f"开始获取企业信息,判断是否登录成功")
if not self._wwrtx_sid:
return False
url = "https://work.weixin.qq.com/wework_admin/contacts/party/cache"
params = {
'lang': "zh_CN",
'f': "json",
'ajax': "1",
'timeZoneInfo[zone_offset]': "-8",
}
self._se.cookies.set('wwrtx.sid', self._wwrtx_sid)
try:
res = self._se.post(url, params=params, headers=self._headers, timeout=10)
if res.status_code == 200:
data = res.json()
if 'errCode' not in res.text:
self._party_cache_data = data.get('data')
self._is_login = True
return True
else:
self._party_cache_data = data
else:
logger.error(f"获取企业微信部门缓存失败HTTP状态码{res.status_code}")
except Exception as e:
logger.error(f"获取企业微信部门缓存异常: {e}")
self._is_login = False
return False
def _login(self, channel, userid):
logger.info(f"触发登录回调,开始执行登录步骤")
check_data = self._check(self._qrcode_key)
if check_data:
code = check_data.get('auth_code')
res = self._loginpage_wx(self._qrcode_key, code)
if 'tl_key' in res.url:
logger.info(f"返回值中获取到tl_key,触发短信验证码")
self.post_message(
channel=channel,
title="短信验证码",
userid=userid,
buttons=self._get_buttons(),
text='\n'.join(
[
"触发验证码:",
f"如果按钮不可用,可回复:\n```\n/update_wechat_ip 验证码内容\n```"
]
),
)
parsed = urlparse(res.url)
query_params = parse_qs(parsed.query)
# 获取 tl_key 的值parse_qs 返回字典,每个键对应一个列表)
self._tl_key = query_params.get('tl_key', [None])[0]
else:
self._wwrtx_sid = self._se.cookies.get_dict().get('wwrtx.sid')
if self._party_cache():
logger.info(f"登录成功")
self._login_success()
self.post_message(
channel=channel,
title="登录成功",
userid=userid,
text=f"成功登录企业:{self._party_cache_data.get('party_list', {}).get('list', [{}])[0].get('name')}",
)
else:
logger.error(f"登录失败,返回值:{self._party_cache_data}")
self.post_message(
channel=channel,
title="登录失败",
userid=userid,
text=f"登录失败,返回值:{self._party_cache_data}",
)
def _save_ip_config(self):
logger.info(f"更新IP为:{self._ip}")
_update_log = []
url = 'https://work.weixin.qq.com/wework_admin/apps/saveIpConfig?lang=zh_CN&f=json&ajax=1'
for appId in self._app_id.split(','):
appId = appId.strip()
if not appId:
continue
data = {
'app_id': appId,
'ipList[]': self._ip
}
res = self._se.post(url, data=data, headers=self._headers)
if 'err' in res.text:
logger.error(f"{appId}更新IP白名单失败返回值{res.text}")
else:
logger.info(f'{appId}更新白名单成功更新IP为{self._ip},接口返回值:{res.text}')
_update_log.append(UpdateLogDto(
status='err' not in res.text,
ip=self._ip,
app_id=appId,
result=res.text
))
update_log: List[UpdateLogDto] = [UpdateLogDto.from_dict(i) for i in self.get_data(self._UpdateLogKey) or []]
self.save_data(self._UpdateLogKey, [i.to_dict() for i in update_log + _update_log])
def _login_success(self):
logger.info("保存配置文件")
self.update_config({
'_enabled': self._enabled,
'_wwrtx_sid': self._wwrtx_sid,
'_app_id': self._app_id,
'_party_cache_data': self._party_cache_data,
'_cron': self._cron,
})
def _get_buttons(self):
buttons = [
[
{
"text": str(j),
"callback_data": f"[PLUGIN]{self.__class__.__name__}|{j}|{self._qrcode_key}"
}
for j in range(i * 5, (i + 1) * 5)
]
for i in range(2)
]
buttons.append(
[{"text": f'输入完毕',
"callback_data": f"[PLUGIN]{self.__class__.__name__}|输入完毕|{self._qrcode_key}"}]
)
return buttons
def get_ip_from_url(self):
urls = self._ip_urls
for url in urls:
try:
response = requests.get(url, timeout=3)
if response.status_code == 200:
ip_address = re.search(self._ip_pattern, response.text)
if ip_address:
return ip_address.group()
except Exception as e:
if "104" not in str(e) and 'Read timed out' not in str(e): # 忽略网络波动,都失败会返回None, "获取IP失败"
logger.warning(f"{url} 获取IP失败, Error: {e}")
return "获取IP失败"
def _get_corp_app_v2(self):
logger.info(f"开始获取企业应用配置")
if not self._app_id:
logger.error("未配置应用ID")
return {}
app_id = self._app_id.split(",")[0].strip()
url = f'https://work.weixin.qq.com/wework_admin/apps/getCorpAppV2?lang=zh_CN&f=json&ajax=1&app_id={app_id}'
try:
res = self._se.get(url, timeout=10)
if res.status_code == 200:
return res.json().get('data', {})
else:
logger.error(f"获取企业应用配置失败HTTP状态码{res.status_code}")
except Exception as e:
logger.error(f"获取企业应用配置异常: {e}")
return {}
def check(self):
if not self._enabled:
logger.error("插件未开启")
return
self._party_cache()
if not self._is_login:
logger.error("未登录")
self.post_message(
title="企业微信登录状态失效",
text='企业微信登录状态失效,请重新操作登录'
)
return
self._ip = self.get_ip_from_url()
if not self._ip or self._ip == "获取IP失败":
logger.error("获取当前公网IP失败跳过本次检测")
return
app_config = self._get_corp_app_v2()
app_config_ips = app_config.get('app', {}).get('white_ip_list', {}).get('ip', [])
if self._ip not in app_config_ips:
self._save_ip_config()
self.post_message(
title='企业微信IP更新',
text="出发IP更新,最新IP为:" + self._ip
)
@dataclass
class UpdateLogDto:
status: bool
ip: str
app_id: str
result: str
UpdateTime: datetime = None
def __post_init__(self):
if self.UpdateTime is None:
self.UpdateTime = datetime.now()
def to_dict(self):
return {
"status": self.status,
"ip": self.ip,
"app_id": self.app_id,
"result": self.result,
"UpdateTime": self.UpdateTime.isoformat()
}
@classmethod
def from_dict(cls, data: dict):
# 深拷贝一份,避免修改原字典
kwargs = dict(data)
# 将 'UpdateTime' 字符串转为 datetime注意参数名对应 __init__ 的 update_time
kwargs['UpdateTime'] = datetime.fromisoformat(kwargs.pop('UpdateTime'))
return cls(**kwargs)

View File

@@ -0,0 +1 @@
requests>=2.34.2

View File

@@ -4,11 +4,13 @@
`飞书命令入口``外部智能体``盘搜``影巢``115``夸克``MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
当前版本:`0.2.71`
当前版本:`0.2.73`
当前 helper 版本:`0.1.51`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.71
当前已验证上游 MoviePilot`v2.11.4`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
如果你是第一次用这个仓库,先把这个插件跑通就够了。
@@ -19,7 +21,7 @@
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
- 你想让 `OpenClaw``Hermes``WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅` 放进同一套命令入口。
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口而是统一交给插件执行。
---
@@ -35,10 +37,10 @@
```text
盘搜搜索 片名
影巢搜索 片名
转存 片名
夸克转存 片名
搜索 片名
下载 片名
更新检查 片名
订阅 片名
选择 1
115登录
影巢签到
```
@@ -62,8 +64,8 @@
如果你的智能体客户端支持 MoviePilot 官方 MCP可以一起接。
- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、115/夸克转存、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、编号选择后的 115 / 夸克处理、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
- `MP搜索 / PT搜索 / 下载 / 订阅` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
MCP 地址通常是:
@@ -84,28 +86,21 @@ X-API-KEY=你的 MoviePilot API_TOKEN
| `盘搜搜索 <片名>` | 先查盘搜;盘搜没结果时按开关补查影巢 |
| `影巢搜索 <片名>` | 先查影巢;影巢没结果时按开关补查盘搜 |
| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 |
| `盘搜更新检查 <片名>` | 只看盘搜侧更新资源 |
| `影巢更新检查 <片名>` | 只看影巢侧更新资源 |
补充:
- `搜索 第 3 集``搜索 E03` 这类带集数线索的写法,会直接按 MP/PT 搜索,不再回退到云盘。
- `检查 大君夫人``检查大君夫人` 这类写法,会按更新检查处理;但 `检查115登录` 仍然保留为 115 登录检查
- `更新检查 xx 剧` / `检查 xx 剧` 这类带剧集意图的写法,会按 MP/PT 搜索;云盘侧更新检查请显式使用 `盘搜更新检查``影巢更新检查`
- `更新检查 <片名>``查更新 <片名>``检查 <片名>` 这些旧写法已并回搜索语义;现在直接用 `搜索 / 盘搜搜索 / 影巢搜索 / MP搜索 / PT搜索` 即可
- `检查115登录` 仍然保留为 115 登录检查,不受这次简化影响
### 转存 / 下载
### 下载
| 命令 | 作用 |
|---|---|
| `转存 <片名>` | 默认等同 `115转存 <片名>` |
| `115转存 <片名>` | 搜索后优先转存到 115 |
| `夸克转存 <片名>` | 搜索后优先转存到夸克 |
| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先找片并列出 PT 候选 |
注意:
- `转存 <片名>` 默认是 115不会自动改成夸克
- 只有明确说 `夸克转存 <片名>` 才走夸克。
- 标题级 `转存 <片名>` / `115转存 <片名>` / `夸克转存 <片名>` 已取消;搜索结果出来后按编号继续处理
- `下载 <片名>` 是 PT 下载,不是云盘转存。
- PT 搜索结果里直接回编号会立即下载。
- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。
@@ -138,7 +133,6 @@ n
- `云盘搜索` 已废弃,收到后只会提示改用 `盘搜搜索` / `影巢搜索`
- 115 转存
- 夸克转存
- 更新检查
- 编号选择、详情、翻页
- 智能建议与候选推荐

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
.aro-config[data-v-eb2e8235] {
display: flex;
flex-direction: column;
max-height: 82vh;
}
.aro-toolbar[data-v-eb2e8235] {
flex: 0 0 auto;
}
.aro-body[data-v-eb2e8235] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 16px;
}
.aro-inner[data-v-eb2e8235] {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.aro-intro[data-v-eb2e8235] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.06);
color: rgb(var(--v-theme-on-surface));
line-height: 1.5;
}
.aro-card-head[data-v-eb2e8235] {
padding-bottom: 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import Config from './__federation_expose_Config-SJKIC-xp.js';
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');
const _sfc_main = {
__name: 'Page',
props: {
api: {
type: Object,
default: () => ({}),
},
initialConfig: {
type: Object,
default: () => ({}),
},
},
emits: ['save', 'close'],
setup(__props, { emit: __emit }) {
const emit = __emit;
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(Config, {
api: __props.api,
"initial-config": __props.initialConfig,
onSave: _cache[0] || (_cache[0] = payload => emit('save', payload)),
onClose: _cache[1] || (_cache[1] = $event => (emit('close')))
}, null, 8, ["api", "initial-config"]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,418 @@
const buildIdentifier = "[0-9A-Za-z-]+";
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
const numericIdentifier = "0|[1-9]\\d*";
const numericIdentifierLoose = "[0-9]+";
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
const gtlt = "((?:<|>)?=?)";
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
const loneTilde = "(?:~>?)";
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
const loneCaret = "(?:\\^)";
const caretTrim = `(\\s*)${loneCaret}\\s+`;
const star = "(<|>)?=?\\s*\\*";
const caret = `^${loneCaret}${xRangePlain}$`;
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
const tilde = `^${loneTilde}${xRangePlain}$`;
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
function parseRegex(source) {
return new RegExp(source);
}
function isXVersion(version) {
return !version || version.toLowerCase() === "x" || version === "*";
}
function pipe(...fns) {
return (x) => {
return fns.reduce((v, f) => f(v), x);
};
}
function extractComparator(comparatorString) {
return comparatorString.match(parseRegex(comparator));
}
function combineVersion(major, minor, patch, preRelease2) {
const mainVersion2 = `${major}.${minor}.${patch}`;
if (preRelease2) {
return `${mainVersion2}-${preRelease2}`;
}
return mainVersion2;
}
function parseHyphen(range) {
return range.replace(
parseRegex(hyphenRange),
(_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
if (isXVersion(fromMajor)) {
from = "";
} else if (isXVersion(fromMinor)) {
from = `>=${fromMajor}.0.0`;
} else if (isXVersion(fromPatch)) {
from = `>=${fromMajor}.${fromMinor}.0`;
} else {
from = `>=${from}`;
}
if (isXVersion(toMajor)) {
to = "";
} else if (isXVersion(toMinor)) {
to = `<${+toMajor + 1}.0.0-0`;
} else if (isXVersion(toPatch)) {
to = `<${toMajor}.${+toMinor + 1}.0-0`;
} else if (toPreRelease) {
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
} else {
to = `<=${to}`;
}
return `${from} ${to}`.trim();
}
);
}
function parseComparatorTrim(range) {
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
}
function parseTildeTrim(range) {
return range.replace(parseRegex(tildeTrim), "$1~");
}
function parseCaretTrim(range) {
return range.replace(parseRegex(caretTrim), "$1^");
}
function parseCarets(range) {
return range.trim().split(/\s+/).map((rangeVersion) => {
return rangeVersion.replace(
parseRegex(caret),
(_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
if (major === "0") {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else {
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
}
} else if (preRelease2) {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
} else {
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
}
} else {
if (major === "0") {
if (minor === "0") {
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
} else {
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
}
}
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
}
}
);
}).join(" ");
}
function parseTildes(range) {
return range.trim().split(/\s+/).map((rangeVersion) => {
return rangeVersion.replace(
parseRegex(tilde),
(_, major, minor, patch, preRelease2) => {
if (isXVersion(major)) {
return "";
} else if (isXVersion(minor)) {
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
} else if (isXVersion(patch)) {
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
} else if (preRelease2) {
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
}
);
}).join(" ");
}
function parseXRanges(range) {
return range.split(/\s+/).map((rangeVersion) => {
return rangeVersion.trim().replace(
parseRegex(xRange),
(ret, gtlt2, major, minor, patch, preRelease2) => {
const isXMajor = isXVersion(major);
const isXMinor = isXMajor || isXVersion(minor);
const isXPatch = isXMinor || isXVersion(patch);
if (gtlt2 === "=" && isXPatch) {
gtlt2 = "";
}
preRelease2 = "";
if (isXMajor) {
if (gtlt2 === ">" || gtlt2 === "<") {
return "<0.0.0-0";
} else {
return "*";
}
} else if (gtlt2 && isXPatch) {
if (isXMinor) {
minor = 0;
}
patch = 0;
if (gtlt2 === ">") {
gtlt2 = ">=";
if (isXMinor) {
major = +major + 1;
minor = 0;
patch = 0;
} else {
minor = +minor + 1;
patch = 0;
}
} else if (gtlt2 === "<=") {
gtlt2 = "<";
if (isXMinor) {
major = +major + 1;
} else {
minor = +minor + 1;
}
}
if (gtlt2 === "<") {
preRelease2 = "-0";
}
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
} else if (isXMinor) {
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
} else if (isXPatch) {
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
}
return ret;
}
);
}).join(" ");
}
function parseStar(range) {
return range.trim().replace(parseRegex(star), "");
}
function parseGTE0(comparatorString) {
return comparatorString.trim().replace(parseRegex(gte0), "");
}
function compareAtom(rangeAtom, versionAtom) {
rangeAtom = +rangeAtom || rangeAtom;
versionAtom = +versionAtom || versionAtom;
if (rangeAtom > versionAtom) {
return 1;
}
if (rangeAtom === versionAtom) {
return 0;
}
return -1;
}
function comparePreRelease(rangeAtom, versionAtom) {
const { preRelease: rangePreRelease } = rangeAtom;
const { preRelease: versionPreRelease } = versionAtom;
if (rangePreRelease === void 0 && !!versionPreRelease) {
return 1;
}
if (!!rangePreRelease && versionPreRelease === void 0) {
return -1;
}
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
return 0;
}
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
const rangeElement = rangePreRelease[i];
const versionElement = versionPreRelease[i];
if (rangeElement === versionElement) {
continue;
}
if (rangeElement === void 0 && versionElement === void 0) {
return 0;
}
if (!rangeElement) {
return 1;
}
if (!versionElement) {
return -1;
}
return compareAtom(rangeElement, versionElement);
}
return 0;
}
function compareVersion(rangeAtom, versionAtom) {
return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom);
}
function eq(rangeAtom, versionAtom) {
return rangeAtom.version === versionAtom.version;
}
function compare(rangeAtom, versionAtom) {
switch (rangeAtom.operator) {
case "":
case "=":
return eq(rangeAtom, versionAtom);
case ">":
return compareVersion(rangeAtom, versionAtom) < 0;
case ">=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
case "<":
return compareVersion(rangeAtom, versionAtom) > 0;
case "<=":
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
case void 0: {
return true;
}
default:
return false;
}
}
function parseComparatorString(range) {
return pipe(
parseCarets,
parseTildes,
parseXRanges,
parseStar
)(range);
}
function parseRange(range) {
return pipe(
parseHyphen,
parseComparatorTrim,
parseTildeTrim,
parseCaretTrim
)(range.trim()).split(/\s+/).join(" ");
}
function satisfy(version, range) {
if (!version) {
return false;
}
const parsedRange = parseRange(range);
const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" ");
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
const extractedVersion = extractComparator(version);
if (!extractedVersion) {
return false;
}
const [
,
versionOperator,
,
versionMajor,
versionMinor,
versionPatch,
versionPreRelease
] = extractedVersion;
const versionAtom = {
version: combineVersion(
versionMajor,
versionMinor,
versionPatch,
versionPreRelease
),
major: versionMajor,
minor: versionMinor,
patch: versionPatch,
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".")
};
for (const comparator2 of comparators) {
const extractedComparator = extractComparator(comparator2);
if (!extractedComparator) {
return false;
}
const [
,
rangeOperator,
,
rangeMajor,
rangeMinor,
rangePatch,
rangePreRelease
] = extractedComparator;
const rangeAtom = {
operator: rangeOperator,
version: combineVersion(
rangeMajor,
rangeMinor,
rangePatch,
rangePreRelease
),
major: rangeMajor,
minor: rangeMinor,
patch: rangePatch,
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".")
};
if (!compare(rangeAtom, versionAtom)) {
return false;
}
}
return true;
}
// eslint-disable-next-line no-undef
const moduleMap = {};
const moduleCache = Object.create(null);
async function importShared(name, shareScope = 'default') {
return moduleCache[name]
? new Promise((r) => r(moduleCache[name]))
: (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name)
}
async function getSharedFromRuntime(name, shareScope) {
let module = null;
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
const versionObj = globalThis.__federation_shared__[shareScope][name];
const requiredVersion = moduleMap[name]?.requiredVersion;
const hasRequiredVersion = !!requiredVersion;
if (hasRequiredVersion) {
const versionKey = Object.keys(versionObj).find((version) =>
satisfy(version, requiredVersion)
);
if (versionKey) {
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
} else {
console.log(
`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`
);
}
} else {
const versionKey = Object.keys(versionObj)[0];
const versionValue = versionObj[versionKey];
module = await (await versionValue.get())();
}
}
if (module) {
return flattenModule(module, name)
}
}
async function getSharedFromLocal(name) {
if (moduleMap[name]?.import) {
let module = await (await moduleMap[name].get())();
return flattenModule(module, name)
} else {
console.error(
`consumer config import=false,so cant use callback shared module`
);
}
}
function flattenModule(module, name) {
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
if (typeof module.default === 'function') {
Object.keys(module).forEach((key) => {
if (key !== 'default') {
module.default[key] = module[key];
}
});
moduleCache[name] = module.default;
return module.default
}
if (module.default) module = Object.assign({}, module.default, module);
moduleCache[name] = module;
return module
}
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };

View File

@@ -0,0 +1,44 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_Page-DAJ1MzFo.js';
true&&(function polyfill() {
const relList = document.createElement("link").relList;
if (relList && relList.supports && relList.supports("modulepreload")) {
return;
}
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
processPreload(link);
}
new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "childList") {
continue;
}
for (const node of mutation.addedNodes) {
if (node.tagName === "LINK" && node.rel === "modulepreload")
processPreload(node);
}
}
}).observe(document, { childList: true, subtree: true });
function getFetchOpts(link) {
const fetchOpts = {};
if (link.integrity) fetchOpts.integrity = link.integrity;
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
if (link.crossOrigin === "use-credentials")
fetchOpts.credentials = "include";
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
else fetchOpts.credentials = "same-origin";
return fetchOpts;
}
function processPreload(link) {
if (link.ep)
return;
link.ep = true;
const fetchOpts = getFetchOpts(link);
fetch(link.href, fetchOpts);
}
}());
const {createApp} = await importShared('vue');
createApp(_sfc_main).mount('#app');

View File

@@ -0,0 +1,84 @@
const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Config":()=>{
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Config');
return __federation_import('./__federation_expose_Config-SJKIC-xp.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Page');
return __federation_import('./__federation_expose_Page-DAJ1MzFo.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;
if (typeof metaUrl === 'undefined') {
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
return;
}
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
const base = '/';
'assets';
cssFilePaths.forEach(cssPath => {
let href = '';
const baseUrl = base || curUrl;
if (baseUrl) {
const trimmer = {
trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path),
leading: (path) => (path.startsWith('/') ? path.slice(1) : path)
};
const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//');
const cleanBaseUrl = trimmer.trailing(baseUrl);
const cleanCssPath = trimmer.leading(cssPath);
const cleanCurUrl = trimmer.trailing(curUrl);
if (isAbsoluteUrl(baseUrl)) {
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
} else {
if (cleanCurUrl.includes(cleanBaseUrl)) {
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/');
} else {
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
}
}
} else {
href = cssPath;
}
if (dontAppendStylesToHead) {
const key = 'css__AgentResourceOfficer__' + exposeItemName;
window[key] = window[key] || [];
window[key].push(href);
return;
}
if (href in seen) return;
seen[href] = true;
const element = document.createElement('link');
element.rel = 'stylesheet';
element.href = href;
document.head.appendChild(element);
});
};
async function __federation_import(name) {
currentImports[name] ??= import(name);
return currentImports[name]
} const get =(module) => {
if(!moduleMap[module]) throw new Error('Can not find remote module ' + module)
return moduleMap[module]();
};
const init =(shareScope) => {
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
Object.entries(shareScope).forEach(([key, value]) => {
for (const [versionKey, versionValue] of Object.entries(value)) {
const scope = versionValue.scope || 'default';
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
const shared= globalThis.__federation_shared__[scope];
(shared[key] = shared[key]||{})[versionKey] = versionValue;
}
});
};
export { dynamicLoadingCss, get, init };

View File

@@ -0,0 +1,6 @@
<script type="module" crossorigin src="/assets/index-WG_aDWmR.js"></script>
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Config-SJKIC-xp.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Page-DAJ1MzFo.js">
<link rel="stylesheet" crossorigin href="/assets/__federation_expose_Config-DenBkx3K.css">
<div id="app"></div>

View File

@@ -35,49 +35,39 @@ _LARK_IMPORT_LOCK = threading.Lock()
_LARK_AUTO_INSTALL_ATTEMPTED = False
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
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
def _optional_import(module_name: str, attr_name: str) -> Any:
try:
module = importlib.import_module(module_name)
return getattr(module, attr_name)
except Exception:
return None
DownloadChain = _optional_import("app.chain.download", "DownloadChain")
DownloadHistoryOper = _optional_import("app.db.downloadhistory_oper", "DownloadHistoryOper")
DownloadHistory = _optional_import("app.db.models.downloadhistory", "DownloadHistory")
TransferHistory = _optional_import("app.db.models.transferhistory", "TransferHistory")
MediaChain = _optional_import("app.chain.media", "MediaChain")
SearchChain = _optional_import("app.chain.search", "SearchChain")
SiteOper = _optional_import("app.db.site_oper", "SiteOper")
SubscribeChain = _optional_import("app.chain.subscribe", "SubscribeChain")
SubscribeHelper = _optional_import("app.helper.subscribe", "SubscribeHelper")
SubscribeOper = _optional_import("app.db.subscribe_oper", "SubscribeOper")
SystemConfigOper = _optional_import("app.db.systemconfig_oper", "SystemConfigOper")
eventmanager = _optional_import("app.core.event", "eventmanager")
MetaInfo = _optional_import("app.core.metainfo", "MetaInfo")
PluginManager = _optional_import("app.core.plugin", "PluginManager")
Scheduler = _optional_import("app.scheduler", "Scheduler")
EventType = _optional_import("app.schemas.types", "EventType")
SystemConfigKey = _optional_import("app.schemas.types", "SystemConfigKey")
TorrentStatus = _optional_import("app.schemas.types", "TorrentStatus")
media_type_to_agent = _optional_import("app.schemas.types", "media_type_to_agent")
RequestUtils = _optional_import("app.utils.http", "RequestUtils")
StringUtils = _optional_import("app.utils.string", "StringUtils")
try:
from app.log import logger
except Exception:
class _FallbackLogger:
@staticmethod
def info(message: str) -> None:
@@ -1449,18 +1439,33 @@ class FeishuChannel:
return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。"
meta = MetaInfo(keyword)
try:
save_path = ""
if self.plugin is not None:
save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip()
subscribe_kwargs = {
"title": keyword,
"year": meta.year,
"mtype": meta.type,
"season": meta.begin_season,
"username": "agentresourceofficer-feishu",
"exist_ok": True,
"message": False,
}
if save_path:
subscribe_kwargs["save_path"] = save_path
sid, message = SubscribeChain().add(
title=keyword,
year=meta.year,
mtype=meta.type,
season=meta.begin_season,
username="agentresourceofficer-feishu",
exist_ok=True,
message=False,
**subscribe_kwargs,
)
if not sid:
return f"订阅失败:{keyword}\n原因:{message}"
if save_path and SubscribeOper is not None:
try:
SubscribeOper().update(sid, {"save_path": save_path})
except Exception as exc:
logger.warning(f"[AgentResourceOfficer][Feishu] 同步订阅保存路径失败sid={sid} {exc}")
lines = [f"已创建订阅:{keyword}", f"订阅ID{sid}", f"结果:{message}"]
if save_path:
lines.append(f"保存路径:{save_path}")
if immediate_search and Scheduler is not None:
Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True})
lines.append("已触发一次订阅搜索。")
@@ -1775,7 +1780,7 @@ class FeishuChannel:
"5. 转存 片名(默认 115\n"
"6. 夸克转存 片名\n"
"7. 下载 片名\n"
"8. 更新检查 片名\n"
"8. 订阅 片名\n"
"9. 选择 序号 / 详情 序号 / n\n"
"10. 115登录 / 115状态 / 115任务\n"
"11. 影巢签到 / 影巢签到日志"

View File

@@ -0,0 +1,2 @@
<div id="app"></div>
<script type="module" src="/src/main.js"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "moviepilot-agent-resource-officer-plugin",
"private": true,
"version": "0.3.0",
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13",
"vuetify": "3.7.3"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.4.11"
}
}

View File

@@ -1,4 +1,4 @@
requests
cloudscraper
lark-oapi>=1.4.0
p115client==0.0.8.4.8
p115client==0.0.8.6.4

View File

@@ -0,0 +1,396 @@
"""
影巢HDHive网页方式资源搜索/解锁服务。
通过 MoviePilot 官方 app.helper.browser.PlaywrightHelpercloakbrowser 后端,
内置反检测与 FlareSolverr用账号 cookie 在影巢网页上搜索资源、解锁拿 115 链接,
不依赖影巢 OpenAPI。仅在 MoviePilot docker 容器内 headless 运行。
本模块的页面抓取/解锁 JavaScript 与流程改编自 GPL v3 项目
DDSRem-Dev/MoviePilot-Plugins (plugins.v2/p115strmhelper/helper/hdhive/browser.py)。
原仓库: https://github.com/DDSRem-Dev/MoviePilot-Plugins
本仓库同为 GPL v3。
"""
from __future__ import annotations
import concurrent.futures
import re
import time
from typing import Any, Callable, Dict, List, Optional
from app.helper.browser import PlaywrightHelper
from app.log import logger
# 改编自 DDSRem-Dev p115strmhelper browser.py::_scrape_resource_cards_js (GPL v3)
_SCRAPE_CARDS_JS = r"""
() => {
const sizeRe = /(\d+\.?\d*)\s*(TB|GB|MB|G(?!B)|M(?!B))\b/i;
const dateRe = /发布于\s*([\d/\-]+)/;
const resRe = /\b(4K|8K|2K|1080[piP]?|720[piP]?|480[piP]?)\b/;
const pointsRe = /(\d+)\s*积分/;
const candidates = [];
for (const el of document.querySelectorAll('a,div,article,li,section')) {
const t = el.innerText || '';
if (!t.includes('发布于') || !sizeRe.test(t)) continue;
if ((t.match(/发布于/g) || []).length !== 1) continue;
if (t.length < 30 || t.length > 5000) continue;
candidates.push(el);
}
const minimal = candidates.filter(
el => !candidates.some(other => other !== el && el.contains(other))
);
const metaTerms = new Set([
'4K','8K','2K','免费','官组','管理员','WEB-DL','WEBRip','BDRip','REMUX','HDTV',
'简中','繁中','简英','繁英','内封','外挂','内嵌','简日','繁日','简韩','繁韩',
'1080P','1080p','720P','720p','480P','480p','蓝光原盘','ISO'
]);
return minimal.map(card => {
const text = card.innerText || '';
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const dateMatch = text.match(dateRe);
const sizeMatch = text.match(sizeRe);
const resMatch = text.match(resRe);
const pointsMatch = text.match(pointsRe);
const isFree = text.includes('免费');
const tags = [];
if (text.includes('官组') || text.includes('管理员')) tags.push('官组');
if (isFree) tags.push('免费');
if (pointsMatch) tags.push(pointsMatch[0].trim());
const dateLineIdx = lines.findIndex(l => /发布于/.test(l));
const user = dateLineIdx > 0 ? lines[dateLineIdx - 1] : (lines[0] || '');
const titleLines = lines.filter(l => {
if (l.length < 3) return false;
if (metaTerms.has(l)) return false;
if (/^发布于/.test(l)) return false;
if (/^\d+\s*积分$/.test(l)) return false;
if (/^\d+\.?\d*\s*(T?B|G[Bi]?|M[Bi]?)$/i.test(l)) return false;
if (l === user) return false;
return true;
});
let title = titleLines
.map(l => l.replace(/^\d+\s*积分\s*/, '').trim())
.filter(Boolean).join(' ').trim();
let hrefEl = card;
while (hrefEl && hrefEl.tagName !== 'A') { hrefEl = hrefEl.parentElement; }
const href = hrefEl ? (hrefEl.getAttribute('href') || '') : '';
return {
user, posted_at: dateMatch ? dateMatch[1] : '', tags, title,
resolution: resMatch ? resMatch[1] : '',
size: sizeMatch ? (sizeMatch[1] + ' ' + sizeMatch[2].toUpperCase()) : '',
is_free: isFree,
unlock_points: isFree ? 0 : (pointsMatch ? parseInt(pointsMatch[1]) : null),
href,
};
});
}
"""
# 改编自 DDSRem-Dev p115strmhelper browser.py::unlock_resource 内 _EXTRACT_URL_JS (GPL v3)
_EXTRACT_115_URL_JS = r"""
() => {
const urlPrefixRe = /^https?:\/\/(115cdn|115)\.com\//;
for (const el of document.querySelectorAll('input')) {
const v = (el.value || '').trim();
if (urlPrefixRe.test(v)) return v;
}
for (const el of document.querySelectorAll('div, span, p, a, code')) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (urlPrefixRe.test(t)) return t;
}
const m = (document.body?.innerText || '').match(/https?:\/\/(115cdn|115)\.com\/\S+/);
return m ? m[0].replace(/\s+$/, '') : null;
}
"""
class HDHiveBrowserService:
def __init__(
self,
base_url: str = "https://hdhive.com",
cookie: str = "",
timeout: int = 30,
cookie_refresh_callback: Optional[Callable[[], str]] = None,
) -> None:
self.base_url = (base_url or "https://hdhive.com").rstrip("/")
self.cookie = (cookie or "").strip()
self.timeout = int(timeout or 30)
self.cookie_refresh_callback = cookie_refresh_callback
def is_ready(self) -> bool:
return bool(self.cookie)
@staticmethod
def _cookie_expired_result() -> Dict[str, str]:
return {"__hdhive_browser_error__": "cookie_expired"}
@staticmethod
def _is_cookie_expired_result(value: Any) -> bool:
return isinstance(value, dict) and value.get("__hdhive_browser_error__") == "cookie_expired"
def _refresh_cookie(self) -> str:
if not self.cookie_refresh_callback:
return ""
try:
cookie = self.cookie_refresh_callback()
except Exception as exc:
logger.warning(f"[HDHiveBrowser] 自动刷新 Cookie 失败: {exc}")
return ""
cookie = str(cookie or "").strip()
if cookie:
self.cookie = cookie
return cookie
def _context_cookies(self) -> List[Dict[str, str]]:
items: List[Dict[str, str]] = []
for part in str(self.cookie or "").split(";"):
if "=" not in part:
continue
name, value = part.strip().split("=", 1)
name = name.strip()
value = value.strip()
if name and value:
items.append({"name": name, "value": value, "url": f"{self.base_url}/"})
return items
def _detail_url(self, media_type: Any, tmdb_id: Any) -> str:
mt = "movie" if str(media_type).lower() in ("movie", "电影") else "tv"
return f"{self.base_url}/tmdb/{mt}/{tmdb_id}"
@staticmethod
def _normalize(card: Dict[str, Any]) -> Dict[str, Any]:
href = (card.get("href") or "").strip()
slug = href.rstrip("/").split("/")[-1] if href else ""
return {
"slug": slug,
"href": href,
"title": card.get("title", ""),
"resolution": card.get("resolution", ""),
"size": card.get("size", ""),
"is_free": bool(card.get("is_free")),
"unlock_points": card.get("unlock_points"),
"user": card.get("user", ""),
"posted_at": card.get("posted_at", ""),
"tags": card.get("tags", []),
}
def _run_browser_action(self, url: str, callback: Any) -> Any:
"""Run MoviePilot's sync Playwright helper outside the active async request loop."""
helper_timeout = max(60, self.timeout)
def _callback_with_context_cookies(page: Any) -> Any:
context_cookies = self._context_cookies()
if context_cookies:
page.context.add_cookies(context_cookies)
page.goto(url)
page.wait_for_load_state("networkidle", timeout=helper_timeout * 1000)
return callback(page)
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="hdhive-browser")
future = executor.submit(
lambda: PlaywrightHelper().action(
url,
callback=_callback_with_context_cookies,
timeout=helper_timeout,
)
)
try:
return future.result(timeout=helper_timeout + 30)
except concurrent.futures.TimeoutError as exc:
future.cancel()
raise RuntimeError(f"影巢网页操作超时({helper_timeout} 秒)") from exc
finally:
executor.shutdown(wait=False, cancel_futures=True)
def search(self, media_type: Any, tmdb_id: Any) -> List[Dict[str, Any]]:
"""打开影巢详情页抓资源卡片。失败返回 []。"""
url = self._detail_url(media_type, tmdb_id)
def _callback(page: Any) -> List[Dict[str, Any]]:
cards: List[Dict[str, Any]] = []
deadline = time.time() + 10
while time.time() < deadline:
try:
if "/login" in (page.url or ""):
return self._cookie_expired_result()
cards = page.evaluate(_SCRAPE_CARDS_JS) or []
except RuntimeError:
raise
except Exception:
cards = []
if cards:
break
page.wait_for_timeout(500)
return cards
try:
cards = self._run_browser_action(url, _callback)
except Exception as exc:
logger.warning(f"[HDHiveBrowser] 搜索失败({url}): {exc}")
return []
if self._is_cookie_expired_result(cards):
raise RuntimeError("cookie 失效,被重定向到登录页")
return [self._normalize(c) for c in (cards or []) if c.get("href")]
def unlock(self, slug: str) -> Dict[str, Any]:
"""解锁资源,返回 {'url','already_owned'}。失败抛 RuntimeError。"""
if not slug:
raise RuntimeError("缺少资源 slug")
url = f"{self.base_url}/resource/115/{slug}"
def _callback(page: Any) -> Dict[str, Any]:
captured: Dict[str, Optional[str]] = {"url": None}
def _on_response(response: Any) -> None:
try:
if response.status != 200:
return
if "json" not in response.headers.get("content-type", ""):
return
body = response.json()
if not isinstance(body, dict):
return
data = body.get("data") or {}
if not isinstance(data, dict):
return
for key in ("full_url", "url", "link", "resource_url"):
val = data.get(key)
if val and re.search(r"(115cdn|115)\.com", str(val)):
captured["url"] = str(val).strip()
break
except Exception:
pass
page.on("response", _on_response)
if "/login" in (page.url or ""):
return self._cookie_expired_result()
confirm = page.get_by_text("确定解锁", exact=True)
existing: Optional[str] = None
has_confirm = False
deadline = time.time() + 15
while time.time() < deadline:
try:
existing = page.evaluate(_EXTRACT_115_URL_JS)
except Exception:
existing = None
if existing:
break
try:
if confirm.first.is_visible():
has_confirm = True
break
except Exception:
pass
page.wait_for_timeout(500)
if existing:
return {"url": existing, "already_owned": True}
if not has_confirm:
raise RuntimeError(f"未找到「确定解锁」按钮或链接URL: {page.url}")
confirm.first.click()
deadline = time.time() + 20
while time.time() < deadline:
if captured["url"]:
return {"url": captured["url"], "already_owned": False}
if re.search(r"(115cdn|115)\.com", page.url or ""):
return {"url": page.url, "already_owned": False}
try:
extracted = page.evaluate(_EXTRACT_115_URL_JS)
except Exception:
extracted = None
if extracted:
return {"url": extracted, "already_owned": False}
page.wait_for_timeout(500)
raise RuntimeError(f"解锁后未获取 115 链接URL: {page.url}")
result = self._run_browser_action(url, _callback)
if self._is_cookie_expired_result(result):
raise RuntimeError("cookie 失效,被重定向到登录页")
return result
# ----- 与 HDHiveOpenApiService 对齐的兼容接口(返回 (ok, result, message) 三元组) -----
@staticmethod
def _norm_media_type(media_type: Any) -> str:
mt = str(media_type or "").strip().lower()
if mt in ("movie", "电影"):
return "movie"
if mt in ("tv", "电视剧"):
return "tv"
return mt
def search_resources(self, media_type: Any, tmdb_id: Any) -> tuple:
"""与 HDHiveOpenApiService.search_resources 同签名/同返回结构(网页方式)。"""
mt = self._norm_media_type(media_type)
tid = str(tmdb_id or "").strip()
query = {"media_type": mt, "tmdb_id": tid}
if mt not in ("movie", "tv"):
return False, {"ok": False, "message": "媒体类型必须是 movie 或 tv", "query": query, "data": []}, "媒体类型必须是 movie 或 tv"
if not tid:
return False, {"ok": False, "message": "TMDB ID 不能为空", "query": query, "data": []}, "TMDB ID 不能为空"
if not self.is_ready() and not self._refresh_cookie():
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "query": query, "data": []}, "影巢网页 Cookie 未配置"
try:
items = self.search(mt, tid)
except Exception as exc:
message = str(exc)
if "cookie 失效" in message and self._refresh_cookie():
try:
items = self.search(mt, tid)
except Exception as retry_exc:
message = str(retry_exc)
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
else:
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
data = [
{
"slug": it.get("slug", ""),
"title": it.get("title", ""),
"name": it.get("title", ""),
"unlock_points": it.get("unlock_points"),
"size": it.get("size", ""),
"resolution": it.get("resolution", ""),
"is_free": it.get("is_free", False),
"user": it.get("user", ""),
"posted_at": it.get("posted_at", ""),
"tags": it.get("tags", []),
"source": "hdhive_browser",
}
for it in items
]
msg = "success" if data else "影巢网页方式未找到资源"
result = {
"ok": bool(data),
"message": msg,
"query": query,
"data": data,
"meta": {"total": len(data)},
"source": "hdhive_browser",
}
return bool(data), result, msg
def unlock_resource(self, slug: str) -> tuple:
"""与 HDHiveOpenApiService.unlock_resource 同签名/同返回结构(网页方式)。"""
slug = (slug or "").strip()
if not slug:
return False, {"ok": False, "message": "slug 不能为空", "slug": "", "data": {}}, "slug 不能为空"
if not self.is_ready() and not self._refresh_cookie():
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "slug": slug, "data": {}}, "影巢网页 Cookie 未配置"
try:
res = self.unlock(slug)
except Exception as exc:
message = str(exc)
if "cookie 失效" in message and self._refresh_cookie():
try:
res = self.unlock(slug)
except Exception as retry_exc:
message = str(retry_exc)
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
else:
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
link = (res.get("url") or "").strip()
data = {"full_url": link, "url": link, "pan_type": "115"}
msg = "success" if link else "影巢网页方式解锁失败"
return bool(link), {"ok": bool(link), "message": msg, "slug": slug, "data": data, "source": "hdhive_browser"}, msg

View File

@@ -8,6 +8,11 @@ from zoneinfo import ZoneInfo
import requests
try:
from app.helper.browser import PlaywrightHelper
except Exception:
PlaywrightHelper = None
try:
from app.chain.media import MediaChain
except Exception:
@@ -32,17 +37,35 @@ class HDHiveOpenApiService:
_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"
# Meta endpoints that only require app-level X-API-Key auth.
_META_ENDPOINT_PREFIXES: Tuple[str, ...] = (
"/api/open/ping",
"/api/open/quota",
"/api/open/usage",
"/api/open/usage/today",
)
# Refresh endpoint path per HDHive documented OpenAPI contract.
_REFRESH_ENDPOINT = "/api/public/openapi/oauth/refresh"
def __init__(
self,
*,
api_key: str = "",
base_url: str = "https://hdhive.com",
timeout: int = 30,
openapi_user_token: str = "",
openapi_refresh_token: str = "",
) -> 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.openapi_user_token = self.normalize_text(openapi_user_token)
self.openapi_refresh_token = self.normalize_text(openapi_refresh_token)
self._login_action_id = ""
self._in_refresh_retry = False
def _is_meta_endpoint(self, path: str) -> bool:
return any(path.startswith(prefix) for prefix in self._META_ENDPOINT_PREFIXES)
@staticmethod
def safe_int(value: Any, default: int) -> int:
@@ -187,15 +210,29 @@ class HDHiveOpenApiService:
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout: Optional[int] = None,
require_user_auth: Optional[bool] = None,
) -> Tuple[bool, Dict[str, Any], str, int]:
if not self.api_key:
return False, {}, "未配置影巢 API Key", 400
# Auto-detect: meta endpoints don't need user auth; business endpoints do.
needs_user_auth = require_user_auth if require_user_auth is not None else not self._is_meta_endpoint(path)
if needs_user_auth and not self.openapi_user_token:
return False, {}, (
"当前影巢 OpenAPI 业务接口需要用户授权令牌Bearer Token"
"但未配置 hdhive_openapi_user_token。请先在插件配置中填入 OpenAPI 用户 Access Token"
"或通过 OAuth 流程获取后填入。"
), 401
headers = self.base_headers()
if needs_user_auth and self.openapi_user_token:
headers["Authorization"] = f"Bearer {self.openapi_user_token}"
try:
response = requests.request(
method=method.upper(),
url=self.api_url(path),
headers=self.base_headers(),
headers=headers,
params=params,
json=payload if payload is not None else None,
timeout=timeout or self.timeout,
@@ -216,6 +253,25 @@ class HDHiveOpenApiService:
if response.ok and isinstance(result, dict) and result.get("success", True):
return True, result, "", response.status_code
# If a business request fails with 401/403 and we have a refresh token, try once.
if (
needs_user_auth
and response.status_code in (401, 403)
and self.openapi_refresh_token
and not self._in_refresh_retry
):
refresh_ok = self._try_refresh_user_token()
if refresh_ok:
self._in_refresh_retry = True
try:
return self.request(
method, path,
params=params, payload=payload, timeout=timeout,
require_user_auth=True,
)
finally:
self._in_refresh_retry = False
message = ""
if isinstance(result, dict):
message = (
@@ -228,6 +284,40 @@ class HDHiveOpenApiService:
message = f"HTTP {response.status_code}"
return False, result if isinstance(result, dict) else {}, message, response.status_code
def _try_refresh_user_token(self) -> bool:
if not self.openapi_refresh_token:
return False
try:
response = requests.post(
url=self.api_url(self._REFRESH_ENDPOINT),
headers=self.base_headers(),
json={"refresh_token": self.openapi_refresh_token},
timeout=self.timeout,
proxies=getattr(settings, "PROXY", None) if settings is not None else None,
)
if response.status_code != 200:
return False
data = response.json()
if not isinstance(data, dict) or not data.get("success", True):
return False
meta = data.get("data") if isinstance(data.get("data"), dict) else {}
new_access = self.normalize_text(meta.get("access_token") or meta.get("token"))
new_refresh = self.normalize_text(meta.get("refresh_token")) or self.openapi_refresh_token
if not new_access:
return False
self.openapi_user_token = new_access
self.openapi_refresh_token = new_refresh
return True
except Exception:
return False
def auth_status(self) -> Dict[str, Any]:
return {
"api_key_configured": bool(self.api_key),
"user_token_configured": bool(self.openapi_user_token),
"refresh_token_configured": bool(self.openapi_refresh_token),
}
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")
@@ -533,13 +623,21 @@ class HDHiveOpenApiService:
@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()
normalized = {str(key or "").strip(): str(value or "").strip() for key, value in (cookies or {}).items()}
token_cookie = normalized.get("token", "")
if not token_cookie:
return ""
cookie_items = [f"token={token_cookie}"]
if csrf_cookie:
cookie_items.append(f"csrf_access_token={csrf_cookie}")
preferred_order = ["hdh_sa_token", "token", "refresh_token", "csrf_access_token", "hdh_uid"]
cookie_items: List[str] = []
seen: set[str] = set()
for name in preferred_order:
value = normalized.get(name, "")
if value:
cookie_items.append(f"{name}={value}")
seen.add(name)
for name, value in normalized.items():
if name and value and name not in seen:
cookie_items.append(f"{name}={value}")
return "; ".join(cookie_items)
@classmethod
@@ -719,76 +817,92 @@ class HDHiveOpenApiService:
else:
server_action_message = "未解析到登录 Action"
try:
from playwright.sync_api import sync_playwright
except Exception:
return False, "", server_action_message or "自动登录失败,且 Playwright 不可用"
if PlaywrightHelper is None:
return False, "", server_action_message or "自动登录失败,且 MoviePilot PlaywrightHelper 不可用"
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
def _login_with_page(page: Any) -> List[Dict[str, Any]]:
for selector in [
"input[name='username']",
"input[name='email']",
"input[type='email']",
"input[placeholder*='邮箱']",
"input[placeholder*='email']",
"input[placeholder*='用户名']",
]:
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")
if page.query_selector(selector):
page.fill(selector, username)
break
except Exception:
page.keyboard.press("Enter")
continue
for selector in [
"input[name='password']",
"input[type='password']",
"input[placeholder*='密码']",
]:
try:
page.wait_for_load_state("networkidle", timeout=10000)
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.click("button")
except Exception:
try:
page.click("button")
except Exception:
pass
cookies = context.cookies()
context.close()
browser.close()
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
deadline = self.tz_now().timestamp() + 15
while self.tz_now().timestamp() < deadline:
try:
cookies = page.context.cookies()
if any(str(item.get("name") or "") == "token" and item.get("value") for item in cookies):
return cookies
except Exception:
pass
try:
if "/login" not in (page.url or ""):
page.wait_for_timeout(1000)
except Exception:
pass
try:
page.wait_for_timeout(500)
except Exception:
break
try:
return page.context.cookies()
except Exception:
return []
try:
proxy_config = getattr(settings, "PROXY", None) if settings is not None else None
cookies = PlaywrightHelper().action(
login_url,
callback=_login_with_page,
proxies=proxy_config,
headless=True,
timeout=max(30, self.timeout),
) or []
except Exception as exc:
return False, "", f"Playwright 自动登录失败: {exc}"
return False, "", f"PlaywrightHelper 自动登录失败: {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 True, cookie_string, "PlaywrightHelper 登录成功"
return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie"
@classmethod

View File

@@ -0,0 +1,735 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CLIENT_TYPES, cloneConfig, maskSecret, unwrapResponse } from '../provider'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'AgentResourceOfficer',
},
initialConfig: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['save', 'close'])
const config = ref({})
const message = reactive({ text: '', type: 'info' })
const showCookie = ref(false)
const showFeishuSecret = ref(false)
const showHdhiveApiKey = ref(false)
const showHdhiveAccessToken = ref(false)
const showHdhiveRefreshToken = ref(false)
const showHdhiveCookie = ref(false)
const showHdhivePassword = ref(false)
const saving = ref(false)
const healthLoading = ref(false)
const health = ref(null)
const qr = reactive({
show: false,
loading: false,
error: '',
qrcode: '',
uid: '',
time: '',
sign: '',
tips: '请使用 115 客户端扫描二维码登录',
status: '等待扫码',
clientType: 'alipaymini',
timer: null,
requestId: 0,
checking: false,
})
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentResourceOfficer'}`)
const p115ReadyText = computed(() => {
if (!health.value) return config.value.p115_cookie ? '已配置 Cookie' : '未检测'
if (health.value.p115_ready) return '115 可用'
return health.value.message || '115 未就绪'
})
function enableChip(value) {
return value
? { text: '已启用', color: 'success' }
: { text: '未启用', color: 'grey' }
}
function showMessage(text, type = 'info') {
message.text = text
message.type = type
if (text) {
setTimeout(() => {
if (message.text === text) message.text = ''
}, 3500)
}
}
async function persistConfig({ silent = false } = {}) {
saving.value = true
try {
const response = await withTimeout(
props.api.post(`${pluginBase.value}/config/save`, cloneConfig(config.value)),
12000,
'保存配置超时,请稍后重试'
)
const result = unwrapResponse(response)
if (!result?.success) {
throw new Error(result?.message || '保存配置失败')
}
if (result.data) {
config.value = cloneConfig(result.data)
}
emit('save', cloneConfig(config.value))
if (!silent) showMessage(result.message || '配置已保存', 'success')
return true
} catch (err) {
if (!silent) showMessage(err?.message || '保存配置失败', 'error')
return false
} finally {
saving.value = false
}
}
function saveConfig() {
persistConfig()
}
async function copyText(value, label) {
try {
await navigator.clipboard.writeText(String(value || ''))
showMessage(`${label} 已复制`, 'success')
} catch (err) {
showMessage('复制失败,请手动复制', 'error')
}
}
function clearQrTimer() {
if (qr.timer) {
clearInterval(qr.timer)
qr.timer = null
}
}
function applyQrData(data) {
qr.qrcode = data?.qrcode || ''
qr.uid = data?.uid || ''
qr.time = data?.time || ''
qr.sign = data?.sign || ''
qr.tips = data?.tips || '请使用 115 客户端扫描二维码登录'
qr.status = '等待扫码'
}
function withTimeout(promise, ms, message) {
let timeoutId
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId))
}
async function requestQrCode() {
const requestId = qr.requestId + 1
qr.requestId = requestId
qr.loading = true
qr.error = ''
qr.qrcode = ''
qr.uid = ''
qr.time = ''
qr.sign = ''
clearQrTimer()
try {
const response = await withTimeout(
props.api.get(`${pluginBase.value}/p115/ui/qrcode?client_type=${encodeURIComponent(qr.clientType)}`),
12000,
'获取二维码超时,请稍后重试'
)
if (requestId !== qr.requestId || !qr.show) return
const result = unwrapResponse(response)
if (!result?.success || !result?.data) {
throw new Error(result?.message || '获取二维码失败')
}
applyQrData(result.data)
qr.timer = setInterval(() => checkQrCode(requestId), 3000)
} catch (err) {
if (requestId !== qr.requestId) return
qr.error = err?.message || '获取二维码失败'
qr.status = '二维码获取失败'
} finally {
if (requestId === qr.requestId) {
qr.loading = false
}
}
}
async function checkQrCode(requestId = qr.requestId) {
if (!qr.show || !qr.uid || !qr.time || !qr.sign) return
if (requestId !== qr.requestId || qr.checking) return
qr.checking = true
try {
const query = new URLSearchParams({
uid: qr.uid,
time: qr.time,
sign: qr.sign,
client_type: qr.clientType,
})
const response = await withTimeout(
props.api.get(`${pluginBase.value}/p115/ui/qrcode/check?${query.toString()}`),
10000,
'检查二维码状态超时'
)
if (requestId !== qr.requestId || !qr.show) return
const result = unwrapResponse(response)
const data = result?.data || {}
if (!result?.success) {
if (data.status === 'expired') {
clearQrTimer()
qr.status = '二维码已失效'
qr.error = result?.message || '二维码已失效,请刷新'
}
return
}
if (data.status === 'waiting') qr.status = '等待扫码'
if (data.status === 'scanned') qr.status = '已扫码,请在设备上确认'
if (data.status === 'expired') {
clearQrTimer()
qr.status = '二维码已失效'
qr.error = '二维码已失效,请刷新'
}
if (data.status === 'success') {
clearQrTimer()
qr.status = '登录成功'
if (data.cookie_saved) {
config.value.p115_client_type = qr.clientType
if (data.cookie) config.value.p115_cookie = data.cookie
await persistConfig({ silent: true })
}
showMessage('115 登录成功Cookie 已自动保存。', 'success')
setTimeout(() => {
qr.show = false
}, 1800)
await loadP115Health()
}
} catch (err) {
console.error('检查 115 二维码状态失败:', err)
} finally {
if (requestId === qr.requestId) {
qr.checking = false
}
}
}
function openQrDialog() {
qr.show = true
qr.error = ''
qr.status = '等待扫码'
qr.clientType = config.value.p115_client_type || 'alipaymini'
requestQrCode()
}
function closeQrDialog() {
clearQrTimer()
qr.requestId += 1
qr.loading = false
qr.checking = false
qr.show = false
}
async function refreshQrCode() {
qr.error = ''
await requestQrCode()
}
async function changeQrClientType(value) {
if (!value || value === qr.clientType) return
qr.clientType = value
qr.error = ''
await requestQrCode()
}
async function loadP115Health() {
if (!props.api?.get) return
healthLoading.value = true
try {
const response = await props.api.get(`${pluginBase.value}/p115/ui/health`)
const result = unwrapResponse(response)
if (result?.success) {
health.value = result.data || null
}
} catch (err) {
health.value = { p115_ready: false, message: err?.message || '检测失败' }
} finally {
healthLoading.value = false
}
}
async function loadLatestConfig() {
if (!props.api?.get) return false
try {
const response = await withTimeout(
props.api.get(`${pluginBase.value}/config/get`),
12000,
'加载配置超时'
)
const result = unwrapResponse(response)
if (result?.success && result.data) {
config.value = cloneConfig(result.data)
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
return true
}
} catch (err) {
console.error('加载 Agent影视助手 配置失败:', err)
}
return false
}
onMounted(async () => {
config.value = cloneConfig(props.initialConfig)
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
await loadLatestConfig()
loadP115Health()
})
onBeforeUnmount(clearQrTimer)
</script>
<template>
<div class="aro-config">
<VToolbar density="comfortable" color="transparent" class="aro-toolbar">
<VIcon icon="mdi-robot-outline" color="primary" class="ms-3 me-2" />
<div class="text-h6">Agent影视助手配置</div>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" :loading="healthLoading" title="刷新 115 状态" @click="loadP115Health" />
<VBtn icon="mdi-content-save" variant="text" color="success" :loading="saving" title="保存配置" @click="saveConfig" />
<VBtn icon="mdi-close" variant="text" title="关闭" @click="emit('close')" />
</VToolbar>
<VDivider />
<div class="aro-body">
<div class="aro-inner">
<VAlert v-if="message.text" :type="message.type" variant="tonal" density="compact" closable class="mb-3">
{{ message.text }}
</VAlert>
<div class="aro-intro text-body-2 mb-3">
<VIcon icon="mdi-rocket-launch-outline" size="small" color="primary" class="me-1" />
<span>快速开始先启用插件并配置 MP/PT再按需开启影巢盘搜与飞书入口完整说明见</span>
<a href="https://github.com/liuyuexi1987/MoviePilot-Plugins" target="_blank" rel="noopener" class="text-primary text-decoration-none font-weight-medium">主页文档</a>
</div>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-toggle-switch" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">基础设置</VCardTitle>
<VCardSubtitle class="text-caption">启用插件通知与调试开关</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.enabled).color" size="small" variant="tonal">{{ enableChip(config.enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" md="4">
<VSwitch v-model="config.enabled" label="启用插件" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.notify" label="发送通知" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.debug" label="调试日志" color="warning" density="compact" hide-details />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-movie-search-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">MP/PT 策略</VCardTitle>
<VCardSubtitle class="text-caption">首选主线原生搜索/订阅/下载评分仅影响未保存偏好的新会话</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.mp_pt_enabled).color" size="small" variant="tonal">{{ enableChip(config.mp_pt_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.mp_pt_enabled" label="启用 MP/PT" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_pt_min_seeders" label="最低做种数" type="number" placeholder="3" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_confirm_score_threshold" label="建议确认分" type="number" placeholder="70" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.assistant_default_auto_ingest_score_threshold" label="自动入库分" type="number" placeholder="90" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.assistant_default_auto_ingest_enabled" label="高分自动入库" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="6" md="9">
<VTextField v-model="config.mp_download_save_path" label="PT 下载保存路径(可选)" placeholder="默认留空;需要时填 local:/downloads 等" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-cloud-lock-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">115 扫码登录</VCardTitle>
<VCardSubtitle class="text-caption">扫码写入 Cookie手填仅作兜底</VCardSubtitle>
<template #append>
<VChip :color="health?.p115_ready ? 'success' : 'warning'" size="small" variant="tonal">{{ p115ReadyText }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense align="center">
<VCol cols="12" sm="6" md="4">
<VTextField v-model="config.p115_default_path" label="115 默认目录" placeholder="/待整理" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="6" md="4">
<VSelect v-model="config.p115_client_type" :items="CLIENT_TYPES" item-title="title" item-value="value" label="智能体扫码默认客户端" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="4">
<VSwitch v-model="config.p115_prefer_direct" label="优先 115 直转" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12">
<VTextField
:model-value="maskSecret(config.p115_cookie, showCookie)"
label="115 Cookie"
variant="outlined"
density="compact"
hide-details="auto"
readonly
hint="点击右侧二维码图标扫码,成功后自动保存 Cookie。"
persistent-hint
>
<template #append-inner>
<VIcon :icon="showCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showCookie = !showCookie" />
<VIcon icon="mdi-content-copy" class="me-2" size="small" :disabled="!config.p115_cookie" @click="copyText(config.p115_cookie, '115 Cookie')" />
</template>
<template #append>
<VIcon icon="mdi-qrcode-scan" :color="config.p115_cookie ? 'success' : 'primary'" title="扫码获取或更新 115 Cookie" @click="openQrDialog" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-honeycomb-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">影巢资源</VCardTitle>
<VCardSubtitle class="text-caption">资源搜索 / 解锁 / 转存积分上限填 0 不限制</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.hdhive_resource_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_resource_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="6" md="3">
<VSwitch v-model="config.hdhive_resource_enabled" label="启用搜索/解锁" color="success" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="6" md="3">
<VSelect
v-model="config.hdhive_resource_mode"
:items="[
{ title: '网页方式', value: 'browser' },
{ title: 'OpenAPI', value: 'openapi' },
{ title: '自动(网页优先)', value: 'auto' },
]"
item-title="title"
item-value="value"
label="资源方式"
variant="outlined"
density="compact"
hide-details="auto"
/>
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_max_unlock_points" label="积分上限" type="number" placeholder="20" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_candidate_page_size" label="候选页大小" type="number" placeholder="10" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="6" sm="3" md="3">
<VTextField v-model="config.hdhive_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_base_url" label="影巢地址" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_default_path" label="影巢默认转存目录" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_api_key" :type="showHdhiveApiKey ? 'text' : 'password'" label="影巢 API Key" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveApiKey ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveApiKey = !showHdhiveApiKey" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_api_key" @click="copyText(config.hdhive_api_key, '影巢 API Key')" />
</template>
</VTextField>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.hdhive_openapi_user_token" :type="showHdhiveAccessToken ? 'text' : 'password'" label="OpenAPI Access Token" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveAccessToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveAccessToken = !showHdhiveAccessToken" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_user_token" @click="copyText(config.hdhive_openapi_user_token, '影巢 Access Token')" />
</template>
</VTextField>
</VCol>
<VCol cols="12">
<VTextField v-model="config.hdhive_openapi_refresh_token" :type="showHdhiveRefreshToken ? 'text' : 'password'" label="OpenAPI Refresh Token可选" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhiveRefreshToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveRefreshToken = !showHdhiveRefreshToken" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_refresh_token" @click="copyText(config.hdhive_openapi_refresh_token, '影巢 Refresh Token')" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-calendar-check-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">影巢签到</VCardTitle>
<VCardSubtitle class="text-caption">OpenAPI 优先网页 Cookie 兜底 Cron 自动签到</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.hdhive_checkin_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_checkin_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_enabled" label="启用签到" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_gambler_mode" label="默认赌狗签到" color="warning" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_once" label="保存后立即运行" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="6" md="3">
<VSwitch v-model="config.hdhive_checkin_auto_login" label="自动刷新 Cookie" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_cron" label="签到 Cron" placeholder="0 8 * * *" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_username" label="影巢用户名/邮箱" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" sm="4" md="4">
<VTextField v-model="config.hdhive_checkin_password" :type="showHdhivePassword ? 'text' : 'password'" label="影巢密码" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showHdhivePassword ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showHdhivePassword = !showHdhivePassword" />
</template>
</VTextField>
</VCol>
<VCol cols="12">
<VTextField
v-model="config.hdhive_checkin_cookie"
:type="showHdhiveCookie ? 'text' : 'password'"
label="影巢网页 Cookie非 Premium 兜底)"
variant="outlined"
density="compact"
hide-details="auto"
>
<template #append-inner>
<VIcon :icon="showHdhiveCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveCookie = !showHdhiveCookie" />
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_checkin_cookie" @click="copyText(config.hdhive_checkin_cookie, '影巢 Cookie')" />
</template>
</VTextField>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-magnify-scan" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">盘搜</VCardTitle>
<VCardSubtitle class="text-caption">聚合公开网盘分享地址需容器视角可访问</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.pansou_enabled).color" size="small" variant="tonal">{{ enableChip(config.pansou_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="3" md="3">
<VSwitch v-model="config.pansou_enabled" label="启用盘搜" color="success" density="compact" hide-details />
</VCol>
<VCol cols="8" sm="6" md="6">
<VTextField v-model="config.pansou_base_url" label="盘搜 API 地址" placeholder="http://host.docker.internal:805" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="4" sm="3" md="3">
<VTextField v-model="config.pansou_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
<VCardItem class="aro-card-head">
<template #prepend>
<VIcon icon="mdi-message-badge-outline" color="primary" />
</template>
<VCardTitle class="text-subtitle-1">飞书入口</VCardTitle>
<VCardSubtitle class="text-caption">内置飞书机器人入口与会话白名单</VCardSubtitle>
<template #append>
<VChip :color="enableChip(config.feishu_enabled).color" size="small" variant="tonal">{{ enableChip(config.feishu_enabled).text }}</VChip>
</template>
</VCardItem>
<VCardText class="pt-2">
<VRow dense>
<VCol cols="12" sm="4" md="4">
<VSwitch v-model="config.feishu_enabled" label="启用飞书入口" color="success" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="4" md="4">
<VSwitch v-model="config.feishu_allow_all" label="允许所有会话" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="6" sm="4" md="4">
<VSwitch v-model="config.feishu_reply_enabled" label="发送飞书回复" color="primary" density="compact" hide-details />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.feishu_app_id" label="飞书 App ID" placeholder="cli_xxxxxxxxx" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol cols="12" md="6">
<VTextField :type="showFeishuSecret ? 'text' : 'password'" v-model="config.feishu_app_secret" label="飞书 App Secret" variant="outlined" density="compact" hide-details="auto">
<template #append-inner>
<VIcon :icon="showFeishuSecret ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showFeishuSecret = !showFeishuSecret" />
</template>
</VTextField>
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" class="py-0">
<div class="text-caption text-medium-emphasis">未允许所有会话时仅下列白名单中的群聊或用户可触发飞书命令</div>
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
<VTextarea v-model="config.feishu_allowed_chat_ids" label="允许的群聊 Chat ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
</VCol>
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
<VTextarea v-model="config.feishu_allowed_user_ids" label="允许的用户 Open ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
</VCol>
</VRow>
</VCardText>
</VCard>
</div>
</div>
<VDialog v-model="qr.show" max-width="450" @update:model-value="value => !value && closeQrDialog()">
<VCard>
<VCardTitle class="text-subtitle-1 d-flex align-center px-3 py-2 bg-primary-lighten-5">
<VIcon icon="mdi-qrcode" color="primary" size="small" class="me-2" />
115网盘扫码登录
</VCardTitle>
<VCardText class="text-center py-4">
<VAlert v-if="qr.error" type="error" density="compact" variant="tonal" closable class="mb-3 mx-3">
{{ qr.error }}
</VAlert>
<div v-if="qr.loading" class="d-flex flex-column align-center py-3">
<VProgressCircular indeterminate color="primary" class="mb-3" />
<div>正在获取二维码...</div>
</div>
<div v-else-if="qr.qrcode" class="d-flex flex-column align-center">
<div class="mb-2 font-weight-medium">请选择扫码方式</div>
<VChipGroup :model-value="qr.clientType" class="mb-3" mandatory selected-class="primary" @update:model-value="changeQrClientType">
<VChip v-for="item in CLIENT_TYPES" :key="item.value" :value="item.value" variant="outlined" color="primary" size="small">
{{ item.label }}
</VChip>
</VChipGroup>
<div class="d-flex flex-column align-center mb-3">
<VCard flat class="border pa-2 mb-2">
<img :src="qr.qrcode" width="220" height="220" alt="115 登录二维码" />
</VCard>
<div class="text-body-2 text-grey mb-1">{{ qr.tips }}</div>
<div class="text-subtitle-2 font-weight-medium text-primary">{{ qr.status }}</div>
</div>
<VBtn color="primary" variant="tonal" size="small" class="mb-2" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">
刷新二维码
</VBtn>
</div>
<div v-else class="d-flex flex-column align-center py-3">
<VIcon icon="mdi-qrcode-off" size="64" color="grey" class="mb-3" />
<div class="text-subtitle-1">二维码获取失败</div>
<div class="text-body-2 text-grey">请点击刷新按钮重试</div>
</div>
</VCardText>
<VDivider />
<VCardActions class="px-3 py-2">
<VBtn color="grey" variant="text" size="small" prepend-icon="mdi-close" @click="closeQrDialog">关闭</VBtn>
<VSpacer />
<VBtn color="primary" variant="text" size="small" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">刷新二维码</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
<style scoped>
.aro-config {
display: flex;
flex-direction: column;
max-height: 82vh;
}
.aro-toolbar {
flex: 0 0 auto;
}
.aro-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 16px;
}
.aro-inner {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.aro-intro {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.06);
color: rgb(var(--v-theme-on-surface));
line-height: 1.5;
}
.aro-card-head {
padding-bottom: 0;
}
.aro-card :deep(.v-card-item__append) {
align-self: center;
}
.aro-card :deep(.v-card-subtitle) {
opacity: 0.7;
white-space: normal;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup>
import Config from './Config.vue'
defineProps({
api: {
type: Object,
default: () => ({}),
},
initialConfig: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['save', 'close'])
</script>
<template>
<Config :api="api" :initial-config="initialConfig" @save="payload => emit('save', payload)" @close="emit('close')" />
</template>

Some files were not shown because too many files have changed in this diff Show More