Compare commits

...

89 Commits

Author SHA1 Message Date
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
wumode
43e080839b feat(clashruleprovider): 移除全局 TLS 指纹配置并更新版本号至 2.1.7 (#1040) 2026-06-05 13:15:57 +08:00
InfinityPacer
9105a95bb9 test: organize plugin tests by plugin id (#1039) 2026-06-04 20:26:08 +08:00
RamenRa
6203fa69c2 fix:目前存在的一些问题 (#1037) 2026-06-04 11:36:04 +08:00
DDSRem
34895bc520 feat: add issue templates for bug reports, feature requests (#1036)
* feat: add issue templates for bug reports, feature requests and RFC

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: add issue management workflow for auto-labeling and stale cleanup

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: adapt issue templates and workflow for plugin repo context

- Remove RFC template (not needed for plugin repo)

- Remove 问题类型/功能改进类型 dropdowns

- Update workflow label rules to match simplified templates

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-04 09:02:48 +08:00
jxxghp
3e6ad20d1b feat: update OIDC plugin author information and icon 2026-06-04 08:21:13 +08:00
jxxghp
96ab3c73a7 feat: initialize OIDC authentication plugin with Vue components and configuration 2026-06-04 08:15:47 +08:00
InfinityPacer
b564a71203 test: align plugin shared harness shim (#1034) 2026-06-03 19:27:17 +08:00
InfinityPacer
75925415a3 test: add pytest scaffold and agenttokens plugin test (#1033) 2026-06-03 10:51:53 +08:00
jxxghp
60451c9b7f feat: add ChatGPT token usage stats 2026-05-31 07:57:52 +08:00
jxxghp
c1270840e4 chore: add .gitignore for node dependencies and lock file 2026-05-31 07:52:47 +08:00
jxxghp
c2dbaf5c5e chore(ChatGPT): v3.0.4 2026-05-30 22:07:14 +08:00
jxxghp
45829c05e5 fix(ChatGPT): 重构缓存管理UI
- get_form 仅显示缓存数量和提示,移除无效的 onClick 按钮
- get_page 实现缓存管理详情页,使用正确的 events.click 模式
- get_cache_stats 改为从数据库读取缓存数量
- 新增 _get_cache_count 方法统一获取缓存数量
2026-05-30 21:59:10 +08:00
liuyuexi1987
c5eac77128 feat: add one-time clear switch for failed samples in settings page (v0.1.13) (#1032) 2026-05-30 21:49:56 +08:00
jxxghp
1b948cab0c fix(ChatGPT): 修复缓存数量显示问题,将 _recognize_cache 改为实例变量 2026-05-30 21:48:13 +08:00
jxxghp
fa2be65bc6 fix(ChatGPT): 修复 API 注册格式 2026-05-30 21:41:57 +08:00
jxxghp
08a32a009d fix(ChatGPT): 添加缓存数量刷新按钮 2026-05-30 21:37:58 +08:00
jxxghp
aa502fc5fc fix(ChatGPT): 删除重复的缓存按钮 2026-05-30 21:35:32 +08:00
jxxghp
7722ce3406 fix(ChatGPT): 修复语法错误 2026-05-30 21:33:29 +08:00
jxxghp
3bd0964209 fix(ChatGPT): 调整缓存区布局
- 缓存区移到识别提示词上方
- 缓存数量在左,清除按钮在右
- 清除缓存后自动刷新页面显示最新数量
2026-05-30 21:27:32 +08:00
jxxghp
157a1053b1 feat(ChatGPT): v3.0.3 添加识别结果持久化缓存
- 添加识别结果持久化缓存,避免相同标题重复调用 LLM API
- 初始化时从数据库加载缓存到内存,新增缓存时同时写入内存和数据库
- 支持手动清除缓存并显示当前缓存数量
- 添加 /clear_cache 和 /cache_stats API
2026-05-30 21:17:51 +08:00
jxxghp
154f996dbe fix: open ChatGPT plugin config directly 2026-05-27 18:34:29 +08:00
jxxghp
5ed9ee9793 fix: hide unknown autosignin sites 2026-05-27 13:22:46 +08:00
jxxghp
bb3c392e62 fix: resolve autosignin site name display 2026-05-27 13:16:50 +08:00
jxxghp
0398af971b feat: support v2 ChatGPT LLM proxy config 2026-05-27 07:09:45 +08:00
jxxghp
a5237c6a5b feat: add Agent Tokens proxy toggle 2026-05-27 07:05:45 +08:00
jxxghp
b048106d2e feat: optimize v2 ChatGPT recognition plugin 2026-05-27 06:59:47 +08:00
jxxghp
ec0c8cc521 修复自动签到详情页图标显示 2026-05-26 18:01:29 +08:00
jxxghp
52cd5b96e1 增强自动签到状态圆点对比度 2026-05-26 15:30:05 +08:00
jxxghp
0d7be2b58c 修复自动签到透明主题样式 2026-05-26 15:21:01 +08:00
jxxghp
e29a710a33 优化站点自动签到详情页 2026-05-26 15:04:45 +08:00
jxxghp
de83b88ad1 feat: refine agent tokens management UI 2026-05-26 08:59:55 +08:00
jxxghp
96ac52041a feat: support agent tokens user agent 2026-05-26 08:20:03 +08:00
jxxghp
287ccf50b2 fix: 禁用VWindow触摸滑动,修复表格内滑动触发tab切换 2026-05-25 15:48:36 +08:00
jxxghp
08faed6ff0 fix: 禁用VWindow触摸滑动,修复表格内滑动触发tab切换问题,升版本1.0.7 2026-05-25 15:44:09 +08:00
jxxghp
944867f96e fix: 设置base为相对路径 2026-05-25 15:36:15 +08:00
jxxghp
5f7c342b78 fix: 移除vuetify-filter,修复页面显示问题 2026-05-25 15:35:30 +08:00
jxxghp
7aa0f188c7 style(agenttokens): add title left margin in dialog 2026-05-25 10:40:22 +08:00
jxxghp
3a7afe5047 style(agenttokens): fix dialog title truncation and align full-page title with site management style, bump version to 1.0.6 2026-05-25 10:36:19 +08:00
jxxghp
9c87f5e51b style(agenttokens): optimize UI layout, make dialog headers sticky, and reposition config toggles, bump version to 1.0.5 2026-05-25 10:11:31 +08:00
jxxghp
35b010c03d feat(agenttokens): add detailed logs for LLM assignment and token usage updates, bump version to 1.0.4 2026-05-25 09:52:04 +08:00
jxxghp
74926ca150 fix(agenttokens): restore missing page variables, remove global vuetify style leak, and unify header buttons style, bump version to 1.0.3 2026-05-25 09:45:46 +08:00
jxxghp
51c18cbb19 fix(agenttokens): resolve double title issue and layout scroll bug in dialog view, bump version to 1.0.2 2026-05-25 09:37:43 +08:00
jxxghp
02b3d61c04 更新 package.v2.json 2026-05-25 09:31:40 +08:00
jxxghp
13c12392d0 fix(agenttokens): resolve API 404 and add missing close button, bump version to 1.0.1 2026-05-25 09:30:34 +08:00
jxxghp
9332e17e6c 更新 __init__.py 2026-05-25 09:13:27 +08:00
jxxghp
8e5bfd58c0 更新 package.v2.json 2026-05-25 09:05:59 +08:00
jxxghp
a52e8ad0ed feat: add agent tokens management plugin 2026-05-25 07:32:36 +08:00
jxxghp
21ebda74b1 fix: filter watchfiles temporary files 2026-05-25 06:14:02 +08:00
jxxghp
50b4d2558c fix: prevent status card icon overflow 2026-05-24 20:32:23 +08:00
jxxghp
94e14d86d7 feat: enhance MoviePilot server status dashboard 2026-05-24 20:16:41 +08:00
jxxghp
a6f5d3a75b fix: dedupe person metadata scraping 2026-05-24 19:58:54 +08:00
jxxghp
ab8e7c99b7 fix: bound seeding plugin caches 2026-05-24 18:03:42 +08:00
jxxghp
41663d5a27 fix: update imdbsource zhconv dependency 2026-05-23 19:51:16 +08:00
jxxghp
d475578bcd chore: update agent resource officer jieba dependency 2026-05-23 13:05:45 +08:00
jxxghp
c6a6877ff7 chore: restore agent resource officer jieba usage 2026-05-23 12:59:48 +08:00
jxxghp
eb13d0ec62 chore: constrain clash rule provider sse dependency 2026-05-23 12:38:35 +08:00
jxxghp
59486dbf01 chore: rely on core fast bencode dependency 2026-05-23 12:18:51 +08:00
jxxghp
c6d91a74f2 chore: clarify fast bencode usage 2026-05-23 12:08:34 +08:00
jxxghp
9b476c61d3 chore: update v2 plugin text dependencies 2026-05-23 11:52:00 +08:00
jxxghp
b74a36bbe2 refactor: migrate monitor plugins to watchfiles 2026-05-22 09:14:42 +08:00
jxxghp
2fd3e1e37e docs: clarify plugin dependency constraints 2026-05-21 21:37:18 +08:00
jxxghp
a6030ad068 docs: add plugin sidebar nav FAQ 2026-05-21 21:31:15 +08:00
liuyuexi1987
6327c89a78 fix: relax lark-oapi constraint to avoid MoviePilot environment conflicts (#1028) 2026-05-20 20:10:54 +08:00
jxxghp
a2be00a423 feat: add plugin system version constraints 2026-05-20 19:56:21 +08:00
jxxghp
230cbc2094 更新 package.v2.json 2026-05-20 10:14:23 +08:00
jxxghp
5e364d9535 更新 __init__.py 2026-05-20 10:13:35 +08:00
jxxghp
6692937c44 更新 requirements.txt 2026-05-20 10:13:11 +08:00
jxxghp
1df37a5149 fix(bugreporter): 更换 Sentry 上报端点
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:56:05 +08:00
jxxghp
718323f781 fix(bugreporter): 仅上报包含异常堆栈的错误,普通日志不再上报
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:33:16 +08:00
jxxghp
0705372054 fix dynamicwechat language header 2026-05-18 17:22:50 +08:00
Jc Fang
9357638adc fix(wechatclawbot): deduplicate incoming messages to avoid duplicate pushes (#1025) 2026-05-18 17:10:50 +08:00
121 changed files with 18489 additions and 2666 deletions

67
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: 问题反馈
description: File a bug report
title: "[错误报告]: 请在此处简单描述你的问题"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
请确认以下信息:
1. 请按此模板提交 issues不按模板提交的问题将直接关闭。
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。
4. 此仓库为**官方插件仓库**,如果是**主程序问题**请在 [MoviePilot 主仓库](https://github.com/jxxghp/MoviePilot/issues) 提 issue如果是**前端 WebUI 问题**请在 [前端仓库](https://github.com/jxxghp/MoviePilot-Frontend) 提 issue。
5. **$\color{red}{不要通过 issues 来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。
- type: checkboxes
id: ensure
attributes:
label: 确认
description: 在提交 issue 之前,请确认你已经阅读并确认以下内容
options:
- label: 我的版本是最新版本,我的版本号与 [version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。
required: true
- label: 我已经 [issue](https://github.com/jxxghp/MoviePilot-Plugins/issues) 中搜索过,确认我的问题没有被提出过。
required: true
- label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过,确认我的问题没有被提出过。
required: true
- label: 我已经修改标题,将标题中的 描述 替换为我遇到的问题。
required: true
- type: input
id: version
attributes:
label: 当前程序版本
description: 遇到问题时 MoviePilot 主程序所在的版本号
validations:
required: true
- type: input
id: plugin
attributes:
label: 涉及插件名称及版本
description: 如果是插件问题,请填写插件名称及版本号(如不清楚可留空)
placeholder: "例如ChatGPT v3.0.4"
validations:
required: false
- type: dropdown
id: environment
attributes:
label: 运行环境
description: 当前程序运行环境
options:
- Docker
- Windows
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: 问题描述
description: 请详细描述你碰到的问题
placeholder: "问题描述"
validations:
required: true
- type: textarea
id: logs
attributes:
label: 发生问题时系统日志和配置文件
description: 问题出现时,程序运行日志请复制到这里。
render: bash

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 项目讨论
url: https://github.com/jxxghp/MoviePilot/discussions/new/choose
about: discussion
- name: Telegram 频道
url: https://t.me/moviepilot_channel
about: 更新日志
- name: Telegram 交流群
url: https://t.me/moviepilot_official
about: 交流互助

View File

@@ -0,0 +1,40 @@
name: 功能改进
description: Feature Request
title: "[Feature Request]: "
labels: ["feature request"]
body:
- type: markdown
attributes:
value: |
请说明你希望添加的功能。
- type: input
id: version
attributes:
label: 当前程序版本
description: 目前使用的 MoviePilot 程序版本
validations:
required: true
- type: dropdown
id: environment
attributes:
label: 运行环境
description: 当前程序运行环境
options:
- Docker
- Windows
validations:
required: true
- type: textarea
id: feature-request
attributes:
label: 功能改进
description: 请详细描述需要改进或者添加的功能。
placeholder: "功能改进"
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料
description: 可以列举一些参考资料,但是不要引用同类但商业化软件的任何内容。
placeholder: "参考资料"

144
.github/workflows/issues.yml vendored Normal file
View File

@@ -0,0 +1,144 @@
name: Close inactive issues
on:
workflow_dispatch:
issues:
types: [opened, edited]
schedule:
- cron: "0 18 * * *"
jobs:
label-opened-issue:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title || '';
const body = issue.body || '';
const currentLabels = (issue.labels || []).map((label) => label.name);
// 网页 Issue Form 已经会自动带模板 labels这里只兜底处理
// API 创建或异常路径产生的无 label issue避免重复补标。
if (currentLabels.length > 0) {
core.info(`Issue #${issue.number} already has labels: ${currentLabels.join(', ')}`);
return;
}
const hasAllMarkers = (markers) => markers.every((marker) => body.includes(marker));
const labelRules = [
{
label: 'bug',
titlePrefix: '[错误报告]:',
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
},
{
label: 'feature request',
titlePrefix: '[Feature Request]:',
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
},
];
const matched = labelRules.find((rule) => (
title.startsWith(rule.titlePrefix) || hasAllMarkers(rule.markers)
));
if (!matched) {
core.info(`Issue #${issue.number} does not match known issue templates.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [matched.label],
});
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
label-unlabeled-issues:
if: github.event_name != 'issues'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const labelRules = [
{
label: 'bug',
titlePrefix: '[错误报告]:',
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
},
{
label: 'feature request',
titlePrefix: '[Feature Request]:',
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
},
];
const hasAllMarkers = (body, markers) => markers.every((marker) => body.includes(marker));
const getMatchedRule = (issue) => {
const title = issue.title || '';
const body = issue.body || '';
return labelRules.find((rule) => (
title.startsWith(rule.titlePrefix) || hasAllMarkers(body, rule.markers)
));
};
// Search API 支持 no:label 查询issues.listForRepo 的 labels=none
// 会被当作名为 none 的标签,不能用于扫描无 label issue。
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open no:label`;
for await (const response of github.paginate.iterator(github.rest.search.issuesAndPullRequests, {
q: query,
per_page: 100,
})) {
for (const issue of response.data) {
if (issue.pull_request) {
continue;
}
const matched = getMatchedRule(issue);
if (!matched) {
continue;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [matched.label],
});
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
}
}
close-issues:
if: github.event_name != 'issues'
needs: label-unlabeled-issues
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
# 标记 stale 标签时间
days-before-issue-stale: 30
# 关闭 issues 标签时间
days-before-issue-close: 14
# 自定义标签名
stale-issue-label: "stale"
stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。"
close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。"
# 忽略所有的 Pull Request只处理 Issue
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 500
repo-token: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -10,7 +10,6 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
@@ -70,6 +69,7 @@ instance/
# Sphinx documentation
docs/_build/
docs/superpowers/
# PyBuilder
.pybuilder/
@@ -160,4 +160,4 @@ cython_debug/
#.idea/
.idea/
.vscode/
.vscode/

View File

@@ -42,6 +42,7 @@ MoviePilot-Plugins/
- MoviePilot 会优先读取 `package.v2.json` 中与当前版本标识匹配的插件定义。
- 如果某个插件不在 `package.v2.json` 中,但其 `package.json` 条目声明了 `"v2": true`,则会作为“兼容 V2 的默认插件”继续显示和安装。
- `package.v2.json` 中的插件代码通常放在 `plugins.v2/<plugin_id_lower>/``package.json` 中的插件代码通常放在 `plugins/<plugin_id_lower>/`
- 插件如果依赖特定主系统版本,可在条目中增加 `system_version`,格式参考 pip 依赖版本范围,例如 `">=2.12.0,<3"`;未定义该字段时不做主系统版本检查。
- 插件目录名必须是插件类名的小写形式,插件主类必须定义在对应目录的 `__init__.py` 中。
- 插件市场里看到的版本、图标、作者、权限级别,都来自 `package.json` / `package.v2.json`;运行时真正生效的类属性来自插件代码中的 `plugin_*` 字段,两者必须保持同步。
@@ -68,6 +69,8 @@ MoviePilot-Plugins/
### 4. 依赖
- 可在插件目录中放置 `requirements.txt` 文件声明额外依赖MoviePilot 安装插件时会自动安装。
- 依赖尽量保持最小化,优先复用主程序已提供的公共能力,例如下载器、媒体服务器、通知渠道、缓存、链式处理等封装。
- 插件依赖安装在 MoviePilot 的共享 Python 运行环境中,不是每个插件独立虚拟环境。不要在插件 `requirements.txt` 中重新声明或覆盖主程序已经提供的依赖版本。
- MoviePilot 会在安装前保护主程序依赖图:核心依赖会严格锁定当前版本,其他主程序依赖也不能被插件要求降级或改成不兼容版本;发生冲突时插件安装会被拒绝。
- 如果插件还依赖 Vue 远程组件,请将前端依赖放在独立的前端工程中构建后再产出到插件目录,不要把前端源码直接混入主插件包。
### 5. 界面开发
@@ -82,7 +85,7 @@ MoviePilot-Plugins/
- 详情页面和仪表板可通过 `events` 发起 API 调用。
- Vue 联邦模式说明:
- 插件后端需要实现 `get_render_mode()` 并返回 `("vue", "dist/assets")`
- 如果需要在主界面左侧导航新增入口,还需要实现 `get_sidebar_nav()`
- `get_page()` 对应插件管理中的详情弹窗;如果需要在主界面左侧导航新增全页入口,还需要实现 `get_sidebar_nav()` 并暴露 `AppPage` 远程组件
- 远程组件的构建、暴露名约定、侧栏多入口、静态资源打包方式,请参考 [模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md)。
### 6. 开发与校验建议
@@ -100,6 +103,7 @@ MoviePilot-Plugins/
- `package.json` / `package.v2.json` 中的 `version` 必须与插件类中的 `plugin_version` 保持一致,否则用户会看到错误的升级提示。
- `name``description``icon``author``level` 建议与插件类属性保持一致,避免插件市场展示与实际运行信息不一致。
- `history` 用于展示插件更新日志,建议每次发布都补齐一条可读变更说明。
- `system_version` 用于声明插件可安装的 MoviePilot 主系统版本范围,格式参考 pip 依赖版本约束;例如插件依赖 v2.12.0 新增能力时填写 `">=2.12.0"`
- 需要走 GitHub Release 压缩包分发的插件,请在对应索引条目中增加 `"release": true`,并确保仓库中的发布工作流能够定位到对应目录。
@@ -123,6 +127,8 @@ MoviePilot-Plugins/
- [14. 如何在插件中通过消息持续与用户交互?](./docs/faq/14-message-interaction.md)
- [15. 如何在插件中使用系统级统一缓存?](./docs/faq/15-use-system-cache.md)
- [16. 如何在插件中注册智能体工具?](./docs/faq/16-register-agent-tools.md)
- [17. 如何将插件页面注册到主界面左侧导航栏?](./docs/faq/17-register-plugin-sidebar-nav.md)
- [18. 如何限定插件可安装的 MoviePilot 主系统版本?](./docs/faq/18-limit-moviepilot-version.md)
## 版本发布

View File

@@ -18,3 +18,5 @@
- [14. 如何在插件中通过消息持续与用户交互?](./faq/14-message-interaction.md)
- [15. 如何在插件中使用系统级统一缓存?](./faq/15-use-system-cache.md)
- [16. 如何在插件中注册智能体工具?](./faq/16-register-agent-tools.md)
- [17. 如何将插件页面注册到主界面左侧导航栏?](./faq/17-register-plugin-sidebar-nav.md)
- [18. 如何限定插件可安装的 MoviePilot 主系统版本?](./faq/18-limit-moviepilot-version.md)

View File

@@ -90,6 +90,7 @@ V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里
- `icon`:图标文件名或完整 HTTP URL
- `author`:作者
- `level`:用户可见级别
- `system_version`:可安装的 MoviePilot 主系统版本范围,格式参考 pip 依赖版本约束,例如 `">=2.12.0,<3"`
- `history`:更新日志
- `release`:是否使用 GitHub Release 压缩包发布
- `v2`:默认索引中的插件是否兼容 V2
@@ -104,12 +105,14 @@ MoviePilot 当前的插件版本选择逻辑可以概括为:
2. 优先检查 `package.v2.json` 中是否存在该插件
3. 若不存在,再检查 `package.json`
4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用
5. 如果条目声明了 `system_version`,安装、更新检测和本地插件同步会继续检查当前 MoviePilot 主程序版本是否落在该范围内;未声明则不检查
这意味着:
- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。
- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`
- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。
- 依赖宿主新增能力的插件需要同步声明 `system_version`,否则旧版 MoviePilot 仍可能看到更新入口但安装后无法加载。
## 5. 与宿主仓库的协作边界

View File

@@ -177,11 +177,14 @@ class MyPlugin(_PluginBase):
"version": "1.0.0",
"icon": "Moviepilot_A.png",
"author": "your-name",
"system_version": ">=2.12.0",
"level": 1
}
}
```
`system_version` 是可选字段。插件依赖某个 MoviePilot 主系统版本才提供的能力时再声明,格式参考 pip 依赖版本范围;不声明时宿主不会做主系统版本检查。
## 4. `_PluginBase` 的核心能力
V2 插件的核心宿主基类是 `MoviePilot/app/plugins/__init__.py` 中的 `_PluginBase`。开发时需要优先理解它暴露出来的扩展点。

View File

@@ -0,0 +1,147 @@
# 17. 如何将插件页面注册到主界面左侧导航栏?
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
插件进入左侧导航栏走的是 **Vue 远程组件全页入口**,不是 `get_page()` 的详情弹窗。完整链路是:插件后端声明 Vue 渲染模式和侧栏入口MoviePilot 后端通过 `GET /api/v1/plugin/sidebar_nav` 聚合,前端把入口插入对应分组并跳转到 `#/plugin-app/<PluginID>/<nav_key>`,再加载插件暴露的 `AppPage` 组件。
## 1. 后端插件要做什么?
插件必须同时满足这些条件:
- 插件已启用,`get_state()` 返回 `True`
- `get_render_mode()` 返回 `("vue", "dist/assets")` 或你的实际构建产物目录。
- 实现 `get_sidebar_nav()` 并返回一个列表。
- 插件目录下存在前端构建产物,至少包含 `remoteEntry.js` 和被暴露的组件文件。
示例:
```python
from typing import Any, Dict, List, Tuple
def get_render_mode(self) -> Tuple[str, str]:
"""
声明插件使用 Vue 远程组件渲染,并指定构建产物目录。
"""
return "vue", "dist/assets"
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""
声明插件在主界面左侧导航栏中的全页入口。
"""
return [
{
"nav_key": "main",
"title": "我的插件",
"icon": "mdi-puzzle",
"section": "system",
"permission": "manage",
"order": 10,
}
]
```
字段说明:
| 字段 | 是否必填 | 说明 |
|------|----------|------|
| `nav_key` | 否 | 当前插件内的入口标识,默认 `main`;会进入 URL 路径段 |
| `title` | 否 | 侧栏显示标题,未填时使用插件名称 |
| `icon` | 否 | MDI 图标名,未填时使用 `mdi-puzzle` |
| `section` | 否 | 侧栏分组:`start` / `discovery` / `subscribe` / `organize` / `system`,无效值会归入 `system` |
| `permission` | 否 | 菜单权限:`subscribe` / `discovery` / `search` / `manage` / `admin`,未填则不额外限制 |
| `order` | 否 | 同组内排序,数值越小越靠前 |
注意:
- `nav_key` 不能包含 `/``?``#`、空格;建议使用 `main``settings``history``my_tool` 这类稳定值。
- `get_page()` 只影响插件管理里的详情弹窗;要出现在主界面左侧导航,必须实现 `get_sidebar_nav()`
- 如果插件依赖这个新前端能力,建议在 `package.json` / `package.v2.json` 中用 `system_version` 限定最低 MoviePilot 版本。
## 2. 前端远程组件要暴露什么?
前端工程需要在模块联邦里暴露全页组件:
```typescript
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./AppPage': './src/components/AppPage.vue',
},
})
```
`AppPage.vue` 会收到主应用传入的 `api``pluginId``navKey`
```vue
<script setup lang="ts">
const props = defineProps({
api: { type: Object, default: () => ({}) },
pluginId: { type: String, default: '' },
navKey: { type: String, default: 'main' },
})
</script>
<template>
<div class="pa-4">
{{ props.pluginId }} / {{ props.navKey }}
</div>
</template>
```
如果页面需要调用插件后端 API后端 `get_api()` 建议使用 `auth: "bear"`,前端通过传入的 `api` 调用:
```typescript
const rows = await props.api.get(`plugin/${props.pluginId}/history`)
```
## 3. 多个导航入口怎么做?
`get_sidebar_nav()` 可以返回多条记录:
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""
声明同一插件的多个左侧导航入口。
"""
return [
{
"nav_key": "main",
"title": "处理面板",
"icon": "mdi-view-dashboard",
"section": "organize",
"permission": "manage",
"order": 20,
},
{
"nav_key": "settings",
"title": "处理设置",
"icon": "mdi-cog",
"section": "system",
"permission": "manage",
"order": 21,
},
]
```
前端加载规则:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage` -> `./Page` |
| 其它,例如 `settings` | `./AppPageSettings` -> `./AppPage` -> `./Page` |
| 其它,例如 `my_tool` | `./AppPageMyTool` -> `./AppPage` -> `./Page` |
也就是说你可以只暴露一个 `./AppPage`,在组件内根据 `navKey` 分支渲染;也可以为不同入口分别暴露 `./AppPageSettings``./AppPageHistory` 等组件。
## 4. 排查清单
- `GET /api/v1/plugin/sidebar_nav` 是否能看到你的插件入口。
- `GET /api/v1/plugin/remotes?token=moviepilot` 是否能看到你的插件远程组件入口。
- 插件是否启用,且 `get_render_mode()` 是否返回 `vue`
- `dist/assets/remoteEntry.js` 是否实际安装到了插件运行目录。
- `nav_key` 是否包含非法字符,或和前端暴露名不匹配。
- 当前用户是否有 `permission` 声明的权限;超级用户默认拥有全部权限。
- 前端侧栏会缓存 `plugin/sidebar_nav` 结果,插件启停或变更入口后建议刷新页面重新加载。

View File

@@ -0,0 +1,26 @@
# 18. 如何限定插件可安装的 MoviePilot 主系统版本?
如果插件依赖某个 MoviePilot 主程序版本才提供的后端接口、前端能力、事件字段或运行时模块,应在对应的 `package.json` / `package.v2.json` 条目中增加 `system_version` 字段。
```json
{
"MyPlugin": {
"name": "我的插件",
"version": "1.0.0",
"system_version": ">=2.12.0"
}
}
```
字段规则:
- `system_version` 的格式参考 pip 依赖版本范围,也就是 PEP 440 specifier例如 `">=2.12.0"``">=2.12.0,<3"``"~=2.12"`
- MoviePilot 当前版本号带 `v` 前缀也可以正常比较,因此 `>=v2.12.0``>=2.12.0` 都能解析;文档和索引中推荐写不带 `v` 的形式。
- 未定义 `system_version` 时,宿主不做主系统版本检查,保持旧插件兼容。
- 如果当前主系统版本不满足范围,插件市场会显示不兼容提示,安装和更新都会被拒绝。
发布插件时,建议在以下场景补充该字段:
- 插件调用了新版本才存在的后端 API、helper、chain、module 或事件字段。
- 插件的 Vue 远程组件依赖新版本前端才支持的加载、侧栏、仪表板或渲染行为。
- 插件换用了新版本 MoviePilot 才内置或才稳定可用的系统能力,例如浏览器运行时、统一缓存、智能体工具注册等。

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

View File

@@ -114,11 +114,13 @@
"name": "目录监控",
"description": "监控目录文件发生变化时实时整理到媒体库。",
"labels": "文件整理",
"version": "2.4",
"version": "2.5.1",
"icon": "directory.png",
"author": "jxxghp",
"level": 1,
"history": {
"v2.5.1": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
"v2.5": "目录监控改用watchfiles移除旧监控依赖",
"v2.4": "修复目录监控不使用ChatGPT辅助识别问题",
"v2.3": "特殊场景下补充转移成功历史记录",
"v2.2": "更新目录设置说明",
@@ -251,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+"
}
@@ -325,11 +328,13 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "1.9.12",
"version": "1.9.14",
"icon": "IYUU.png",
"author": "jxxghp",
"level": 2,
"history": {
"v1.9.14": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
"v1.9.13": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v1.9.12": "修复海豹不能辅种的问题",
"v1.9.11": "修复馒头不能辅种的问题",
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
@@ -355,11 +360,12 @@
"name": "青蛙辅种助手",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
"labels": "做种",
"version": "2.4",
"version": "2.4.1",
"icon": "qingwa.png",
"author": "233@qingwa",
"level": 2,
"history": {
"v2.4.1": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v2.4": "支持qbittorrent 5",
"v2.2": "站点停用后会同步暂停对该站点的辅种",
"v2.3": "站点辅种支持代理"
@@ -369,11 +375,13 @@
"name": "整理VCB动漫压制组作品",
"description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件",
"labels": "文件整理,识别",
"version": "1.8.2.1",
"version": "1.8.2.4",
"icon": "vcbmonitor.png",
"author": "pixel@qingwa",
"level": 2,
"history": {
"v1.8.2.4": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
"v1.8.2.3": "目录监控改用watchfiles移除旧监控依赖",
"v1.8.2.1": "修复日志输出&同步目录监控插件功能",
"v1.8.2": "提高识别率",
"v1.8.1": "重构插件,测试版",
@@ -470,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自动更新功能",
@@ -637,12 +646,14 @@
"name": "清理硬链接",
"description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件",
"labels": "文件整理",
"version": "2.2",
"version": "2.3.1",
"icon": "Ombi_A.png",
"author": "DzAvril",
"level": 1,
"v2": true,
"history": {
"v2.3.1": "优化下载临时文件过滤,避免清理硬链接处理未完成文件",
"v2.3": "目录监控改用watchfiles移除旧监控依赖",
"v2.2": "修复直接删除文件夹导致的插件崩溃的bug",
"v2.1": "联动删除历史记录",
"v2.0": "联动删除种子,需安装插件[下载器助手]并打开监听源文件事件",
@@ -656,12 +667,14 @@
"name": "实时硬链接",
"description": "监控目录文件变化,实时硬链接。",
"labels": "文件整理",
"version": "1.6",
"version": "1.7.1",
"icon": "Linkace_C.png",
"author": "jxxghp",
"level": 1,
"v2": true,
"history": {
"v1.7.1": "过滤下载临时文件,避免实时硬链接处理未完成文件",
"v1.7": "目录监控改用watchfiles移除旧监控依赖",
"v1.6": "增强API安全性"
}
},
@@ -867,12 +880,14 @@
"name": "MoviePilot服务器监控",
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
"labels": "仪表板",
"version": "1.2",
"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": "增加详情界面显示"
}

View File

@@ -44,12 +44,14 @@
"name": "站点自动签到",
"description": "自动模拟登录、签到站点。",
"labels": "站点",
"version": "2.8.2",
"version": "2.9.1",
"icon": "signin.png",
"author": "thsrite",
"level": 2,
"release": true,
"history": {
"v2.9.1": "修复详情页历史记录部分站点只显示站点ID的问题",
"v2.9.0": "优化插件详情页,改为紧凑状态矩阵展示签到和登录情况",
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
"v2.8.1": "更新站点 Rousi Pro 签到接口",
"v2.8": "适配站点 Rousi Pro",
@@ -97,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": "修复单集剧情信息有概率获取失败的问题",
@@ -115,13 +118,20 @@
},
"ChatGPT": {
"name": "ChatGPT",
"description": "消息交互支持与ChatGPT对话。",
"labels": "消息通知,识别",
"version": "2.1.9",
"description": "使用系统智能助手或 Agent Tokens 管理插件的 LLM 配置增强媒体名称识别。",
"labels": "AI,识别,LLM,媒体识别,Agent Tokens",
"version": "3.0.5",
"icon": "Chatgpt_A.png",
"author": "jxxghp",
"level": 1,
"system_version": ">=2.13.2",
"history": {
"v3.0.5": "新增识别调用 Token 用量统计,详情页按千分位展示并支持清除统计,同时优化缓存说明文案。",
"v3.0.4": "重构缓存管理 UI缓存数量改从数据库读取新增插件详情页管理缓存。",
"v3.0.3": "添加识别结果持久化缓存,避免相同标题重复调用 LLM API支持手动清除缓存并显示缓存数量。",
"v3.0.2": "修复插件入口误打开空详情页,并统一配置表单行列布局。",
"v3.0.1": "兼容系统智能助手和 Agent Tokens 供应商的 LLM 代理开关配置。",
"v3.0": "移除聊天功能,仅保留媒体识别增强;模型来源改为系统智能助手设置或 Agent Tokens 管理插件;重写英文识别提示词并优化配置界面。",
"v2.1.9": "更新依赖库",
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
"v2.1.7": "独立安装OpenAi SDK依赖",
@@ -185,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 版本",
@@ -201,11 +213,14 @@
"name": "演职人员刮削",
"description": "刮削演职人员图片以及中文名称。",
"labels": "媒体库,刮削",
"version": "2.2.2",
"version": "2.2.4",
"icon": "actor.png",
"author": "jxxghp",
"level": 1,
"system_version": ">2.12.4",
"history": {
"v2.2.4": "改为监听元数据刮削事件并增加实时防重,修复媒体服务器过滤和空人物保存问题",
"v2.2.3": "简繁转换依赖改用 zhconv-rs需要 MoviePilot >2.12.4",
"v2.2.2": "修复异常日志问题",
"v2.2.1": "优化错误数据兼容处理",
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
@@ -261,11 +276,13 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
"version": "2.15",
"version": "2.17",
"icon": "IYUU.png",
"author": "jxxghp,CKun",
"level": 2,
"history": {
"v2.17": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
"v2.16": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v2.15": "修复海豹不能辅种的问题",
"v2.14": "修复馒头不能辅种的问题",
"v2.13": "开启跳过校验后需手动开启自动开始",
@@ -288,11 +305,12 @@
"name": "青蛙辅种助手",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
"labels": "做种",
"version": "3.0.2",
"version": "3.0.3",
"icon": "qingwa.png",
"author": "233@qingwa",
"level": 2,
"history": {
"v3.0.3": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
"v3.0.2": "更新依赖库",
"v3.0.1": "遗漏了一个私有属性",
"v3.0": "兼容MoviePilot V2 版本"
@@ -479,11 +497,13 @@
"name": "IMDb源",
"description": "让探索推荐和媒体识别支持IMDb数据源。",
"labels": "探索",
"version": "1.6.8",
"version": "1.6.9",
"icon": "IMDb_IOS-OSX_App.png",
"author": "wumode",
"level": 1,
"system_version": ">2.12.4",
"history": {
"v1.6.9": "简繁转换依赖改用 zhconv-rs需要 MoviePilot >2.12.4",
"v1.6.8": "兼容 MoviePilot v2.11.0 识别链新增 share_meta 参数,修复辅助识别模式下刮削报错",
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
"v1.6.6": "优化主页组件链接跳转",
@@ -519,12 +539,13 @@
"name": "Clash Rule Provider",
"description": "随时为Clash添加一些额外的规则。",
"labels": "工具",
"version": "2.1.6",
"version": "2.1.7",
"icon": "Mihomo_Meta_A.png",
"author": "wumode",
"level": 1,
"release": true,
"history": {
"v2.1.7": "移除全局 TLS 指纹配置,请在代理节点中直接设置 client-fingerprint",
"v2.1.6": "修复依赖冲突",
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
"v2.1.4": "支持 xhttp 协议",
@@ -567,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": "优化提示词",
@@ -603,11 +625,13 @@
"name": "Bug反馈",
"description": "自动上报异常,协助开发者发现和解决问题。",
"labels": "开发",
"version": "1.3",
"version": "1.5.1",
"icon": "Alist_encrypt_A.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.5": "更换上报端点",
"v1.4": "仅上报包含异常堆栈的错误,普通日志不再上报",
"v1.3": "减少网络异常信息上送",
"v1.2": "优化上报信息量",
"v1.1": "加强脱敏处理"
@@ -638,6 +662,70 @@
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
}
},
"DynamicWeChat": {
"name": "动态企微可信IP",
"description": "修改企微应用可信IP支持Srever酱等第三方通知。验证码以结尾发送到企业微信应用",
"labels": "消息通知",
"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 兼容。",
"v1.7.3": "修复检测登录的元素",
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
"v1.7.0": "使用第三方通知时可IP变动后通知拟支持多网络出口检查。",
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快v1可配置第三方为备用通知server酱可以将文本发送到server3,二维码给服务号",
"v1.5.1": "修复v2微信通知可以指定微信通知ID",
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
"v1.4.1": "完善面板说明",
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版显示了一个没用的参数"
}
},
"InvitesSignin": {
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"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": "增加签到检测机制防止重复签到,增强代码健壮性。",
"v2.0.1": "尝试修复签到失败问题新增使用代理、Cookie自动更新功能",
"v2.0.0": "修复签到失败问题新增账户登录签到功能、新增签到失败重试机制美化界面UI",
"v1.4.1": "更新签到域名前缀",
"v1.4": "自定义保留消息天数"
}
},
"ContractCheck": {
"name": "契约检查",
"description": "定时检查保种契约达成情况。",
"labels": "做种",
"version": "2.0.0",
"icon": "contract.png",
"author": "DzAvril",
"level": 1,
"system_version": ">=2.12.0",
"history": {
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
"v1.4.1": "增加站点猪猪",
"v1.4": "支持仪表板组件显示",
"v1.3": "修复观众做种数据异常问题",
"v1.2": "修复契约检查无数据返回的问题"
}
},
"TvFirstWatch": {
"name": "首播试看",
"description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。",
@@ -654,12 +742,13 @@
"name": "WechatClawBot消息推送",
"description": "支持使用微信(通过ClawBot)发送消息通知。",
"labels": "消息通知,微信",
"version": "0.2.1",
"version": "0.2.2",
"icon": "Wechat_A.png",
"author": "mijjjj",
"level": 1,
"v2": true,
"history": {
"v0.2.2": "修复重复推送消息问题,增加入站消息去重并修正轮询更新提取逻辑",
"v0.2.1": "修复详情页状态信息换行显示问题",
"v0.2.0": "优化配置页UI布局修复回复消息携带多余类型前缀的问题",
"v0.1.0": "初始版本"
@@ -693,11 +782,13 @@
"name": "Agent影视助手",
"description": "龙虾agent稳定控制 MP飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
"version": "0.2.72",
"version": "0.2.73",
"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/命令文档。",
@@ -971,61 +1062,79 @@
"v1.0.1": "修复定时任务重复触发问题"
}
},
"DynamicWeChat": {
"name": "动态企微可信IP",
"description": "修改企微应用可信IP支持Srever酱等第三方通知。验证码以结尾发送到企业微信应用",
"labels": "消息通知",
"version": "2.0.0",
"icon": "Wecom_A.png",
"author": "RamenRa",
"level": 2,
"history": {
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
"v1.7.3": "修复检测登录的元素",
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
"v1.7.0": "使用第三方通知时可IP变动后通知拟支持多网络出口检查。",
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快v1可配置第三方为备用通知server酱可以将文本发送到server3,二维码给服务号",
"v1.5.1": "修复v2微信通知可以指定微信通知ID",
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
"v1.4.1": "完善面板说明",
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版显示了一个没用的参数"
}
},
"InvitesSignin": {
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"version": "3.0.0",
"icon": "invites.png",
"author": "thsrite",
"level": 2,
"OidcAuth": {
"name": "OIDC 认证",
"description": "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。",
"labels": "认证,OIDC,SSO",
"version": "0.3.1",
"icon": "Oidcauth_A.png",
"author": "ui-beam-9,jxxghp",
"level": 1,
"system_version": ">=2.13.5",
"release": true,
"history": {
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
"v2.0.3": "增加启用浏览器仿真功能发送请求",
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
"v2.0.1": "尝试修复签到失败问题新增使用代理、Cookie自动更新功能",
"v2.0.0": "修复签到失败问题新增账户登录签到功能、新增签到失败重试机制美化界面UI",
"v1.4.1": "更新签到域名前缀",
"v1.4": "自定义保留消息天数"
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。",
"v0.2.0": "AuthPage 自动跳转 OIDC 授权,新增加载动画与错误重试;修复弹窗拦截提示及 PROXY_HOST 空值崩溃,补充配置表单指南。",
"v0.3.0": "重构双栏布局与动态背景,支持深浅主题自适应;新增绑定可视化、详情卡片及解绑确认;升级通信机制,新增特性介绍与底部信息栏,统一图标风格。",
"v0.3.1": "修复回调事件类型不匹配导致前端错误提示不准确;移除解绑方法多余检查,允许 OIDC 关闭状态下正常解绑。"
}
},
"ContractCheck": {
"name": "契约检查",
"description": "定时检查保种契约达成情况。",
"labels": "做种",
"version": "2.0.0",
"icon": "contract.png",
"author": "DzAvril",
"AgentTokens": {
"name": "Agent Tokens 管理",
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
"labels": "Agent,AI,系统",
"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",
"v1.0.7": "禁用VWindow触摸滑动修复表格内滑动触发tab切换问题",
"v1.0.6": "优化标题样式并对齐站点管理页面风格,修复弹窗标题截断问题",
"v1.0.5": "优化UI布局修复页面标题和按钮滚动问题",
"v1.0.4": "补充分配模型信息及更新用量的运行日志",
"v1.0.3": "修复因组件导出导致的界面空白问题、统一图标样式并去除全局样式污染",
"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": {
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
"v1.4.1": "增加站点猪猪",
"v1.4": "支持仪表板组件显示",
"v1.3": "修复观众做种数据异常问题",
"v1.2": "修复契约检查无数据返回的问题"
"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": "初始化"
}
}
}

View File

@@ -126,7 +126,7 @@ class AgentResourceOfficer(_PluginBase):
plugin_name = "Agent影视助手"
plugin_desc = "龙虾agent稳定控制 MP飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png"
plugin_version = "0.2.72"
plugin_version = "0.2.73"
request_templates_schema_version = "request_templates.v1"
plugin_author = "liuyuexi1987"
plugin_level = 1

View File

@@ -15,9 +15,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
import jieba
from jieba_next import cut as jieba_cut
except Exception:
jieba = None
jieba_cut = None
for _site_path in (
"/usr/local/lib/python3.12/site-packages",
@@ -33,7 +33,7 @@ except Exception:
_LARK_IMPORT_LOCK = threading.Lock()
_LARK_AUTO_INSTALL_ATTEMPTED = False
_LARK_PACKAGE_SPEC = "lark-oapi==1.5.3"
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
try:
from app.chain.download import DownloadChain
@@ -1350,9 +1350,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 is not None:
if title_text and jieba_cut 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

View File

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

2
plugins.v2/agenttokens/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
package-lock.json
node_modules/

View File

@@ -0,0 +1,493 @@
import threading
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from fastapi import Body
from app import schemas
from app.api.endpoints.plugin import register_plugin_api
from app.core.event import Event, eventmanager
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import ChainEventType, EventType
class AgentTokens(_PluginBase):
"""
Agent Tokens 管理插件。
通过 Agent LLM 供应商链式事件按优先级选择仍有 token 余量的供应商,
并通过 Agent Tokens 用量广播事件回写实际消耗。
"""
plugin_name = "Agent Tokens 管理"
plugin_desc = "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。"
plugin_icon = "agentresourceofficer.png"
plugin_version = "1.0.12"
plugin_author = "jxxghp"
author_url = "https://github.com/jxxghp"
plugin_config_prefix = "agenttokens_"
plugin_order = 45
auth_level = 1
DATA_KEY_USAGE = "usage"
def init_plugin(self, config: dict = None):
"""
初始化插件配置,补齐供应商稳定 ID 以便后续用量能持续关联。
"""
self._usage_lock = threading.RLock()
config = config or {}
self._enabled = bool(config.get("enabled"))
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
self._providers = self._normalize_providers(config.get("providers") or [])
self._save_config()
def get_state(self) -> bool:
"""
返回插件是否已启用。
"""
return bool(getattr(self, "_enabled", False))
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
当前插件不注册远程命令。
"""
return []
def get_api(self) -> List[Dict[str, Any]]:
"""
注册 Vue 界面需要调用的插件 API。
"""
return [
{
"path": "/status",
"endpoint": self.get_status,
"methods": ["GET"],
"auth": "bear",
"summary": "获取 Agent Tokens 状态",
},
{
"path": "/config",
"endpoint": self.save_config_api,
"methods": ["POST"],
"auth": "bear",
"summary": "保存 Agent Tokens 配置",
},
{
"path": "/usage/reset",
"endpoint": self.reset_usage_api,
"methods": ["POST"],
"auth": "bear",
"summary": "重置指定供应商用量",
},
{
"path": "/usage/reset_all",
"endpoint": self.reset_all_usage_api,
"methods": ["POST"],
"auth": "bear",
"summary": "重置全部供应商用量",
},
]
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""
声明插件使用 Vue 联邦组件渲染。
"""
return "vue", "dist/assets"
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
Vue 模式下返回默认配置模型。
"""
return [], self._current_config()
def get_page(self) -> List[dict]:
"""
Vue 模式下详情页由远程 Page 组件渲染。
"""
return []
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
"""
声明一个用量概览仪表板组件。
"""
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], Optional[List[dict]]]]:
"""
返回 Vue 仪表板组件的布局与标题配置。
"""
if not self.get_state():
return None
return (
{"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]]:
"""
将 Agent Tokens 管理页注册到主界面侧栏。
"""
if not self.get_state() or not getattr(self, "_show_sidebar_nav", True):
return []
return [
{
"nav_key": "main",
"title": "Agent Tokens 管理",
"icon": "mdi-key-chain",
"section": "system",
"permission": "manage",
"order": 46,
}
]
def stop_service(self):
"""
插件无后台服务,停用时无需清理额外资源。
"""
pass
@staticmethod
def _to_int(value: Any, default: int = 0) -> int:
"""
将配置或事件中的数字字段安全转为整数。
"""
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _clean_text(value: Any) -> str:
"""
清理配置中的文本字段,避免空白值参与供应商选择。
"""
return str(value or "").strip()
@staticmethod
def _event_get(event_data: Any, key: str, default: Any = None) -> Any:
"""
兼容读取 Pydantic 事件模型或字典中的字段。
"""
if isinstance(event_data, dict):
return event_data.get(key, default)
return getattr(event_data, key, default)
@staticmethod
def _event_set(event_data: Any, key: str, value: Any) -> None:
"""
兼容写入 Pydantic 事件模型或字典中的字段。
"""
if isinstance(event_data, dict):
event_data[key] = value
else:
setattr(event_data, key, value)
@classmethod
def _normalize_provider(cls, provider: dict, index: int) -> dict:
"""
标准化单个供应商配置,并为旧配置补齐稳定 ID。
"""
provider = provider or {}
provider_id = cls._clean_text(provider.get("id")) or uuid.uuid4().hex
token_limit = max(cls._to_int(provider.get("token_limit"), 0), 0)
used_tokens = max(cls._to_int(provider.get("used_tokens"), 0), 0)
priority = cls._to_int(provider.get("priority"), index + 1)
return {
"id": provider_id,
"enabled": bool(provider.get("enabled", True)),
"name": cls._clean_text(provider.get("name")) or f"Provider {index + 1}",
"provider": cls._clean_text(
provider.get("provider") or provider.get("llm_provider")
) or "openai",
"base_url": cls._clean_text(provider.get("base_url")),
"api_key": cls._clean_text(provider.get("api_key")),
"user_agent": cls._clean_text(provider.get("user_agent")),
"use_proxy": bool(provider.get("use_proxy", True)),
"model": cls._clean_text(provider.get("model")),
"token_limit": token_limit,
"used_tokens": used_tokens,
"priority": priority,
}
@classmethod
def _normalize_providers(cls, providers: list) -> List[dict]:
"""
标准化供应商列表并按优先级排序。
"""
normalized = [
cls._normalize_provider(provider, index)
for index, provider in enumerate(providers or [])
if isinstance(provider, dict)
]
return sorted(normalized, key=lambda item: (item["priority"], item["name"]))
@staticmethod
def _mask_api_key(api_key: str) -> str:
"""
生成 API Key 的脱敏展示文本。
"""
if not api_key:
return ""
if len(api_key) <= 8:
return "****"
return f"{api_key[:4]}...{api_key[-4:]}"
def _current_config(self) -> Dict[str, Any]:
"""
返回当前插件配置快照。
"""
return {
"enabled": bool(getattr(self, "_enabled", False)),
"show_sidebar_nav": bool(getattr(self, "_show_sidebar_nav", True)),
"providers": list(getattr(self, "_providers", [])),
}
def _save_config(self) -> None:
"""
保存当前插件配置,确保供应商 ID 的补齐结果能持久化。
"""
self.update_config(self._current_config())
def _load_usage(self) -> Dict[str, dict]:
"""
读取已记录的供应商用量。
"""
usage = self.get_data(self.DATA_KEY_USAGE) or {}
return usage if isinstance(usage, dict) else {}
def _save_usage(self, usage: Dict[str, dict]) -> None:
"""
保存供应商用量数据。
"""
self.save_data(self.DATA_KEY_USAGE, usage or {})
def _provider_usage(self, provider: dict, usage: Optional[Dict[str, dict]] = None) -> dict:
"""
汇总供应商的手工初始用量和 Agent 实际记录用量。
"""
usage = usage if usage is not None else self._load_usage()
provider_usage = usage.get(provider["id"], {}) or {}
recorded_total = self._to_int(provider_usage.get("total_tokens"), 0)
manual_used = self._to_int(provider.get("used_tokens"), 0)
total_used = manual_used + recorded_total
token_limit = self._to_int(provider.get("token_limit"), 0)
remaining = None if token_limit <= 0 else max(token_limit - total_used, 0)
percent = 0
if token_limit > 0:
percent = min(round(total_used * 100 / token_limit, 2), 100)
return {
"input_tokens": self._to_int(provider_usage.get("input_tokens"), 0),
"output_tokens": self._to_int(provider_usage.get("output_tokens"), 0),
"recorded_tokens": recorded_total,
"manual_used_tokens": manual_used,
"total_tokens": total_used,
"token_limit": token_limit,
"remaining_tokens": remaining,
"usage_percent": percent,
"model_call_count": self._to_int(provider_usage.get("model_call_count"), 0),
"runs": self._to_int(provider_usage.get("runs"), 0),
"success_count": self._to_int(provider_usage.get("success_count"), 0),
"failure_count": self._to_int(provider_usage.get("failure_count"), 0),
"last_used_at": provider_usage.get("last_used_at"),
"last_error": provider_usage.get("last_error"),
"exhausted": token_limit > 0 and total_used >= token_limit,
}
def _provider_status_rows(self) -> List[dict]:
"""
构建前端展示用的供应商状态列表。
"""
usage = self._load_usage()
rows = []
for provider in getattr(self, "_providers", []):
provider_usage = self._provider_usage(provider, usage)
rows.append({
**provider,
"masked_api_key": self._mask_api_key(provider.get("api_key", "")),
"usage": provider_usage,
})
return rows
def _summary(self) -> Dict[str, Any]:
"""
汇总当前供应商数量和 token 使用情况。
"""
rows = self._provider_status_rows()
enabled_rows = [row for row in rows if row.get("enabled")]
available_rows = [
row for row in enabled_rows
if not row["usage"].get("exhausted")
and row.get("api_key")
and row.get("model")
and row.get("base_url")
]
return {
"enabled": self.get_state(),
"provider_count": len(rows),
"enabled_count": len(enabled_rows),
"available_count": len(available_rows),
"total_limit": sum(row["usage"]["token_limit"] for row in rows),
"total_used": sum(row["usage"]["total_tokens"] for row in rows),
}
def _select_provider(self) -> Optional[dict]:
"""
按优先级选择第一个启用且未耗尽 token 配额的供应商。
"""
usage = self._load_usage()
for provider in getattr(self, "_providers", []):
if not provider.get("enabled"):
continue
if not provider.get("api_key") or not provider.get("model") or not provider.get("base_url"):
continue
provider_usage = self._provider_usage(provider, usage)
if provider_usage["exhausted"]:
continue
return provider
return None
def get_status(self) -> schemas.Response:
"""
获取插件配置、供应商用量和概览统计。
"""
return schemas.Response(
success=True,
data={
"config": self._current_config(),
"providers": self._provider_status_rows(),
"summary": self._summary(),
},
)
def save_config_api(self, config: dict = Body(...)) -> schemas.Response:
"""
保存前端提交的供应商配置。
"""
try:
self._enabled = bool(config.get("enabled"))
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
self._providers = self._normalize_providers(config.get("providers") or [])
self._save_config()
return schemas.Response(success=True, data=self.get_status().data)
except Exception as err:
logger.error(f"保存 Agent Tokens 配置失败: {err}")
return schemas.Response(success=False, message=str(err))
def reset_usage_api(self, payload: Optional[dict] = Body(default=None)) -> schemas.Response:
"""
重置指定供应商的已记录用量。
"""
payload = payload or {}
provider_id = self._clean_text(payload.get("provider_id"))
if not provider_id:
return schemas.Response(success=False, message="缺少 provider_id")
with self._usage_lock:
usage = self._load_usage()
usage.pop(provider_id, None)
self._save_usage(usage)
return schemas.Response(success=True, data=self.get_status().data)
def reset_all_usage_api(self) -> schemas.Response:
"""
重置所有供应商的已记录用量。
"""
with self._usage_lock:
self._save_usage({})
return schemas.Response(success=True, data=self.get_status().data)
@eventmanager.register(ChainEventType.AgentLLMProvider, priority=50)
def select_llm_provider(self, event: Event):
"""
响应 Agent LLM 供应商链式事件,写入当前可用供应商配置。
"""
if not self.get_state() or not event or not event.event_data:
return
if self._event_get(event.event_data, "selected_provider_id"):
return
provider = self._select_provider()
if not provider:
logger.info("Agent Tokens 没有可用供应商Agent 将使用系统 LLM 配置")
return
provider_name = provider.get("name")
model = provider.get("model")
logger.info(f"Agent Tokens 分配 LLM 供应商:[{provider_name}] 模型:[{model}]")
self._event_set(event.event_data, "provider", provider.get("provider") or "openai")
self._event_set(event.event_data, "base_url", provider.get("base_url"))
self._event_set(event.event_data, "api_key", provider.get("api_key"))
self._event_set(event.event_data, "user_agent", provider.get("user_agent"))
self._event_set(event.event_data, "use_proxy", bool(provider.get("use_proxy", True)))
self._event_set(event.event_data, "model", provider.get("model"))
self._event_set(event.event_data, "base_url_preset", None)
self._event_set(event.event_data, "selected_provider_id", provider.get("id"))
self._event_set(event.event_data, "selected_provider_name", provider.get("name"))
self._event_set(event.event_data, "source", self.__class__.__name__)
@eventmanager.register(EventType.AgentTokensUsage)
def record_tokens_usage(self, event: Event):
"""
响应 Agent Tokens 用量广播事件,累计记录到对应供应商。
"""
if not self.get_state() or not event or not event.event_data:
return
provider_id = self._clean_text(
self._event_get(event.event_data, "selected_provider_id")
)
if not provider_id:
return
input_tokens = max(self._to_int(self._event_get(event.event_data, "input_tokens"), 0), 0)
output_tokens = max(self._to_int(self._event_get(event.event_data, "output_tokens"), 0), 0)
total_tokens = max(self._to_int(self._event_get(event.event_data, "total_tokens"), 0), 0)
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
with self._usage_lock:
usage = self._load_usage()
record = usage.setdefault(provider_id, {})
record["input_tokens"] = self._to_int(record.get("input_tokens"), 0) + input_tokens
record["output_tokens"] = self._to_int(record.get("output_tokens"), 0) + output_tokens
record["total_tokens"] = self._to_int(record.get("total_tokens"), 0) + total_tokens
record["model_call_count"] = self._to_int(
record.get("model_call_count"), 0
) + max(self._to_int(self._event_get(event.event_data, "model_call_count"), 0), 0)
record["runs"] = self._to_int(record.get("runs"), 0) + 1
if bool(self._event_get(event.event_data, "success", False)):
record["success_count"] = self._to_int(record.get("success_count"), 0) + 1
record["last_error"] = None
else:
record["failure_count"] = self._to_int(record.get("failure_count"), 0) + 1
record["last_error"] = self._clean_text(self._event_get(event.event_data, "error"))
record["last_model"] = self._clean_text(self._event_get(event.event_data, "model"))
record["last_used_at"] = (
self._clean_text(self._event_get(event.event_data, "finished_at"))
or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
usage[provider_id] = record
provider_name = self._clean_text(self._event_get(event.event_data, "selected_provider_name")) or provider_id
logger.info(f"Agent Tokens 更新用量记录:供应商 [{provider_name}] 本次消耗了 {total_tokens} Tokens")
self._save_usage(usage)
@eventmanager.register(EventType.PluginReload)
def reload(self, event: Event):
"""
插件重载后重新注册动态 API。
"""
if event.event_data.get("plugin_id") == self.__class__.__name__:
register_plugin_api(plugin_id=self.__class__.__name__)

View File

@@ -0,0 +1,148 @@
.provider-table-shell[data-v-cd4337d8] {
overflow-x: auto;
}
.provider-table-shell[data-v-cd4337d8] table {
min-width: 960px;
}
.truncate-cell[data-v-cd4337d8] {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-table-shell[data-v-a305c97e] {
overflow-x: auto;
}
.provider-table-shell[data-v-a305c97e] table {
min-width: 760px;
}
.progress-cell[data-v-a305c97e] {
min-width: 140px;
}
.usage-overview-card[data-v-f9b76345] {
block-size: 100%;
padding: 20px;
}
.usage-overview-card__content[data-v-f9b76345] {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 20px;
}
.usage-overview-card__chart[data-v-f9b76345] {
display: flex;
justify-content: center;
}
.usage-overview-card__percent[data-v-f9b76345] {
font-size: 1.35rem;
font-weight: 700;
}
.usage-overview-card__headline[data-v-f9b76345] {
margin-block-start: 4px;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
.usage-overview-card__meta[data-v-f9b76345] {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
}
@media (max-width: 600px) {
.usage-overview-card[data-v-f9b76345] {
padding: 16px;
}
.usage-overview-card__content[data-v-f9b76345] {
grid-template-columns: 1fr;
text-align: center;
}
.usage-overview-card__meta[data-v-f9b76345] {
justify-content: center;
}
}
.agenttokens-page[data-v-a6c1ea54] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.agenttokens-header[data-v-a6c1ea54] {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.agenttokens-control-panel[data-v-a6c1ea54] {
display: flex;
align-items: center;
padding: 12px 16px;
}
.agenttokens-control-panel__switches[data-v-a6c1ea54] {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.agenttokens-overview-grid[data-v-a6c1ea54] {
display: grid;
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
gap: 12px;
}
.agenttokens-overview-card[data-v-a6c1ea54] {
min-block-size: 172px;
}
.agenttokens-stat-card[data-v-a6c1ea54] {
display: flex;
align-items: center;
gap: 12px;
min-block-size: 104px;
padding: 16px;
}
.agenttokens-stat-card__value[data-v-a6c1ea54] {
margin-block-start: 2px;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
.agenttokens-content-panel[data-v-a6c1ea54] {
overflow: hidden;
}
.agenttokens-tabs-row[data-v-a6c1ea54] {
padding-inline: 8px;
}
.agenttokens-window[data-v-a6c1ea54] {
padding: 12px;
}
.agenttokens-table-actions[data-v-a6c1ea54] {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-block-end: 12px;
}
@media (max-width: 1100px) {
.agenttokens-overview-grid[data-v-a6c1ea54] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.agenttokens-overview-card[data-v-a6c1ea54] {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.agenttokens-page[data-v-a6c1ea54] {
padding: 12px;
}
.agenttokens-overview-grid[data-v-a6c1ea54] {
grid-template-columns: 1fr;
}
.agenttokens-stat-card[data-v-a6c1ea54] {
min-block-size: 88px;
}
}

View File

@@ -0,0 +1,983 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
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');
const _hoisted_1$3 = { key: 0 };
const _hoisted_2$3 = { key: 1 };
const _hoisted_3$3 = {
key: 0,
class: "truncate-cell"
};
const _hoisted_4$2 = { key: 1 };
const _hoisted_5$2 = { class: "text-right" };
const _hoisted_6$2 = { key: 0 };
const _hoisted_7$2 = ["colspan"];
const _sfc_main$4 = {
__name: 'ProviderConfigTable',
props: {
providers: {
type: Array,
default: () => [],
},
providerRows: {
type: Array,
default: () => [],
},
showCredentials: {
type: Boolean,
default: false,
},
},
emits: ['edit', 'remove'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
// 获取管理页服务端返回的脱敏 Key。
function getMaskedApiKey(index) {
return props.providerRows[index]?.masked_api_key || '****'
}
return (_ctx, _cache) => {
const _component_VSwitch = _resolveComponent$4("VSwitch");
const _component_VChip = _resolveComponent$4("VChip");
const _component_VBtn = _resolveComponent$4("VBtn");
const _component_VTable = _resolveComponent$4("VTable");
const _component_VSheet = _resolveComponent$4("VSheet");
return (_openBlock$4(), _createBlock$4(_component_VSheet, {
border: "",
rounded: "",
class: "provider-table-shell"
}, {
default: _withCtx$4(() => [
_createVNode$4(_component_VTable, { density: "comfortable" }, {
default: _withCtx$4(() => [
_createElementVNode$3("thead", null, [
_createElementVNode$3("tr", null, [
_cache[0] || (_cache[0] = _createElementVNode$3("th", null, "启用", -1)),
_cache[1] || (_cache[1] = _createElementVNode$3("th", null, "优先级", -1)),
_cache[2] || (_cache[2] = _createElementVNode$3("th", null, "名称", -1)),
_cache[3] || (_cache[3] = _createElementVNode$3("th", null, "类型", -1)),
(__props.showCredentials)
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_1$3, "地址"))
: _createCommentVNode$2("", true),
(__props.showCredentials)
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_2$3, "Key"))
: _createCommentVNode$2("", true),
_cache[4] || (_cache[4] = _createElementVNode$3("th", null, "代理", -1)),
_cache[5] || (_cache[5] = _createElementVNode$3("th", null, "模型", -1)),
_cache[6] || (_cache[6] = _createElementVNode$3("th", null, "额度", -1)),
_cache[7] || (_cache[7] = _createElementVNode$3("th", { class: "text-right" }, "操作", -1))
])
]),
_createElementVNode$3("tbody", null, [
(_openBlock$4(true), _createElementBlock$2(_Fragment$1, null, _renderList$1(__props.providers, (row, index) => {
return (_openBlock$4(), _createElementBlock$2("tr", {
key: row.id || index
}, [
_createElementVNode$3("td", null, [
_createVNode$4(_component_VSwitch, {
modelValue: row.enabled,
"onUpdate:modelValue": $event => ((row.enabled) = $event),
color: "primary",
"hide-details": "",
density: "compact"
}, null, 8, ["modelValue", "onUpdate:modelValue"])
]),
_createElementVNode$3("td", null, _toDisplayString$4(row.priority), 1),
_createElementVNode$3("td", null, _toDisplayString$4(row.name), 1),
_createElementVNode$3("td", null, _toDisplayString$4(row.provider), 1),
(__props.showCredentials)
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_3$3, _toDisplayString$4(row.base_url), 1))
: _createCommentVNode$2("", true),
(__props.showCredentials)
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_4$2, _toDisplayString$4(getMaskedApiKey(index)), 1))
: _createCommentVNode$2("", true),
_createElementVNode$3("td", null, [
_createVNode$4(_component_VChip, {
size: "small",
color: row.use_proxy === false ? 'default' : 'primary',
variant: "tonal"
}, {
default: _withCtx$4(() => [
_createTextVNode$4(_toDisplayString$4(row.use_proxy === false ? '直连' : '代理'), 1)
]),
_: 2
}, 1032, ["color"])
]),
_createElementVNode$3("td", null, _toDisplayString$4(row.model), 1),
_createElementVNode$3("td", null, _toDisplayString$4(row.token_limit > 0 ? _unref$4(formatTokens)(row.token_limit) : '不限'), 1),
_createElementVNode$3("td", _hoisted_5$2, [
_createVNode$4(_component_VBtn, {
icon: "mdi-pencil",
size: "small",
variant: "text",
onClick: $event => (emit('edit', index))
}, null, 8, ["onClick"]),
_createVNode$4(_component_VBtn, {
icon: "mdi-delete",
size: "small",
variant: "text",
color: "error",
onClick: $event => (emit('remove', index))
}, null, 8, ["onClick"])
])
]))
}), 128)),
(!__props.providers.length)
? (_openBlock$4(), _createElementBlock$2("tr", _hoisted_6$2, [
_createElementVNode$3("td", {
colspan: __props.showCredentials ? 10 : 8,
class: "text-center text-medium-emphasis py-8"
}, "暂无供应商", 8, _hoisted_7$2)
]))
: _createCommentVNode$2("", true)
])
]),
_: 1
})
]),
_: 1
}))
}
}
};
const ProviderConfigTable = /*#__PURE__*/_export_sfc(_sfc_main$4, [['__scopeId',"data-v-cd4337d8"]]);
const {toDisplayString:_toDisplayString$3,createTextVNode:_createTextVNode$3,resolveComponent:_resolveComponent$3,withCtx:_withCtx$3,createVNode:_createVNode$3,unref:_unref$3,openBlock:_openBlock$3,createBlock:_createBlock$3} = await importShared('vue');
const {computed: computed$2} = await importShared('vue');
const _sfc_main$3 = {
__name: 'ProviderEditorDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
provider: {
type: Object,
default: () => ({}),
},
editorIndex: {
type: Number,
default: -1,
},
},
emits: ['update:modelValue', 'commit'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
const dialogVisible = computed$2({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
// 提交当前弹窗编辑的供应商配置。
function commitProvider() {
emit('commit');
}
return (_ctx, _cache) => {
const _component_VCardTitle = _resolveComponent$3("VCardTitle");
const _component_VTextField = _resolveComponent$3("VTextField");
const _component_VCol = _resolveComponent$3("VCol");
const _component_VSelect = _resolveComponent$3("VSelect");
const _component_VSwitch = _resolveComponent$3("VSwitch");
const _component_VRow = _resolveComponent$3("VRow");
const _component_VCardText = _resolveComponent$3("VCardText");
const _component_VSpacer = _resolveComponent$3("VSpacer");
const _component_VBtn = _resolveComponent$3("VBtn");
const _component_VCardActions = _resolveComponent$3("VCardActions");
const _component_VCard = _resolveComponent$3("VCard");
const _component_VDialog = _resolveComponent$3("VDialog");
return (_openBlock$3(), _createBlock$3(_component_VDialog, {
modelValue: dialogVisible.value,
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((dialogVisible).value = $event)),
"max-width": "760",
"max-height": "85vh",
scrollable: ""
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VCard, null, {
default: _withCtx$3(() => [
_createVNode$3(_component_VCardTitle, null, {
default: _withCtx$3(() => [
_createTextVNode$3(_toDisplayString$3(__props.editorIndex >= 0 ? '编辑供应商' : '新增供应商'), 1)
]),
_: 1
}),
_createVNode$3(_component_VCardText, null, {
default: _withCtx$3(() => [
_createVNode$3(_component_VRow, null, {
default: _withCtx$3(() => [
_createVNode$3(_component_VCol, {
cols: "12",
md: "8"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.name,
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((__props.provider.name) = $event)),
label: "名称",
variant: "outlined",
density: "comfortable"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, {
cols: "12",
md: "4"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.priority,
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((__props.provider.priority) = $event)),
modelModifiers: { number: true },
label: "优先级",
type: "number",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VSelect, {
modelValue: __props.provider.provider,
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((__props.provider.provider) = $event)),
items: _unref$3(PROVIDER_TYPE_OPTIONS),
label: "类型",
variant: "outlined"
}, null, 8, ["modelValue", "items"])
]),
_: 1
}),
_createVNode$3(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.model,
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((__props.provider.model) = $event)),
label: "模型",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, { cols: "12" }, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.base_url,
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((__props.provider.base_url) = $event)),
label: "API 地址",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, { cols: "12" }, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.api_key,
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((__props.provider.api_key) = $event)),
label: "API Key",
type: "password",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, { cols: "12" }, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.user_agent,
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((__props.provider.user_agent) = $event)),
label: "User-Agent",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, { cols: "12" }, {
default: _withCtx$3(() => [
_createVNode$3(_component_VSwitch, {
modelValue: __props.provider.use_proxy,
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((__props.provider.use_proxy) = $event)),
color: "primary",
label: "使用代理服务器",
hint: "启用后Agent 连接该供应商时会使用系统代理服务器",
"persistent-hint": ""
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.token_limit,
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((__props.provider.token_limit) = $event)),
modelModifiers: { number: true },
label: "Token 额度",
type: "number",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode$3(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.used_tokens,
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((__props.provider.used_tokens) = $event)),
modelModifiers: { number: true },
label: "初始已用",
type: "number",
variant: "outlined"
}, null, 8, ["modelValue"])
]),
_: 1
})
]),
_: 1
})
]),
_: 1
}),
_createVNode$3(_component_VCardActions, null, {
default: _withCtx$3(() => [
_createVNode$3(_component_VSpacer),
_createVNode$3(_component_VBtn, {
variant: "text",
onClick: _cache[10] || (_cache[10] = $event => (dialogVisible.value = false))
}, {
default: _withCtx$3(() => [...(_cache[12] || (_cache[12] = [
_createTextVNode$3("取消", -1)
]))]),
_: 1
}),
_createVNode$3(_component_VBtn, {
color: "primary",
onClick: commitProvider
}, {
default: _withCtx$3(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode$3("确定", -1)
]))]),
_: 1
})
]),
_: 1
})
]),
_: 1
})
]),
_: 1
}, 8, ["modelValue"]))
}
}
};
const {createElementVNode:_createElementVNode$2,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock$2,createElementBlock:_createElementBlock$1,toDisplayString:_toDisplayString$2,unref:_unref$2,resolveComponent:_resolveComponent$2,createVNode:_createVNode$2,createTextVNode:_createTextVNode$2,withCtx:_withCtx$2,createCommentVNode:_createCommentVNode$1,createBlock:_createBlock$2} = await importShared('vue');
const _hoisted_1$2 = { class: "progress-cell" };
const _hoisted_2$2 = { class: "text-right" };
const _hoisted_3$2 = { key: 0 };
const _sfc_main$2 = {
__name: 'ProviderUsageTable',
props: {
providerRows: {
type: Array,
default: () => [],
},
},
emits: ['reset'],
setup(__props, { emit: __emit }) {
const emit = __emit;
// 根据供应商状态返回 Vuetify 颜色。
function rowStatusColor(row) {
if (!row.enabled) return 'default'
if (row.usage?.exhausted) return 'error'
if (!row.api_key || !row.base_url || !row.model) return 'warning'
return 'success'
}
// 根据供应商状态返回短标签。
function rowStatusText(row) {
if (!row.enabled) return '停用'
if (row.usage?.exhausted) return '耗尽'
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
return '可用'
}
return (_ctx, _cache) => {
const _component_VProgressLinear = _resolveComponent$2("VProgressLinear");
const _component_VChip = _resolveComponent$2("VChip");
const _component_VBtn = _resolveComponent$2("VBtn");
const _component_VTable = _resolveComponent$2("VTable");
const _component_VSheet = _resolveComponent$2("VSheet");
return (_openBlock$2(), _createBlock$2(_component_VSheet, {
border: "",
rounded: "",
class: "provider-table-shell"
}, {
default: _withCtx$2(() => [
_createVNode$2(_component_VTable, { density: "comfortable" }, {
default: _withCtx$2(() => [
_cache[1] || (_cache[1] = _createElementVNode$2("thead", null, [
_createElementVNode$2("tr", null, [
_createElementVNode$2("th", null, "优先级"),
_createElementVNode$2("th", null, "名称"),
_createElementVNode$2("th", null, "模型"),
_createElementVNode$2("th", null, "已用"),
_createElementVNode$2("th", null, "余量"),
_createElementVNode$2("th", null, "进度"),
_createElementVNode$2("th", null, "状态"),
_createElementVNode$2("th", { class: "text-right" }, "操作")
])
], -1)),
_createElementVNode$2("tbody", null, [
(_openBlock$2(true), _createElementBlock$1(_Fragment, null, _renderList(__props.providerRows, (row, index) => {
return (_openBlock$2(), _createElementBlock$1("tr", {
key: row.id || index
}, [
_createElementVNode$2("td", null, _toDisplayString$2(row.priority), 1),
_createElementVNode$2("td", null, _toDisplayString$2(row.name), 1),
_createElementVNode$2("td", null, _toDisplayString$2(row.model), 1),
_createElementVNode$2("td", null, _toDisplayString$2(_unref$2(formatTokens)(row.usage?.total_tokens)), 1),
_createElementVNode$2("td", null, _toDisplayString$2(row.usage?.remaining_tokens === null ? '不限' : _unref$2(formatTokens)(row.usage?.remaining_tokens)), 1),
_createElementVNode$2("td", _hoisted_1$2, [
_createVNode$2(_component_VProgressLinear, {
"model-value": row.usage?.usage_percent || 0,
color: rowStatusColor(row),
height: "8",
rounded: ""
}, null, 8, ["model-value", "color"])
]),
_createElementVNode$2("td", null, [
_createVNode$2(_component_VChip, {
size: "small",
color: rowStatusColor(row),
variant: "tonal"
}, {
default: _withCtx$2(() => [
_createTextVNode$2(_toDisplayString$2(rowStatusText(row)), 1)
]),
_: 2
}, 1032, ["color"])
]),
_createElementVNode$2("td", _hoisted_2$2, [
_createVNode$2(_component_VBtn, {
icon: "mdi-backup-restore",
size: "small",
variant: "text",
onClick: $event => (emit('reset', row.id, index))
}, null, 8, ["onClick"])
])
]))
}), 128)),
(!__props.providerRows.length)
? (_openBlock$2(), _createElementBlock$1("tr", _hoisted_3$2, [...(_cache[0] || (_cache[0] = [
_createElementVNode$2("td", {
colspan: "8",
class: "text-center text-medium-emphasis py-8"
}, "暂无供应商", -1)
]))]))
: _createCommentVNode$1("", true)
])
]),
_: 1
})
]),
_: 1
}))
}
}
};
const ProviderUsageTable = /*#__PURE__*/_export_sfc(_sfc_main$2, [['__scopeId',"data-v-a305c97e"]]);
const {toDisplayString:_toDisplayString$1,createElementVNode:_createElementVNode$1,resolveComponent:_resolveComponent$1,withCtx:_withCtx$1,createVNode:_createVNode$1,unref:_unref$1,createTextVNode:_createTextVNode$1,openBlock:_openBlock$1,createBlock:_createBlock$1} = await importShared('vue');
const _hoisted_1$1 = { class: "usage-overview-card__content" };
const _hoisted_2$1 = { class: "usage-overview-card__chart" };
const _hoisted_3$1 = { class: "usage-overview-card__percent" };
const _hoisted_4$1 = { class: "usage-overview-card__body" };
const _hoisted_5$1 = { class: "usage-overview-card__headline" };
const _hoisted_6$1 = { class: "text-medium-emphasis" };
const _hoisted_7$1 = { class: "usage-overview-card__meta" };
const {computed: computed$1} = await importShared('vue');
const _sfc_main$1 = {
__name: 'UsageOverviewCard',
props: {
summary: {
type: Object,
default: () => ({}),
},
},
setup(__props) {
const props = __props;
const totalUsed = computed$1(() => Number(props.summary.total_used || 0));
const totalLimit = computed$1(() => Number(props.summary.total_limit || 0));
const usagePercent = computed$1(() => {
if (totalLimit.value <= 0) return 0
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
});
const usagePercentText = computed$1(() => `${Math.round(usagePercent.value)}%`);
const remainingTokens = computed$1(() => {
if (totalLimit.value <= 0) return null
return Math.max(totalLimit.value - totalUsed.value, 0)
});
const progressColor = computed$1(() => {
if (totalLimit.value <= 0) return 'primary'
if (usagePercent.value >= 90) return 'error'
if (usagePercent.value >= 70) return 'warning'
return 'success'
});
return (_ctx, _cache) => {
const _component_VProgressCircular = _resolveComponent$1("VProgressCircular");
const _component_VProgressLinear = _resolveComponent$1("VProgressLinear");
const _component_VSheet = _resolveComponent$1("VSheet");
return (_openBlock$1(), _createBlock$1(_component_VSheet, {
border: "",
rounded: "",
class: "usage-overview-card"
}, {
default: _withCtx$1(() => [
_createElementVNode$1("div", _hoisted_1$1, [
_createElementVNode$1("div", _hoisted_2$1, [
_createVNode$1(_component_VProgressCircular, {
"model-value": usagePercent.value,
color: progressColor.value,
"bg-color": "surface-variant",
size: 132,
width: 12
}, {
default: _withCtx$1(() => [
_createElementVNode$1("div", _hoisted_3$1, _toDisplayString$1(totalLimit.value > 0 ? usagePercentText.value : '不限'), 1)
]),
_: 1
}, 8, ["model-value", "color"])
]),
_createElementVNode$1("div", _hoisted_4$1, [
_cache[0] || (_cache[0] = _createElementVNode$1("div", { class: "text-caption text-medium-emphasis" }, "总使用进度", -1)),
_createElementVNode$1("div", _hoisted_5$1, [
_createTextVNode$1(_toDisplayString$1(_unref$1(formatTokens)(totalUsed.value)) + " ", 1),
_createElementVNode$1("span", _hoisted_6$1, "/ " + _toDisplayString$1(totalLimit.value > 0 ? _unref$1(formatTokens)(totalLimit.value) : '不限'), 1)
]),
_createVNode$1(_component_VProgressLinear, {
"model-value": usagePercent.value,
color: progressColor.value,
height: "8",
rounded: "",
class: "my-4"
}, null, 8, ["model-value", "color"]),
_createElementVNode$1("div", _hoisted_7$1, [
_createElementVNode$1("span", null, "剩余 " + _toDisplayString$1(remainingTokens.value === null ? '不限' : _unref$1(formatTokens)(remainingTokens.value)), 1),
_createElementVNode$1("span", null, "可用 " + _toDisplayString$1(__props.summary.available_count || 0) + " / " + _toDisplayString$1(__props.summary.enabled_count || 0), 1)
])
])
])
]),
_: 1
}))
}
}
};
const UsageOverviewCard = /*#__PURE__*/_export_sfc(_sfc_main$1, [['__scopeId',"data-v-f9b76345"]]);
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock,unref:_unref} = await importShared('vue');
const _hoisted_1 = { class: "agenttokens-page" };
const _hoisted_2 = {
key: 0,
class: "agenttokens-header"
};
const _hoisted_3 = { class: "agenttokens-control-panel__switches" };
const _hoisted_4 = { class: "agenttokens-overview-grid" };
const _hoisted_5 = { class: "agenttokens-stat-card__value" };
const _hoisted_6 = { class: "agenttokens-stat-card__value" };
const _hoisted_7 = { class: "agenttokens-stat-card__value" };
const _hoisted_8 = { class: "agenttokens-tabs-row" };
const _hoisted_9 = { class: "agenttokens-table-actions" };
const {computed,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AgentTokensManager',
props: {
config: {
type: Object,
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
},
providerRows: {
type: Array,
default: () => [],
},
summary: {
type: Object,
default: () => ({}),
},
error: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
saving: {
type: Boolean,
default: false,
},
hideTitle: {
type: Boolean,
default: false,
},
},
emits: ['refresh', 'save', 'reset-usage', 'reset-all-usage'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
const activeTab = ref('usage');
const showEditor = ref(false);
const editorIndex = ref(-1);
const editedProvider = ref(createProvider());
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] });
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []));
const displayProviderRows = computed(() => (
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
));
const displaySummary = computed(() => (
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
));
// 打开新增供应商弹窗。
function addProvider() {
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) };
editorIndex.value = -1;
showEditor.value = true;
}
// 打开编辑供应商弹窗。
function editProvider(index) {
editedProvider.value = { ...providers.value[index] };
editorIndex.value = index;
showEditor.value = true;
}
// 将弹窗中的供应商写回配置列表。
function commitProvider() {
const nextProviders = [...providers.value];
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1);
if (editorIndex.value >= 0) {
nextProviders.splice(editorIndex.value, 1, normalized);
} else {
nextProviders.push(normalized);
}
configValue.value.providers = nextProviders;
showEditor.value = false;
}
// 从配置列表中移除一个供应商。
function removeProvider(index) {
const nextProviders = [...providers.value];
nextProviders.splice(index, 1);
configValue.value.providers = nextProviders;
}
// 请求重置单个供应商用量。
function resetUsage(providerId, index) {
emit('reset-usage', providerId, index);
}
// 请求重置全部供应商用量。
function resetAllUsage() {
emit('reset-all-usage');
}
return (_ctx, _cache) => {
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VSwitch = _resolveComponent("VSwitch");
const _component_VSheet = _resolveComponent("VSheet");
const _component_VIcon = _resolveComponent("VIcon");
const _component_VTab = _resolveComponent("VTab");
const _component_VTabs = _resolveComponent("VTabs");
const _component_VDivider = _resolveComponent("VDivider");
const _component_VWindowItem = _resolveComponent("VWindowItem");
const _component_VWindow = _resolveComponent("VWindow");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(!__props.hideTitle)
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
_cache[7] || (_cache[7] = _createElementVNode("h2", { class: "text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9" }, [
_createElementVNode("span", { class: "text-moviepilot" }, "Agent Tokens 管理")
], -1)),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "mdi-refresh",
variant: "text",
loading: __props.loading,
onClick: _cache[0] || (_cache[0] = $event => (emit('refresh')))
}, null, 8, ["loading"]),
_createVNode(_component_VBtn, {
icon: "mdi-content-save",
variant: "text",
color: "primary",
loading: __props.saving,
onClick: _cache[1] || (_cache[1] = $event => (emit('save')))
}, null, 8, ["loading"])
]))
: _createCommentVNode("", true),
(__props.error)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "error",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(__props.error), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
_createVNode(_component_VSheet, {
border: "",
rounded: "",
class: "agenttokens-control-panel"
}, {
default: _withCtx(() => [
_createElementVNode("div", _hoisted_3, [
_createVNode(_component_VSwitch, {
modelValue: configValue.value.enabled,
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((configValue.value.enabled) = $event)),
color: "primary",
"hide-details": "",
inset: "",
label: "启用插件"
}, null, 8, ["modelValue"]),
_createVNode(_component_VSwitch, {
modelValue: configValue.value.show_sidebar_nav,
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((configValue.value.show_sidebar_nav) = $event)),
color: "primary",
"hide-details": "",
inset: "",
label: "侧边栏入口"
}, null, 8, ["modelValue"])
])
]),
_: 1
}),
_createElementVNode("div", _hoisted_4, [
_createVNode(UsageOverviewCard, {
class: "agenttokens-overview-card",
summary: displaySummary.value
}, null, 8, ["summary"]),
_createVNode(_component_VSheet, {
border: "",
rounded: "",
class: "agenttokens-stat-card"
}, {
default: _withCtx(() => [
_createVNode(_component_VIcon, {
icon: "mdi-check-decagram-outline",
color: "success"
}),
_createElementVNode("div", null, [
_cache[8] || (_cache[8] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
_createElementVNode("div", _hoisted_5, _toDisplayString(displaySummary.value.available_count || 0) + " / " + _toDisplayString(displaySummary.value.enabled_count || 0), 1)
])
]),
_: 1
}),
_createVNode(_component_VSheet, {
border: "",
rounded: "",
class: "agenttokens-stat-card"
}, {
default: _withCtx(() => [
_createVNode(_component_VIcon, {
icon: "mdi-chart-timeline-variant",
color: "primary"
}),
_createElementVNode("div", null, [
_cache[9] || (_cache[9] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "累计使用", -1)),
_createElementVNode("div", _hoisted_6, _toDisplayString(_unref(formatTokens)(displaySummary.value.total_used)), 1)
])
]),
_: 1
}),
_createVNode(_component_VSheet, {
border: "",
rounded: "",
class: "agenttokens-stat-card"
}, {
default: _withCtx(() => [
_createVNode(_component_VIcon, {
icon: "mdi-database-outline",
color: "info"
}),
_createElementVNode("div", null, [
_cache[10] || (_cache[10] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "总额度", -1)),
_createElementVNode("div", _hoisted_7, _toDisplayString(displaySummary.value.total_limit ? _unref(formatTokens)(displaySummary.value.total_limit) : '不限'), 1)
])
]),
_: 1
})
]),
_createVNode(_component_VSheet, {
border: "",
rounded: "",
class: "agenttokens-content-panel"
}, {
default: _withCtx(() => [
_createElementVNode("div", _hoisted_8, [
_createVNode(_component_VTabs, {
modelValue: activeTab.value,
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((activeTab).value = $event)),
density: "comfortable"
}, {
default: _withCtx(() => [
_createVNode(_component_VTab, { value: "usage" }, {
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
_createTextVNode("用量", -1)
]))]),
_: 1
}),
_createVNode(_component_VTab, { value: "config" }, {
default: _withCtx(() => [...(_cache[12] || (_cache[12] = [
_createTextVNode("配置", -1)
]))]),
_: 1
})
]),
_: 1
}, 8, ["modelValue"])
]),
_createVNode(_component_VDivider),
_createVNode(_component_VWindow, {
modelValue: activeTab.value,
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((activeTab).value = $event)),
touch: false,
class: "agenttokens-window"
}, {
default: _withCtx(() => [
_createVNode(_component_VWindowItem, { value: "usage" }, {
default: _withCtx(() => [
_createVNode(ProviderUsageTable, {
"provider-rows": displayProviderRows.value,
onReset: resetUsage
}, null, 8, ["provider-rows"])
]),
_: 1
}),
_createVNode(_component_VWindowItem, { value: "config" }, {
default: _withCtx(() => [
_createElementVNode("div", _hoisted_9, [
_createVNode(_component_VBtn, {
"prepend-icon": "mdi-plus",
color: "primary",
variant: "tonal",
onClick: addProvider
}, {
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode("新增", -1)
]))]),
_: 1
}),
_createVNode(_component_VBtn, {
"prepend-icon": "mdi-backup-restore",
color: "warning",
variant: "tonal",
onClick: resetAllUsage
}, {
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
_createTextVNode(" 重置用量 ", -1)
]))]),
_: 1
})
]),
_createVNode(ProviderConfigTable, {
providers: providers.value,
"provider-rows": displayProviderRows.value,
"show-credentials": "",
onEdit: editProvider,
onRemove: removeProvider
}, null, 8, ["providers", "provider-rows"])
]),
_: 1
})
]),
_: 1
}, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_sfc_main$3, {
modelValue: showEditor.value,
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((showEditor).value = $event)),
provider: editedProvider.value,
"editor-index": editorIndex.value,
onCommit: commitProvider
}, null, 8, ["modelValue", "provider", "editor-index"])
]))
}
}
};
const AgentTokensManager = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-a6c1ea54"]]);
export { AgentTokensManager as A };

View File

@@ -0,0 +1,130 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.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');
const {computed,onMounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AppPage',
props: {
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'AgentTokens',
},
hideTitle: {
type: Boolean,
default: false,
},
},
setup(__props, { expose: __expose }) {
const props = __props;
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const status = ref({
config: { enabled: false, show_sidebar_nav: true, providers: [] },
providers: [],
summary: {},
});
// 构造 API 基础路径。
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`);
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] });
const providerRows = computed(() => status.value.providers || []);
const summary = computed(() => status.value.summary || {});
// 从插件 API 拉取当前配置和用量状态。
async function loadStatus() {
loading.value = true;
error.value = '';
try {
const response = await props.api.get(`${pluginBase.value}/status`);
status.value = unwrapResponse(response) || status.value;
} catch (err) {
error.value = err?.message || '加载失败';
} finally {
loading.value = false;
}
}
// 保存完整插件配置并刷新服务端标准化后的状态。
async function saveConfig() {
saving.value = true;
error.value = '';
try {
const payload = {
enabled: Boolean(config.value.enabled),
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
providers: [...(config.value.providers || [])],
};
const response = await props.api.post(`${pluginBase.value}/config`, payload);
status.value = unwrapResponse(response) || status.value;
} catch (err) {
error.value = err?.message || '保存失败';
} finally {
saving.value = false;
}
}
// 重置指定供应商的运行记录。
async function resetUsage(providerId) {
if (!providerId) return
loading.value = true;
try {
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId });
status.value = unwrapResponse(response) || status.value;
} finally {
loading.value = false;
}
}
// 重置全部供应商的运行记录。
async function resetAllUsage() {
loading.value = true;
try {
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {});
status.value = unwrapResponse(response) || status.value;
} finally {
loading.value = false;
}
}
__expose({
loadStatus,
saveConfig,
loading,
saving,
});
onMounted(loadStatus);
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(AgentTokensManager, {
config: config.value,
"provider-rows": providerRows.value,
summary: summary.value,
error: error.value,
loading: loading.value,
saving: saving.value,
"hide-title": __props.hideTitle,
onRefresh: loadStatus,
onSave: saveConfig,
onResetUsage: resetUsage,
onResetAllUsage: resetAllUsage
}, null, 8, ["config", "provider-rows", "summary", "error", "loading", "saving", "hide-title"]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,103 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.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');
const _hoisted_1 = { class: "agenttokens-config" };
const {onMounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'Config',
props: {
initialConfig: {
type: Object,
default: () => ({}),
},
},
emits: ['save', 'close'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] });
// 重置本地配置中的单个供应商用量。
function resetUsage(providerId, index) {
const providers = localConfig.value.providers || [];
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId);
const targetIndex = providerIndex >= 0 ? providerIndex : index;
if (!providers[targetIndex]) return
providers[targetIndex].used_tokens = 0;
}
// 重置本地配置中的全部供应商用量。
function resetAllUsage() {
(localConfig.value.providers || []).forEach(provider => {
provider.used_tokens = 0;
});
}
// 通知宿主保存 Vue 配置。
function saveConfig() {
emit('save', cloneConfig(localConfig.value));
}
onMounted(() => {
localConfig.value = cloneConfig(props.initialConfig);
if (localConfig.value.show_sidebar_nav === undefined) {
localConfig.value.show_sidebar_nav = true;
}
if (!Array.isArray(localConfig.value.providers)) {
localConfig.value.providers = [];
}
});
return (_ctx, _cache) => {
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VToolbar = _resolveComponent("VToolbar");
const _component_VDivider = _resolveComponent("VDivider");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createVNode(_component_VToolbar, {
density: "comfortable",
color: "transparent"
}, {
default: _withCtx(() => [
_cache[1] || (_cache[1] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 配置", -1)),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "mdi-content-save",
variant: "text",
color: "primary",
onClick: saveConfig
}),
_createVNode(_component_VBtn, {
icon: "mdi-close",
variant: "text",
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
})
]),
_: 1
}),
_createVNode(_component_VDivider),
_createVNode(AgentTokensManager, {
config: localConfig.value,
"hide-title": "",
onSave: saveConfig,
onResetUsage: resetUsage,
onResetAllUsage: resetAllUsage
}, null, 8, ["config"])
]))
}
}
};
export { _sfc_main as default };

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

@@ -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

@@ -0,0 +1,79 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.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');
const _hoisted_1 = { class: "agenttokens-page-wrapper" };
const {ref} = await importShared('vue');
const _sfc_main = {
__name: 'Page',
props: {
api: {
type: Object,
default: () => ({}),
},
},
emits: ['close'],
setup(__props, { emit: __emit }) {
const emit = __emit;
const pageRef = ref(null);
return (_ctx, _cache) => {
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VToolbar = _resolveComponent("VToolbar");
const _component_VDivider = _resolveComponent("VDivider");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createVNode(_component_VToolbar, {
density: "comfortable",
class: "sticky-toolbar"
}, {
default: _withCtx(() => [
_cache[3] || (_cache[3] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 管理", -1)),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "mdi-refresh",
variant: "text",
loading: pageRef.value?.loading,
onClick: _cache[0] || (_cache[0] = $event => (pageRef.value?.loadStatus()))
}, null, 8, ["loading"]),
_createVNode(_component_VBtn, {
icon: "mdi-content-save",
variant: "text",
color: "primary",
loading: pageRef.value?.saving,
onClick: _cache[1] || (_cache[1] = $event => (pageRef.value?.saveConfig()))
}, null, 8, ["loading"]),
_createVNode(_component_VBtn, {
icon: "mdi-close",
variant: "text",
onClick: _cache[2] || (_cache[2] = $event => (emit('close')))
})
]),
_: 1
}),
_createVNode(_component_VDivider),
_createVNode(_sfc_main$1, {
ref_key: "pageRef",
ref: pageRef,
api: __props.api,
"plugin-id": "AgentTokens",
"hide-title": ""
}, null, 8, ["api"])
]))
}
}
};
const Page = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-2f12fb0f"]]);
export { Page as default };

View File

@@ -0,0 +1,7 @@
.sticky-toolbar[data-v-2f12fb0f] {
position: sticky;
top: 0;
z-index: 10;
background: rgb(var(--v-theme-surface));
}

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,112 @@
const PROVIDER_TYPE_OPTIONS = [
{ title: 'OpenAI Compatible', value: 'openai' },
{ title: 'DeepSeek', value: 'deepseek' },
{ title: 'Google Gemini', value: 'google' },
{ title: 'Anthropic Compatible', value: 'anthropic' },
{ title: 'ChatGPT', value: 'chatgpt' },
];
// 构建一个新的供应商默认配置。
function createProvider() {
return {
id: '',
enabled: true,
name: '',
provider: 'openai',
base_url: '',
api_key: '',
user_agent: '',
use_proxy: true,
model: '',
token_limit: 0,
used_tokens: 0,
priority: 1,
}
}
// 生成深拷贝配置,避免直接修改父组件传入对象。
function cloneConfig(config) {
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
}
// 格式化 token 数字,保持表格和统计展示可读。
function formatTokens(value) {
const numberValue = Number(value || 0);
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
}
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
function unwrapResponse(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
return response.data
}
return response?.data ?? response
}
// 计算新增供应商的下一个优先级。
function getNextProviderPriority(providers) {
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
}
// 标准化弹窗中写回的供应商数值字段。
function normalizeProvider(provider, fallbackPriority) {
return {
...provider,
use_proxy: provider.use_proxy !== false,
token_limit: Number(provider.token_limit || 0),
used_tokens: Number(provider.used_tokens || 0),
priority: Number(provider.priority || fallbackPriority),
}
}
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
function buildProviderRow(provider) {
const tokenLimit = Number(provider.token_limit || 0);
const totalTokens = Number(provider.used_tokens || 0);
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0);
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100);
return {
...provider,
masked_api_key: provider.api_key ? '****' : '',
usage: {
total_tokens: totalTokens,
remaining_tokens: remainingTokens,
usage_percent: usagePercent,
exhausted: tokenLimit > 0 && remainingTokens === 0,
},
}
}
// 批量生成本地供应商用量行。
function buildProviderRows(providers) {
return (providers || []).map(provider => buildProviderRow(provider))
}
// 根据供应商行汇总用量统计。
function buildProviderSummary(rows) {
const providers = rows || [];
const enabledRows = providers.filter(row => row.enabled);
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0);
const totalLimit = providers.reduce((sum, row) => {
const tokenLimit = Number(row.token_limit || 0);
return tokenLimit > 0 ? sum + tokenLimit : sum
}, 0);
return {
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
enabled_count: enabledRows.length,
total_used: totalUsed,
total_limit: totalLimit,
}
}
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

@@ -0,0 +1,44 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_AppPage-EV4Kchio.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,90 @@
const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Page-vwwFlnk-.css","AgentTokensManager-9miSzH4d.css"], false, './Page');
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-CpvEDTaR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Dashboard":()=>{
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-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;
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__AgentTokens__' + 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,7 @@
<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/_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

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

View File

@@ -0,0 +1,18 @@
{
"name": "moviepilot-agenttokens-plugin",
"private": true,
"version": "1.0.12",
"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

@@ -0,0 +1,299 @@
<script setup>
import { computed, ref } from 'vue'
import ProviderConfigTable from './ProviderConfigTable.vue'
import ProviderEditorDialog from './ProviderEditorDialog.vue'
import ProviderUsageTable from './ProviderUsageTable.vue'
import UsageOverviewCard from './UsageOverviewCard.vue'
import {
buildProviderRows,
buildProviderSummary,
createProvider,
formatTokens,
getNextProviderPriority,
normalizeProvider,
} from '../provider'
const props = defineProps({
config: {
type: Object,
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
},
providerRows: {
type: Array,
default: () => [],
},
summary: {
type: Object,
default: () => ({}),
},
error: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
saving: {
type: Boolean,
default: false,
},
hideTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['refresh', 'save', 'reset-usage', 'reset-all-usage'])
const activeTab = ref('usage')
const showEditor = ref(false)
const editorIndex = ref(-1)
const editedProvider = ref(createProvider())
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] })
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []))
const displayProviderRows = computed(() => (
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
))
const displaySummary = computed(() => (
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
))
// 打开新增供应商弹窗。
function addProvider() {
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) }
editorIndex.value = -1
showEditor.value = true
}
// 打开编辑供应商弹窗。
function editProvider(index) {
editedProvider.value = { ...providers.value[index] }
editorIndex.value = index
showEditor.value = true
}
// 将弹窗中的供应商写回配置列表。
function commitProvider() {
const nextProviders = [...providers.value]
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1)
if (editorIndex.value >= 0) {
nextProviders.splice(editorIndex.value, 1, normalized)
} else {
nextProviders.push(normalized)
}
configValue.value.providers = nextProviders
showEditor.value = false
}
// 从配置列表中移除一个供应商。
function removeProvider(index) {
const nextProviders = [...providers.value]
nextProviders.splice(index, 1)
configValue.value.providers = nextProviders
}
// 请求重置单个供应商用量。
function resetUsage(providerId, index) {
emit('reset-usage', providerId, index)
}
// 请求重置全部供应商用量。
function resetAllUsage() {
emit('reset-all-usage')
}
</script>
<template>
<div class="agenttokens-page">
<div v-if="!hideTitle" class="agenttokens-header">
<h2 class="text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9">
<span class="text-moviepilot">Agent Tokens 管理</span>
</h2>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" :loading="loading" @click="emit('refresh')" />
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="saving" @click="emit('save')" />
</div>
<VAlert v-if="error" type="error" variant="tonal" class="mb-4">{{ error }}</VAlert>
<VSheet border rounded class="agenttokens-control-panel">
<div class="agenttokens-control-panel__switches">
<VSwitch v-model="configValue.enabled" color="primary" hide-details inset label="启用插件" />
<VSwitch v-model="configValue.show_sidebar_nav" color="primary" hide-details inset label="侧边栏入口" />
</div>
</VSheet>
<div class="agenttokens-overview-grid">
<UsageOverviewCard class="agenttokens-overview-card" :summary="displaySummary" />
<VSheet border rounded class="agenttokens-stat-card">
<VIcon icon="mdi-check-decagram-outline" color="success" />
<div>
<div class="text-caption text-medium-emphasis">可用供应商</div>
<div class="agenttokens-stat-card__value">
{{ displaySummary.available_count || 0 }} / {{ displaySummary.enabled_count || 0 }}
</div>
</div>
</VSheet>
<VSheet border rounded class="agenttokens-stat-card">
<VIcon icon="mdi-chart-timeline-variant" color="primary" />
<div>
<div class="text-caption text-medium-emphasis">累计使用</div>
<div class="agenttokens-stat-card__value">{{ formatTokens(displaySummary.total_used) }}</div>
</div>
</VSheet>
<VSheet border rounded class="agenttokens-stat-card">
<VIcon icon="mdi-database-outline" color="info" />
<div>
<div class="text-caption text-medium-emphasis">总额度</div>
<div class="agenttokens-stat-card__value">
{{ displaySummary.total_limit ? formatTokens(displaySummary.total_limit) : '不限' }}
</div>
</div>
</VSheet>
</div>
<VSheet border rounded class="agenttokens-content-panel">
<div class="agenttokens-tabs-row">
<VTabs v-model="activeTab" density="comfortable">
<VTab value="usage">用量</VTab>
<VTab value="config">配置</VTab>
</VTabs>
</div>
<VDivider />
<VWindow v-model="activeTab" :touch="false" class="agenttokens-window">
<VWindowItem value="usage">
<ProviderUsageTable :provider-rows="displayProviderRows" @reset="resetUsage" />
</VWindowItem>
<VWindowItem value="config">
<div class="agenttokens-table-actions">
<VBtn prepend-icon="mdi-plus" color="primary" variant="tonal" @click="addProvider">新增</VBtn>
<VBtn prepend-icon="mdi-backup-restore" color="warning" variant="tonal" @click="resetAllUsage">
重置用量
</VBtn>
</div>
<ProviderConfigTable
:providers="providers"
:provider-rows="displayProviderRows"
show-credentials
@edit="editProvider"
@remove="removeProvider"
/>
</VWindowItem>
</VWindow>
</VSheet>
<ProviderEditorDialog
v-model="showEditor"
:provider="editedProvider"
:editor-index="editorIndex"
@commit="commitProvider"
/>
</div>
</template>
<style scoped>
.agenttokens-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.agenttokens-header {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.agenttokens-control-panel {
display: flex;
align-items: center;
padding: 12px 16px;
}
.agenttokens-control-panel__switches {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.agenttokens-overview-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
gap: 12px;
}
.agenttokens-overview-card {
min-block-size: 172px;
}
.agenttokens-stat-card {
display: flex;
align-items: center;
gap: 12px;
min-block-size: 104px;
padding: 16px;
}
.agenttokens-stat-card__value {
margin-block-start: 2px;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
.agenttokens-content-panel {
overflow: hidden;
}
.agenttokens-tabs-row {
padding-inline: 8px;
}
.agenttokens-window {
padding: 12px;
}
.agenttokens-table-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-block-end: 12px;
}
@media (max-width: 1100px) {
.agenttokens-overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.agenttokens-overview-card {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.agenttokens-page {
padding: 12px;
}
.agenttokens-table-actions > :deep(.v-btn) {
flex: 1 1 10rem;
}
.agenttokens-overview-grid {
grid-template-columns: 1fr;
}
.agenttokens-stat-card {
min-block-size: 88px;
}
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import AgentTokensManager from './AgentTokensManager.vue'
import { unwrapResponse } from '../provider'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'AgentTokens',
},
hideTitle: {
type: Boolean,
default: false,
},
})
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const status = ref({
config: { enabled: false, show_sidebar_nav: true, providers: [] },
providers: [],
summary: {},
})
// 构造 API 基础路径。
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`)
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] })
const providerRows = computed(() => status.value.providers || [])
const summary = computed(() => status.value.summary || {})
// 从插件 API 拉取当前配置和用量状态。
async function loadStatus() {
loading.value = true
error.value = ''
try {
const response = await props.api.get(`${pluginBase.value}/status`)
status.value = unwrapResponse(response) || status.value
} catch (err) {
error.value = err?.message || '加载失败'
} finally {
loading.value = false
}
}
// 保存完整插件配置并刷新服务端标准化后的状态。
async function saveConfig() {
saving.value = true
error.value = ''
try {
const payload = {
enabled: Boolean(config.value.enabled),
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
providers: [...(config.value.providers || [])],
}
const response = await props.api.post(`${pluginBase.value}/config`, payload)
status.value = unwrapResponse(response) || status.value
} catch (err) {
error.value = err?.message || '保存失败'
} finally {
saving.value = false
}
}
// 重置指定供应商的运行记录。
async function resetUsage(providerId) {
if (!providerId) return
loading.value = true
try {
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId })
status.value = unwrapResponse(response) || status.value
} finally {
loading.value = false
}
}
// 重置全部供应商的运行记录。
async function resetAllUsage() {
loading.value = true
try {
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {})
status.value = unwrapResponse(response) || status.value
} finally {
loading.value = false
}
}
defineExpose({
loadStatus,
saveConfig,
loading,
saving,
})
onMounted(loadStatus)
</script>
<template>
<AgentTokensManager
:config="config"
:provider-rows="providerRows"
:summary="summary"
:error="error"
:loading="loading"
:saving="saving"
:hide-title="hideTitle"
@refresh="loadStatus"
@save="saveConfig"
@reset-usage="resetUsage"
@reset-all-usage="resetAllUsage"
/>
</template>

View File

@@ -0,0 +1,67 @@
<script setup>
import { onMounted, ref } from 'vue'
import AgentTokensManager from './AgentTokensManager.vue'
import { cloneConfig } from '../provider'
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['save', 'close'])
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] })
// 重置本地配置中的单个供应商用量。
function resetUsage(providerId, index) {
const providers = localConfig.value.providers || []
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId)
const targetIndex = providerIndex >= 0 ? providerIndex : index
if (!providers[targetIndex]) return
providers[targetIndex].used_tokens = 0
}
// 重置本地配置中的全部供应商用量。
function resetAllUsage() {
;(localConfig.value.providers || []).forEach(provider => {
provider.used_tokens = 0
})
}
// 通知宿主保存 Vue 配置。
function saveConfig() {
emit('save', cloneConfig(localConfig.value))
}
onMounted(() => {
localConfig.value = cloneConfig(props.initialConfig)
if (localConfig.value.show_sidebar_nav === undefined) {
localConfig.value.show_sidebar_nav = true
}
if (!Array.isArray(localConfig.value.providers)) {
localConfig.value.providers = []
}
})
</script>
<template>
<div class="agenttokens-config">
<VToolbar density="comfortable" color="transparent">
<div class="text-h6 ms-3">Agent Tokens 配置</div>
<VSpacer />
<VBtn icon="mdi-content-save" variant="text" color="primary" @click="saveConfig" />
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
</VToolbar>
<VDivider />
<AgentTokensManager
:config="localConfig"
hide-title
@save="saveConfig"
@reset-usage="resetUsage"
@reset-all-usage="resetAllUsage"
/>
</div>
</template>

View File

@@ -0,0 +1,530 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { formatTokens, unwrapResponse } from '../provider'
const props = defineProps({
api: {
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.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()
})
</script>
<template>
<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>
<VAvatar color="primary" variant="tonal" size="36">
<VIcon icon="mdi-key-chain" size="20" />
</VAvatar>
</template>
<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

@@ -0,0 +1,38 @@
<script setup>
import { ref } from 'vue'
import AppPage from './AppPage.vue'
defineProps({
api: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['close'])
const pageRef = ref(null)
</script>
<template>
<div class="agenttokens-page-wrapper">
<VToolbar density="comfortable" class="sticky-toolbar">
<div class="text-h6 ms-3">Agent Tokens 管理</div>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" :loading="pageRef?.loading" @click="pageRef?.loadStatus()" />
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="pageRef?.saving" @click="pageRef?.saveConfig()" />
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
</VToolbar>
<VDivider />
<AppPage ref="pageRef" :api="api" plugin-id="AgentTokens" hide-title />
</div>
</template>
<style scoped>
.sticky-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: rgb(var(--v-theme-surface));
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
import { formatTokens } from '../provider'
const props = defineProps({
providers: {
type: Array,
default: () => [],
},
providerRows: {
type: Array,
default: () => [],
},
showCredentials: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['edit', 'remove'])
// 获取管理页服务端返回的脱敏 Key。
function getMaskedApiKey(index) {
return props.providerRows[index]?.masked_api_key || '****'
}
</script>
<template>
<VSheet border rounded class="provider-table-shell">
<VTable density="comfortable">
<thead>
<tr>
<th>启用</th>
<th>优先级</th>
<th>名称</th>
<th>类型</th>
<th v-if="showCredentials">地址</th>
<th v-if="showCredentials">Key</th>
<th>代理</th>
<th>模型</th>
<th>额度</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in providers" :key="row.id || index">
<td>
<VSwitch v-model="row.enabled" color="primary" hide-details density="compact" />
</td>
<td>{{ row.priority }}</td>
<td>{{ row.name }}</td>
<td>{{ row.provider }}</td>
<td v-if="showCredentials" class="truncate-cell">{{ row.base_url }}</td>
<td v-if="showCredentials">{{ getMaskedApiKey(index) }}</td>
<td>
<VChip size="small" :color="row.use_proxy === false ? 'default' : 'primary'" variant="tonal">
{{ row.use_proxy === false ? '直连' : '代理' }}
</VChip>
</td>
<td>{{ row.model }}</td>
<td>{{ row.token_limit > 0 ? formatTokens(row.token_limit) : '不限' }}</td>
<td class="text-right">
<VBtn icon="mdi-pencil" size="small" variant="text" @click="emit('edit', index)" />
<VBtn icon="mdi-delete" size="small" variant="text" color="error" @click="emit('remove', index)" />
</td>
</tr>
<tr v-if="!providers.length">
<td :colspan="showCredentials ? 10 : 8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
</tr>
</tbody>
</VTable>
</VSheet>
</template>
<style scoped>
.provider-table-shell {
overflow-x: auto;
}
.provider-table-shell :deep(table) {
min-width: 960px;
}
.truncate-cell {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup>
import { computed } from 'vue'
import { PROVIDER_TYPE_OPTIONS } from '../provider'
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
provider: {
type: Object,
default: () => ({}),
},
editorIndex: {
type: Number,
default: -1,
},
})
const emit = defineEmits(['update:modelValue', 'commit'])
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// 提交当前弹窗编辑的供应商配置。
function commitProvider() {
emit('commit')
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="760" max-height="85vh" scrollable>
<VCard>
<VCardTitle>{{ editorIndex >= 0 ? '编辑供应商' : '新增供应商' }}</VCardTitle>
<VCardText>
<VRow>
<VCol cols="12" md="8">
<VTextField v-model="provider.name" label="名称" variant="outlined" density="comfortable" />
</VCol>
<VCol cols="12" md="4">
<VTextField v-model.number="provider.priority" label="优先级" type="number" variant="outlined" />
</VCol>
<VCol cols="12" md="6">
<VSelect v-model="provider.provider" :items="PROVIDER_TYPE_OPTIONS" label="类型" variant="outlined" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="provider.model" label="模型" variant="outlined" />
</VCol>
<VCol cols="12">
<VTextField v-model="provider.base_url" label="API 地址" variant="outlined" />
</VCol>
<VCol cols="12">
<VTextField v-model="provider.api_key" label="API Key" type="password" variant="outlined" />
</VCol>
<VCol cols="12">
<VTextField v-model="provider.user_agent" label="User-Agent" variant="outlined" />
</VCol>
<VCol cols="12">
<VSwitch
v-model="provider.use_proxy"
color="primary"
label="使用代理服务器"
hint="启用后Agent 连接该供应商时会使用系统代理服务器"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model.number="provider.token_limit" label="Token 额度" type="number" variant="outlined" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model.number="provider.used_tokens" label="初始已用" type="number" variant="outlined" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="text" @click="dialogVisible = false">取消</VBtn>
<VBtn color="primary" @click="commitProvider">确定</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,89 @@
<script setup>
import { formatTokens } from '../provider'
defineProps({
providerRows: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['reset'])
// 根据供应商状态返回 Vuetify 颜色。
function rowStatusColor(row) {
if (!row.enabled) return 'default'
if (row.usage?.exhausted) return 'error'
if (!row.api_key || !row.base_url || !row.model) return 'warning'
return 'success'
}
// 根据供应商状态返回短标签。
function rowStatusText(row) {
if (!row.enabled) return '停用'
if (row.usage?.exhausted) return '耗尽'
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
return '可用'
}
</script>
<template>
<VSheet border rounded class="provider-table-shell">
<VTable density="comfortable">
<thead>
<tr>
<th>优先级</th>
<th>名称</th>
<th>模型</th>
<th>已用</th>
<th>余量</th>
<th>进度</th>
<th>状态</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in providerRows" :key="row.id || index">
<td>{{ row.priority }}</td>
<td>{{ row.name }}</td>
<td>{{ row.model }}</td>
<td>{{ formatTokens(row.usage?.total_tokens) }}</td>
<td>
{{ row.usage?.remaining_tokens === null ? '不限' : formatTokens(row.usage?.remaining_tokens) }}
</td>
<td class="progress-cell">
<VProgressLinear
:model-value="row.usage?.usage_percent || 0"
:color="rowStatusColor(row)"
height="8"
rounded
/>
</td>
<td>
<VChip size="small" :color="rowStatusColor(row)" variant="tonal">{{ rowStatusText(row) }}</VChip>
</td>
<td class="text-right">
<VBtn icon="mdi-backup-restore" size="small" variant="text" @click="emit('reset', row.id, index)" />
</td>
</tr>
<tr v-if="!providerRows.length">
<td colspan="8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
</tr>
</tbody>
</VTable>
</VSheet>
</template>
<style scoped>
.provider-table-shell {
overflow-x: auto;
}
.provider-table-shell :deep(table) {
min-width: 760px;
}
.progress-cell {
min-width: 140px;
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed } from 'vue'
import { formatTokens } from '../provider'
const props = defineProps({
summary: {
type: Object,
default: () => ({}),
},
})
const totalUsed = computed(() => Number(props.summary.total_used || 0))
const totalLimit = computed(() => Number(props.summary.total_limit || 0))
const usagePercent = computed(() => {
if (totalLimit.value <= 0) return 0
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
})
const usagePercentText = computed(() => `${Math.round(usagePercent.value)}%`)
const remainingTokens = computed(() => {
if (totalLimit.value <= 0) return null
return Math.max(totalLimit.value - totalUsed.value, 0)
})
const progressColor = computed(() => {
if (totalLimit.value <= 0) return 'primary'
if (usagePercent.value >= 90) return 'error'
if (usagePercent.value >= 70) return 'warning'
return 'success'
})
</script>
<template>
<VSheet border rounded class="usage-overview-card">
<div class="usage-overview-card__content">
<div class="usage-overview-card__chart">
<VProgressCircular
:model-value="usagePercent"
:color="progressColor"
bg-color="surface-variant"
:size="132"
:width="12"
>
<div class="usage-overview-card__percent">{{ totalLimit > 0 ? usagePercentText : '不限' }}</div>
</VProgressCircular>
</div>
<div class="usage-overview-card__body">
<div class="text-caption text-medium-emphasis">总使用进度</div>
<div class="usage-overview-card__headline">
{{ formatTokens(totalUsed) }}
<span class="text-medium-emphasis">/ {{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</span>
</div>
<VProgressLinear
:model-value="usagePercent"
:color="progressColor"
height="8"
rounded
class="my-4"
/>
<div class="usage-overview-card__meta">
<span>剩余 {{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</span>
<span>可用 {{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</span>
</div>
</div>
</div>
</VSheet>
</template>
<style scoped>
.usage-overview-card {
block-size: 100%;
padding: 20px;
}
.usage-overview-card__content {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 20px;
}
.usage-overview-card__chart {
display: flex;
justify-content: center;
}
.usage-overview-card__percent {
font-size: 1.35rem;
font-weight: 700;
}
.usage-overview-card__headline {
margin-block-start: 4px;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
.usage-overview-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
}
@media (max-width: 600px) {
.usage-overview-card {
padding: 16px;
}
.usage-overview-card__content {
grid-template-columns: 1fr;
text-align: center;
}
.usage-overview-card__meta {
justify-content: center;
}
}
</style>

View File

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

View File

@@ -0,0 +1,102 @@
export const PROVIDER_TYPE_OPTIONS = [
{ title: 'OpenAI Compatible', value: 'openai' },
{ title: 'DeepSeek', value: 'deepseek' },
{ title: 'Google Gemini', value: 'google' },
{ title: 'Anthropic Compatible', value: 'anthropic' },
{ title: 'ChatGPT', value: 'chatgpt' },
]
// 构建一个新的供应商默认配置。
export function createProvider() {
return {
id: '',
enabled: true,
name: '',
provider: 'openai',
base_url: '',
api_key: '',
user_agent: '',
use_proxy: true,
model: '',
token_limit: 0,
used_tokens: 0,
priority: 1,
}
}
// 生成深拷贝配置,避免直接修改父组件传入对象。
export function cloneConfig(config) {
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
}
// 格式化 token 数字,保持表格和统计展示可读。
export function formatTokens(value) {
const numberValue = Number(value || 0)
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
}
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
export function unwrapResponse(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
return response.data
}
return response?.data ?? response
}
// 计算新增供应商的下一个优先级。
export function getNextProviderPriority(providers) {
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
}
// 标准化弹窗中写回的供应商数值字段。
export function normalizeProvider(provider, fallbackPriority) {
return {
...provider,
use_proxy: provider.use_proxy !== false,
token_limit: Number(provider.token_limit || 0),
used_tokens: Number(provider.used_tokens || 0),
priority: Number(provider.priority || fallbackPriority),
}
}
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
export function buildProviderRow(provider) {
const tokenLimit = Number(provider.token_limit || 0)
const totalTokens = Number(provider.used_tokens || 0)
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0)
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100)
return {
...provider,
masked_api_key: provider.api_key ? '****' : '',
usage: {
total_tokens: totalTokens,
remaining_tokens: remainingTokens,
usage_percent: usagePercent,
exhausted: tokenLimit > 0 && remainingTokens === 0,
},
}
}
// 批量生成本地供应商用量行。
export function buildProviderRows(providers) {
return (providers || []).map(provider => buildProviderRow(provider))
}
// 根据供应商行汇总用量统计。
export function buildProviderSummary(rows) {
const providers = rows || []
const enabledRows = providers.filter(row => row.enabled)
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0)
const totalLimit = providers.reduce((sum, row) => {
const tokenLimit = Number(row.token_limit || 0)
return tokenLimit > 0 ? sum + tokenLimit : sum
}, 0)
return {
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
enabled_count: enabledRows.length,
total_used: totalUsed,
total_limit: totalLimit,
}
}

View File

@@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'AgentTokens',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
},
format: 'esm',
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: true,
},
css: {
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: atRule => {
if (atRule.name === 'charset') {
atRule.remove()
}
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
root.walkRules(rule => {
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
})
},
},
],
},
},
})

View File

@@ -50,6 +50,36 @@
- `request_timeout`
- `max_retries`
- `save_failed_samples`
- `save_title_only_samples`
- `max_failed_samples`
- `auto_remove_applied_sample`
- `clear_failed_samples_once`
## 新增数据面
### 可处理失败样本
用于承载低置信度、可继续分析和出队的样本数据,支持:
- 摘要列表
- 洞察汇总
- 重放复查
- 批量复查
- 批量建议
- 批量写入
- 清空与按索引出队
### LLM 错误诊断记录
用于承载超时、网络错误、模型不可用等 LLM 调用失败信息,和可处理失败样本分开存储,避免噪音污染主样本池。
### 样本来源标注
失败样本与诊断记录都会保留轻量 provenance 标记,便于区分:
- 路径样本
- 仅标题样本
- 来自哪个 source plugin
## 二期规划
@@ -78,6 +108,8 @@
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
- 已支持样本来源标注,便于区分路径样本与仅标题样本
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升

View File

@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- 用当前 LLM 结构化判断标题、年份、类型、季集
- 回写 `name / year / season / episode`
- 交回 MoviePilot 原生链路继续二次识别
- 保存低置信度失败样本
- 保存低置信度失败样本(可处理)
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
- 提供失败样本工作清单、洞察、重放、删除和清空能力
- 生成并应用 `CustomIdentifiers` 建议
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
## 主要接口
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
- 对单个标题做一次本地结构化识别测试
### 可处理失败样本接口
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
- 查看最近保存的失败样本
- 查看最近保存的可处理失败样本
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
- 返回适合继续处理的失败样本摘要列表
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
- 把建议规则写入系统 `CustomIdentifiers`
### LLM 诊断错误接口
这些接口返回因 LLM 调用异常(如超时、网络错误、模型不可用)而产生的诊断记录。它们不参与识别词生成流程,仅供排查 LLM 问题使用。
- `GET /api/v1/plugin/AIRecognizerEnhancer/llm_errors`
- 查看 LLM 调用失败的诊断记录
- `POST /api/v1/plugin/AIRecognizerEnhancer/clear_llm_errors`
- 清空 LLM 错误诊断记录
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
## 配置建议
- 先确认 MoviePilot 本身已经配置好可用的 LLM
- 建议保持保存失败样本”开启
- 建议保持保存失败样本”开启
- 默认情况下”保存仅标题样本”是关闭的,这可以减少没有真实文件路径的低价值噪音;如果你的使用场景以纯标题匹配为主,可以在设置中手动开启
- 如果失败样本池已经积累了大量历史噪音,可在设置页勾选“一次性清空”后保存
- 如果你经常处理历史资源或网盘资源,建议定期查看:
- `failed_samples`
- `sample_worklist`
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
## 已验证情况
当前版本:`0.1.12`
当前版本:`0.1.13`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
这版已经验证过:

View File

@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
plugin_name = "AI识别增强"
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
plugin_version = "0.1.12"
plugin_version = "0.1.13"
plugin_author = "liuyuexi1987"
plugin_level = 1
author_url = "https://github.com/liuyuexi1987"
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
_request_timeout = 25
_max_retries = 2
_save_failed_samples = True
_save_title_only_samples = False
_max_failed_samples = 200
_auto_remove_applied_sample = True
_clear_failed_samples_once = False
_systemconfig: Optional[SystemConfigOper] = None
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
self._save_failed_samples = bool(config.get("save_failed_samples", True))
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
self._systemconfig = SystemConfigOper()
self._register_events()
if self._clear_failed_samples_once:
cleared = self._clear_failed_samples()
self._clear_failed_samples_once = False
self.update_config(self._build_config({"clear_failed_samples_once": False}))
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared}")
def get_state(self) -> bool:
return self._enabled
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
if body:
for key in ("apikey", "api_key"):
for key in ("apikey", "api_key", "token"):
token = str(body.get(key) or "").strip()
if token:
return token
return str(request.query_params.get("apikey") or "").strip()
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = {
"enabled": self._enabled,
"debug": self._debug,
"confidence_threshold": self._confidence_threshold,
"request_timeout": self._request_timeout,
"max_retries": self._max_retries,
"save_failed_samples": self._save_failed_samples,
"save_title_only_samples": self._save_title_only_samples,
"max_failed_samples": self._max_failed_samples,
"auto_remove_applied_sample": self._auto_remove_applied_sample,
"clear_failed_samples_once": self._clear_failed_samples_once,
}
if overrides:
config.update(overrides)
return config
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
)
return str(title or "").strip(), str(path or "").strip()
@staticmethod
def _extract_provenance(event_data: Any) -> Dict[str, str]:
"""Extract lightweight provenance metadata from event data for sample recording."""
source_plugin = ""
if isinstance(event_data, dict):
source_plugin = str(event_data.get("source_plugin") or "").strip()
else:
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
title = ""
path = ""
if isinstance(event_data, dict):
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
else:
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
return {
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
"sample_source_plugin": source_plugin,
}
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
try:
meta = MetaInfo(raw_text)
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
def _sample_path(self) -> Path:
return self.get_data_path() / "failed_samples.jsonl"
def _llm_errors_path(self) -> Path:
return self.get_data_path() / "llm_errors.jsonl"
def _failed_sample_cap(self) -> int:
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
@staticmethod
def _sample_identity(payload: Dict[str, Any]) -> str:
return json.dumps(
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
sample_path = self._sample_path()
sample_path.parent.mkdir(parents=True, exist_ok=True)
trimmed = rows[-self._max_failed_samples:]
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
trimmed = filtered[-self._failed_sample_cap():]
with sample_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
except Exception as exc:
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
try:
error_path = self._llm_errors_path()
error_path.parent.mkdir(parents=True, exist_ok=True)
provenance = provenance or {}
entry = {
"title": title,
"path": path,
"meta_hint": meta_hint,
"reason": f"llm_error:{error}",
"timestamp": __import__("datetime").datetime.now().isoformat(),
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
existing = self._read_llm_errors(limit=1000)
existing.reverse()
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
existing = [
row for row in existing
if {
"title": row.get("title"),
"path": row.get("path"),
"reason": row.get("reason"),
} != new_identity
]
existing.append(entry)
trimmed = existing[-self._failed_sample_cap():]
with error_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
error_path = self._llm_errors_path()
if not error_path.exists():
return []
rows: List[Dict[str, Any]] = []
try:
with error_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except Exception:
continue
except Exception as exc:
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
return []
if limit > 0:
rows = rows[-limit:]
rows.reverse()
return rows
def _clear_llm_errors(self) -> int:
rows = self._read_llm_errors(limit=10000)
error_path = self._llm_errors_path()
if error_path.exists():
error_path.unlink()
return len(rows)
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
sample_path = self._sample_path()
if not sample_path.exists():
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
sample_index: Optional[Any] = None,
limit: int = 100,
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
if not samples:
return None, None, "暂无失败样本"
index = self._safe_int(sample_index, 0)
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
self,
sample_indexes: Optional[List[Any]] = None,
limit: int = 10,
pool_limit: int = 200,
pool_limit: int = 0,
) -> Tuple[List[int], List[Dict[str, Any]], str]:
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
if pool_limit <= 0:
pool_limit = self._failed_sample_cap()
current_samples = self._inject_sample_indices(
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
)
if not current_samples:
return [], [], "暂无失败样本"
if isinstance(sample_indexes, list) and sample_indexes:
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
"title": sample.get("title"),
"path": sample.get("path"),
"reason": sample.get("reason"),
"sample_source_kind": sample.get("sample_source_kind", ""),
"sample_source_plugin": sample.get("sample_source_plugin", ""),
"guess_name": guess.get("name"),
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
"verified_title": verified.get("title"),
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
label = self._sample_display_name(summary)
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
source_plugin = summary.get("sample_source_plugin") or ""
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
lines.append("下一步:可直接调用批量建议或批量复查接口。")
return "\n".join(lines)
@@ -937,7 +1066,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
return None
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
def _recognize(
self, title: str, path: str = "", record_failed_sample: bool = True,
provenance: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
title = str(title or "").strip()
path = str(path or "").strip()
if not title and path:
title = Path(path).name
if not title:
return {"success": False, "message": "标题为空"}
provenance = provenance or {}
sample_source_kind = provenance.get("sample_source_kind")
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
try:
guess = self._invoke_llm(title, path)
except Exception as exc:
if record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"reason": f"llm_error:{exc}",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
else:
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
return {"success": False, "message": f"LLM 调用失败: {exc}"}
verified = self._verify_guess(title, path, guess)
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
if not passed and record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
else:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
)
return {
"success": passed,
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
title, path = self._extract_title_path(event_data)
if not title and not path:
return
result = self._recognize(title=title, path=path)
provenance = self._extract_provenance(event_data)
result = self._recognize(title=title, path=path, provenance=provenance)
if not result.get("success"):
if self._debug:
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 50)
limit = max(1, min(limit, 200))
limit = max(1, min(limit, self._failed_sample_cap()))
top = self._safe_int(request.query_params.get("top"), 10)
top = max(1, min(top, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 5)
limit = max(1, min(limit, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
return {
"success": True,
"data": {
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
},
}
async def api_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 20)
limit = max(1, min(limit, 100))
errors = self._read_llm_errors(limit=limit)
return {
"success": True,
"data": {
"count": len(errors),
"errors": errors,
},
}
async def api_clear_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
cleared = self._clear_llm_errors()
return {
"success": True,
"message": "success",
"data": {
"cleared_count": cleared,
},
}
async def api_remove_failed_sample(self, request: Request):
body = await request.json()
ok, message = self._check_api_access(request, body)
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
"methods": ["POST"],
"summary": "清空失败样本文件",
},
{
"path": "/llm_errors",
"endpoint": self.api_llm_errors,
"methods": ["GET"],
"summary": "查看 LLM 调用失败的诊断记录",
},
{
"path": "/clear_llm_errors",
"endpoint": self.api_clear_llm_errors,
"methods": ["POST"],
"summary": "清空 LLM 错误诊断记录",
},
{
"path": "/remove_failed_sample",
"endpoint": self.api_remove_failed_sample,
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
def get_page(self) -> List[dict]:
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
failed_samples_count = len(self._read_failed_samples(limit=200))
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
custom_identifiers_count = len(self._get_custom_identifiers())
llm_provider = getattr(settings, "LLM_PROVIDER", "")
llm_model = getattr(settings, "LLM_MODEL", "")
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 可用", "" if llm_ready else "", f"{llm_provider} / {llm_model}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"content": [stat_card("失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("可处理失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 错误", f"{llm_errors_count}", "诊断记录")],
},
{
"component": "VCol",
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("自定义识别词", f"{custom_identifiers_count}", "系统 CustomIdentifiers")],
},
],
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"props": {"variant": "outlined", "class": "pa-4 h-100"},
"content": [
{
"component": "div",
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
"text": "识别兜底",
},
{
"component": "div",
"props": {"class": "text-body-2 text-medium-emphasis"},
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode供 MoviePilot 继续原生二次识别。",
},
{
"component": "div",
"props": {"class": "text-caption text-medium-emphasis mt-3"},
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout}",
},
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"props": {"cols": 12, "md": 12},
"content": [
{
"component": "VCard",
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
return "vuetify", None
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
form = [
{
"component": "VForm",
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VAlert",
"props": {
"type": "warning",
"variant": "tonal",
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "save_title_only_samples",
"label": "保存仅标题样本",
},
}
],
},
],
},
{
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VSwitch",
"props": {
"model": "clear_failed_samples_once",
"label": "保存时清空失败样本(一次性)",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
"request_timeout": 25,
"max_retries": 2,
"save_failed_samples": True,
"save_title_only_samples": False,
"max_failed_samples": 200,
"auto_remove_applied_sample": True,
"clear_failed_samples_once": False,
}

File diff suppressed because it is too large Load Diff

View File

@@ -114,11 +114,44 @@ class SentrySanitizer:
return False
TRACEBACK_PATTERN = re.compile(
r"Traceback \(most recent call last\):|"
r"File \"[^\"]+\", line \d+|"
r"^\s+raise \w+|"
r"^\w+Error:|^\w+Exception:",
re.MULTILINE
)
@classmethod
def has_stacktrace(cls, event) -> bool:
"""
判断事件是否包含明确的异常堆栈信息(结构化异常或日志文本中的堆栈)
"""
if "exception" in event:
for exc in event["exception"].get("values", []):
stacktrace = exc.get("stacktrace")
if stacktrace and stacktrace.get("frames"):
return True
if "message" in event and cls.TRACEBACK_PATTERN.search(event["message"]):
return True
if "logentry" in event:
msg = event["logentry"].get("message", "") or event["logentry"].get("formatted", "")
if cls.TRACEBACK_PATTERN.search(msg):
return True
return False
@classmethod
def before_send(cls, event, hint):
"""
在发送到 Sentry 之前脱敏和过滤
"""
# 只上报包含明确异常堆栈的事件,普通 error 日志不上报
if not cls.has_stacktrace(event):
return None
# 如果是网络连接错误,直接返回 None 不上报
if cls.is_network_error(event):
return None
@@ -171,7 +204,7 @@ class BugReporter(_PluginBase):
# 插件图标
plugin_icon = "Alist_encrypt_A.png"
# 插件版本
plugin_version = "1.3"
plugin_version = "1.5.1"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -188,7 +221,7 @@ class BugReporter(_PluginBase):
def init_plugin(self, config: dict = None):
self._enable = config.get("enable")
if self._enable:
sentry_sdk.init("https://88da01ad33b4423cb0380620de53efa8@glitchtip.movie-pilot.org/1",
sentry_sdk.init("https://3999f6a035db46a588b03e5a92b9f592@glitchtip.movie-pilot.org/2",
before_send=SentrySanitizer.before_send,
release=APP_VERSION,
send_default_pii=False)
@@ -240,7 +273,7 @@ class BugReporter(_PluginBase):
'props': {
'type': 'warning',
'variant': 'tonal',
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报系统异常信息,不会包含任何个人隐私信息或敏感数据;网络连接错误类异常不会上报;异常信息采集为使用开源项目解决方案GlitchTip。',
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报包含异常堆栈的系统错误,普通日志和网络连接错误不会上报;不会包含任何个人隐私信息或敏感数据异常信息采集为使用开源项目解决方案GlitchTip。',
}
}
]

View File

@@ -1 +1 @@
sentry_sdk~=2.44.0
sentry_sdk

File diff suppressed because it is too large Load Diff

View File

@@ -1,235 +1,216 @@
import asyncio
import inspect
import json
import re
import time
from typing import List, Union
import threading
from typing import Any, Dict, Optional
import openai
from cacheout import Cache
OpenAISessionCache = Cache(maxsize=100, ttl=3600, timer=time.time, default=None)
from app.agent.llm import LLMHelper
from langchain_core.messages import HumanMessage, SystemMessage
class OpenAi:
_api_key: str = None
_api_url: str = None
_model: str = "gpt-3.5-turbo"
_prompt: str = '接下来我会给你一个电影或电视剧的文件名你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息并按以下JSON格式返回{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null}特别注意返回结果需要严格附合JSON格式不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况请还原最有可能的结果。'
_client: openai.OpenAI = None
"""
Lightweight LLM recognition client kept under the original module name for plugin compatibility.
"""
def __init__(self, api_key: str = None, api_url: str = None,
proxy: dict = None, model: str = None,
compatible: bool = False, customize_prompt: str = None):
_JSON_FENCE_PATTERN = re.compile(r"^```(?:json)?\s*([\s\S]*?)\s*```$", re.IGNORECASE)
def __init__(
self,
api_key: str = None,
api_url: str = None,
provider: str = None,
model: str = None,
base_url_preset: str = None,
user_agent: str = None,
use_proxy: bool = None,
thinking_level: str = None,
customize_prompt: str = None,
**kwargs,
):
"""
初始化用于媒体识别的 LLM 客户端运行参数。
"""
self._api_key = api_key
self._api_url = api_url
if model:
self._model = model
if customize_prompt:
self._prompt = customize_prompt
# 初始化 OpenAI 客户端
if self._api_key and self._api_url:
base_url = self._api_url if compatible else self._api_url + "/v1"
http_client = None
if proxy and proxy.get("https"):
import httpx
proxy_url = proxy.get("https")
# httpx 支持字符串格式的代理 URL
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
self._client = openai.OpenAI(
api_key=self._api_key,
base_url=base_url,
http_client=http_client
)
self._provider = provider or "openai"
self._model = model
self._base_url_preset = base_url_preset
self._user_agent = user_agent
self._use_proxy = use_proxy
self._thinking_level = thinking_level
self._prompt = customize_prompt or ""
self._last_usage: Dict[str, int] = {}
def get_state(self) -> bool:
return True if self._api_key else False
"""
返回当前客户端是否具备发起识别调用的必要模型配置。
"""
return bool(self._api_key and self._model)
def get_last_usage(self) -> Dict[str, int]:
"""
返回最近一次模型调用提取到的 token 用量。
"""
return dict(self._last_usage or {})
@staticmethod
def __save_session(session_id: str, message: str):
def _run_async_compatible(value: Any) -> Any:
"""
保存会话
:param session_id: 会话ID
:param message: 消息
:return:
在同步插件回调中兼容执行新版 MoviePilot 的异步 LLM 初始化。
"""
seasion = OpenAISessionCache.get(session_id)
if seasion:
seasion.append({
"role": "assistant",
"content": message
})
OpenAISessionCache.set(session_id, seasion)
if not inspect.isawaitable(value):
return value
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(value)
result: Dict[str, Any] = {}
error: Dict[str, BaseException] = {}
def _worker() -> None:
"""
在独立线程中运行协程,避免嵌套事件循环。
"""
try:
result["value"] = asyncio.run(value)
except BaseException as exc: # noqa: BLE001
error["exc"] = exc
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
if "exc" in error:
raise error["exc"]
return result.get("value")
@staticmethod
def __get_session(session_id: str, message: str) -> List[dict]:
def _lookup_int(data: Any, key: str) -> Optional[int]:
"""
获取会话
:param session_id: 会话ID
:return: 会话上下文
从字典或对象字段中安全读取整数 token 统计。
"""
seasion = OpenAISessionCache.get(session_id)
if seasion:
seasion.append({
"role": "user",
"content": message
})
else:
seasion = [
{
"role": "system",
"content": "请在接下来的对话中请使用中文回复,并且内容尽可能详细。"
},
{
"role": "user",
"content": message
}]
OpenAISessionCache.set(session_id, seasion)
return seasion
if not data:
return None
value = data.get(key) if isinstance(data, dict) else getattr(data, key, None)
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def __get_model(self, message: Union[str, List[dict]],
prompt: str = None,
user: str = "MoviePilot",
**kwargs):
@classmethod
def _extract_usage(cls, response: Any) -> Dict[str, int]:
"""
获取模型
从 LangChain AIMessage 中提取 token 用量。
"""
if not self._client:
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
if not isinstance(message, list):
if prompt:
message = [
{
"role": "system",
"content": prompt
},
{
"role": "user",
"content": message
}
]
else:
message = [
{
"role": "user",
"content": message
}
]
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
kwargs.pop('user', None)
return self._client.chat.completions.create(
model=self._model,
messages=message,
**kwargs
usage_metadata = getattr(response, "usage_metadata", None)
response_metadata = getattr(response, "response_metadata", None) or {}
token_usage = (
response_metadata.get("token_usage")
or response_metadata.get("usage")
or response_metadata.get("usage_metadata")
or {}
)
@staticmethod
def __clear_session(session_id: str):
"""
清除会话
:param session_id: 会话ID
:return:
"""
if OpenAISessionCache.get(session_id):
OpenAISessionCache.delete(session_id)
input_tokens = (
cls._lookup_int(usage_metadata, "input_tokens")
or cls._lookup_int(token_usage, "input_tokens")
or cls._lookup_int(token_usage, "prompt_tokens")
or 0
)
output_tokens = (
cls._lookup_int(usage_metadata, "output_tokens")
or cls._lookup_int(token_usage, "output_tokens")
or cls._lookup_int(token_usage, "completion_tokens")
or 0
)
total_tokens = (
cls._lookup_int(usage_metadata, "total_tokens")
or cls._lookup_int(token_usage, "total_tokens")
or input_tokens + output_tokens
)
return {
"input_tokens": max(input_tokens, 0),
"output_tokens": max(output_tokens, 0),
"total_tokens": max(total_tokens, 0),
}
def get_media_name(self, filename: str):
def _get_llm(self) -> Any:
"""
从文件名中提取媒体名称等要素
:param filename: 文件名
:return: Json
按当前运行参数创建 MoviePilot LLM 实例。
"""
llm = LLMHelper.get_llm(
streaming=False,
provider=self._provider,
model=self._model,
thinking_level=self._thinking_level,
api_key=self._api_key,
base_url=self._api_url,
base_url_preset=self._base_url_preset,
user_agent=self._user_agent,
use_proxy=self._use_proxy,
)
return self._run_async_compatible(llm)
@staticmethod
def _extract_response_text(response: Any) -> str:
"""
从模型响应对象中提取文本内容。
"""
content = getattr(response, "content", response)
return LLMHelper._extract_text_content(content).strip()
@classmethod
def _strip_json_fence(cls, text: str) -> str:
"""
移除模型可能附加的 Markdown JSON 代码块包裹。
"""
text = str(text or "").strip()
match = cls._JSON_FENCE_PATTERN.match(text)
return match.group(1).strip() if match else text
@classmethod
def _extract_json_text(cls, text: str) -> str:
"""
从模型回复中提取第一个 JSON 对象文本。
"""
text = cls._strip_json_fence(text)
if text.startswith("{") and text.endswith("}"):
return text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
return text
def get_media_name(self, filename: str) -> Dict[str, Any]:
"""
从媒体文件名中提取结构化识别信息。
"""
self._last_usage = {}
if not self.get_state():
return None
return {"errorMsg": "LLM API Key or model is not configured"}
result = ""
try:
_filename_prompt = self._prompt
completion = self.__get_model(prompt=_filename_prompt, message=filename)
result = completion.choices[0].message.content
# 有些模型返回json数据时会使用 ```json ``` 包裹json对象 所以需要进行提取
# 定义正则表达式模式,匹配```json开头和```结尾的内容
pattern = r'^```json\s*([\s\S]*?)\s*```$'
# 使用正则表达式进行匹配
match = re.match(pattern, result.strip())
if match:
# 提取中间的JSON部分
result = match.group(1)
return json.loads(result)
except Exception as e:
llm = self._get_llm()
completion = llm.invoke(
[
SystemMessage(content=self._prompt),
HumanMessage(content=str(filename or "")),
]
)
self._last_usage = self._extract_usage(completion)
result = self._extract_response_text(completion)
json_text = self._extract_json_text(result)
data = json.loads(json_text)
if not isinstance(data, dict):
raise ValueError("LLM response is not a JSON object")
return data
except Exception as exc:
return {
"content": result,
"errorMsg": str(e)
"errorMsg": str(exc),
}
def get_response(self, text: str, userid: str):
"""
聊天对话,获取答案
:param text: 输入文本
:param userid: 用户ID
:return:
"""
if not self.get_state():
return ""
try:
if not userid:
return "用户信息错误"
else:
userid = str(userid)
if text == "#清除":
self.__clear_session(userid)
return "会话已清除"
# 获取历史上下文
messages = self.__get_session(userid, text)
completion = self.__get_model(message=messages, user=userid)
result = completion.choices[0].message.content
if result:
self.__save_session(userid, text)
return result
except openai.RateLimitError as e:
return f"请求被ChatGPT拒绝了{str(e)}"
except openai.APIConnectionError as e:
return f"ChatGPT网络连接失败{str(e)}"
except openai.APITimeoutError as e:
return f"没有接收到ChatGPT的返回消息{str(e)}"
except Exception as e:
return f"请求ChatGPT出现错误{str(e)}"
def translate_to_zh(self, text: str):
"""
翻译为中文
:param text: 输入文本
"""
if not self.get_state():
return False, None
system_prompt = "You are a translation engine that can only translate text and cannot interpret it."
user_prompt = f"translate to zh-CN:\n\n{text}"
result = ""
try:
completion = self.__get_model(prompt=system_prompt,
message=user_prompt,
temperature=0,
top_p=1,
frequency_penalty=0,
presence_penalty=0)
result = completion.choices[0].message.content.strip()
return True, result
except Exception as e:
print(f"{str(e)}{result}")
return False, str(e)
def get_question_answer(self, question: str):
"""
从给定问题和选项中获取正确答案
:param question: 问题及选项
:return: Json
"""
if not self.get_state():
return None
result = ""
try:
_question_prompt = "下面我们来玩一个游戏,你是老师,我是学生,你需要回答我的问题,我会给你一个题目和几个选项,你的回复必须是给定选项中正确答案对应的序号,请直接回复数字"
completion = self.__get_model(prompt=_question_prompt, message=question)
result = completion.choices[0].message.content
return result
except Exception as e:
print(f"{str(e)}{result}")
return {}

View File

@@ -1 +0,0 @@
cacheout~=0.16.0

View File

@@ -36,7 +36,7 @@ class ClashRuleProvider(_PluginBase):
# 插件图标
plugin_icon = "Mihomo_Meta_A.png"
# 插件版本
plugin_version = "2.1.6"
plugin_version = "2.1.7"
# 插件作者
plugin_author = "wumode"
# 作者主页

View File

@@ -7,7 +7,6 @@ from app.log import logger
from .proxy import Proxy
from .proxygroups import ProxyGroup
from .proxyproviders import ProxyProvider
from .proxy.tlsmixin import ClientFingerprint
from .ruleproviders import RuleProvider
from .rule import RuleType, Action, RoutingRuleType
from ..helper.clashruleparser import ClashRuleParser
@@ -70,8 +69,7 @@ class ClashConfig(BaseModel):
interface_name: str | None = Field(default=None, alias="interface-name")
routing_mark: int | None = Field(default=None, alias="routing-mark")
tls: dict[str, Any] | None = Field(default=None, alias="tls")
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
alias="global-client-fingerprint")
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")

View File

@@ -1,5 +1,5 @@
websockets
sse_starlette>=3.0.0
sse_starlette>=3.0.0,<3.0.4
PyYAML~=6.0.2
jsonpatch~=1.33
simpleeval~=1.0.3
simpleeval~=1.0.3

View File

@@ -179,7 +179,7 @@ class CrossSeed(_PluginBase):
# 插件图标
plugin_icon = "qingwa.png"
# 插件版本
plugin_version = "3.0.2"
plugin_version = "3.0.3"
# 插件作者
plugin_author = "233@qingwa"
# 作者主页
@@ -218,6 +218,8 @@ class CrossSeed(_PluginBase):
_success_caches = []
# 辅种缓存出错的种子不再重复辅种且无法清除。种子被删除404等情况
_permanent_error_caches = []
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
_seed_cache_max_items = 10000
_torrentpaths = []
_site_cs_infos = []
# 辅种计数
@@ -229,6 +231,11 @@ class CrossSeed(_PluginBase):
cached = 0
def init_plugin(self, config: dict = None):
self._error_caches = []
self._success_caches = []
self._permanent_error_caches = []
self._torrentpaths = []
self._site_cs_infos = []
# 读取配置
if config:
@@ -245,9 +252,14 @@ class CrossSeed(_PluginBase):
self._nolabels = config.get("nolabels")
self._nopaths = config.get("nopaths")
self._clearcache = config.get("clearcache")
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
self._permanent_error_caches = (
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
)
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
self.__trim_seed_cache(self._permanent_error_caches)
self.__trim_seed_cache(self._error_caches)
self.__trim_seed_cache(self._success_caches)
# 过滤掉已删除的站点
inner_site_list = SiteOper().list_order_by_pri()
@@ -317,6 +329,8 @@ class CrossSeed(_PluginBase):
# 停止现有任务
self.stop_service()
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
self._recheck_torrents = {}
# 启动定时任务 & 立即运行一次
if self.get_state() or self._onlyonce:
@@ -756,6 +770,32 @@ class CrossSeed(_PluginBase):
"permanent_error_caches": self._permanent_error_caches
})
def __trim_seed_cache(self, cache: list):
"""
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
"""
if not cache:
return
unique_cache = []
seen = set()
for item in reversed(cache):
if not item or item in seen:
continue
seen.add(item)
unique_cache.append(item)
unique_cache.reverse()
cache[:] = unique_cache[-self._seed_cache_max_items:]
def __append_seed_cache(self, cache: list, value: str):
"""
写入辅种缓存并保持上限,重复值只保留一份。
"""
if not value:
return
if value not in cache:
cache.append(value)
self.__trim_seed_cache(cache)
def auto_seed(self):
"""
开始辅种
@@ -1106,10 +1146,10 @@ class CrossSeed(_PluginBase):
self.cached += 1
# 加入失败缓存
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
self._error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
else:
# 种子不存在的情况
self._permanent_error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._permanent_error_caches, tor.get_name_id_tag())
logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
return False
@@ -1120,7 +1160,7 @@ class CrossSeed(_PluginBase):
tors, msg = downloader_obj.get_torrents(ids=[tmp_tor_info.info_hash])
if tors:
self.exist += 1
self._success_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
return True
else:
@@ -1136,7 +1176,7 @@ class CrossSeed(_PluginBase):
self.fail += 1
self.cached += 1
# 加入失败缓存
self._error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
return False
else:
self.success += 1
@@ -1149,7 +1189,7 @@ class CrossSeed(_PluginBase):
# 下载成功
logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}")
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
self._success_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
return True
def __add_recheck_torrents(self, service: ServiceInfo, download_id: str):

View File

@@ -1 +0,0 @@
fast-bencode~=1.1.7

View File

@@ -20,7 +20,7 @@ from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType
from .helper import PyCookieCloud, MySender, IpLocationParser
from .helper import PyCookieCloud, MySender, IpLocationParser, JsonFieldManager
class DynamicWeChat(_PluginBase):
@@ -31,7 +31,7 @@ class DynamicWeChat(_PluginBase):
# 插件图标
plugin_icon = "Wecom_A.png"
# 插件版本
plugin_version = "2.0.0"
plugin_version = "2.1.2"
# 插件作者
plugin_author = "RamenRa"
# 作者主页
@@ -73,13 +73,13 @@ class DynamicWeChat(_PluginBase):
_notification_token = ''
# 标记企业微信通知可用
_wechat_available = True
# 标记IP变动后 是否发送通知
# 标记IP变动后 通知发送过了没有
_send_notification = False
# 匹配ip地址的正则
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
# 获取ip地址的网址列表
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"]
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://r.inews.qq.com/api/ip2city", "https://uapis.cn/api/v1/network/myip"]
# 当前ip地址
_current_ip_address = '0.0.0.0'
# 企业微信登录
@@ -116,9 +116,13 @@ class DynamicWeChat(_PluginBase):
@staticmethod
def _launch_browser_context(headless: bool = True):
"""
使用 CloakBrowser 启动企业微信页面上下文,统一注入中文语言参数
使用 CloakBrowser 启动企业微信页面上下文,统一注入中文语言环境
"""
return launch_context(headless=headless, args=['--lang=zh-CN'])
context = launch_context(headless=headless, args=['--lang=zh-CN'])
context.set_extra_http_headers({
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.1'
})
return context
if hasattr(settings, 'VERSION_FLAG'):
version = settings.VERSION_FLAG # V2
@@ -127,6 +131,7 @@ class DynamicWeChat(_PluginBase):
def init_plugin(self, config: dict = None):
# 清空配置
self._last_code = ""
self._notification_token = ''
self._cron = '*/10 * * * *'
self._ip_changed = True
@@ -136,13 +141,14 @@ class DynamicWeChat(_PluginBase):
self._input_id_list = ''
self._cookie_header = ""
self._settings_file_path = self.get_data_path() / "settings.json"
self.cfg = JsonFieldManager(self._settings_file_path)
self._qr_running = False
if config:
self._enabled = config.get("enabled")
self._notification_token = config.get("notification_token")
self._cron = config.get("cron")
self._onlyonce = config.get("onlyonce")
self._input_id_list = config.get("input_id_list")
# self._current_ip_address = config.get("current_ip_address")
self._forced_update = config.get("forced_update")
self._local_scan = config.get("local_scan")
self._use_cookiecloud = config.get("use_cookiecloud")
@@ -166,7 +172,9 @@ class DynamicWeChat(_PluginBase):
self._current_ip_address = self.wan2.read_ips("ips") # 从文件中读取
else:
self.wan2 = None
_, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取
# _, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取 返回URL和IP
self._current_ip_address = self.cfg.get("WECHAT_NOW_IP") # 应对MP/NAS长时间关闭后公网IP和可信IP不一致
# 停止现有任务
self.stop_service()
if (self._enabled or self._onlyonce) and self._input_id_list:
@@ -236,14 +244,33 @@ class DynamicWeChat(_PluginBase):
def _send_cookie_false(self):
self._cookie_valid = False
if self._my_send and not self._await_ip: # 不启用“IP变动后通知
if self._my_send and not self._await_ip and self._wechat_available: # 配置了通知 且 不启用“IP变动后通知 且 微信通知有效
error = self._my_send.send(
title="cookie已失效,请及时更新",
content="请在企业微信应用发送/push_qr, 如有验证码以''结束发送到企业微信应用。 如果使用微信通知请确保公网IP还没有变动",
content="请在企业微信应用发送/push_qr, 验证码以''结束发送到企业微信应用。 如果使用微信通知请确保公网IP还没有变动",
image=None, force_send=False
)
if error:
logger.info(f"cookie失效通知发送失败,原因:{error}")
return None
elif self._my_send and not self._wechat_available and self._my_send.other_channel: # self._my_send 防止空对象
'''
# 微信通知无效IP已不一致 且 配置了第三方通知
'''
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
title="cookie已失效,且微信通知失效",
content="请在企业微信应用发送/push_qr, 验证码以''结束发送到企业微信应用。",
image=None, force_send=False, diy_channel=channel, diy_token=token
)
if error:
logger.error(f"通道 {channel} 发送失败,原因:{error}")
else:
return None
else:
# logger.error(f"通道 {self._my_send} 发送失败,原因:{error}")
return None
@eventmanager.register(EventType.PluginAction)
def forced_change(self, event: Event = None):
@@ -293,12 +320,14 @@ 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()
page.goto(self._wechatUrl)
time.sleep(3) # 页面加载等待时间
if self.find_qrc(page):
img, _ = self.find_qrc(page)
if img:
current_time = datetime.now()
future_time = current_time + timedelta(seconds=110)
self._future_timestamp = int(future_time.timestamp())
@@ -321,6 +350,7 @@ class DynamicWeChat(_PluginBase):
except Exception as e:
logger.error(f"本地扫码任务: 本地扫码失败: {e}")
finally:
self._qr_running = False
if context:
context.close()
@@ -366,36 +396,26 @@ class DynamicWeChat(_PluginBase):
if not event_data or event_data.get("action") != "dynamicwechat":
return
# 情况1cookie有效
if self._cookie_valid:
logger.info("开始检测公网IP")
if self.CheckIP():
self.ChangeIP()
self.__update_config()
logger.info("----------------------本次任务结束----------------------")
elif self._await_ip and not self._send_notification:
# logger.info("cookie已失效。但配置了第三方通知继续检测公网IP。当IP变动企业微信通知彻底无法使用时通知用户")
return
# 情况2cookie失效 + 启用IP变动后通知有第三方通知
if self._await_ip:
logger.info("开始检测公网IP,等待IP变动后发送通知")
if self.CheckIP(func="public"):
# logger.info(f"配置的第三方通知{self._my_send.other_channel}")
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
title="公网IP与企业微信IP不一致",
content="请在企业微信应用发送/push_qr, 如有验证码以''结束发送到企业微信应用。",
image=None, force_send=False, diy_channel=channel, diy_token=token
)
if error:
logger.error(f"通道 {channel} 发送失败,原因:{error}")
else:
self._send_notification = True
break # 发送成功后退出循环
self._wechat_available = False # 标记不可用
self._send_cookie_false()
logger.info("----------------------本次任务结束----------------------")
else:
if self._send_notification:
logger.info("企业微信可信IP和公网IP不一致微信通知可能已经无法使用。第三方通知已经发送。")
else:
logger.info("cookie已失效请及时更新,本次不检查公网IP")
return
# 情况3cookie失效 + 不等待IP变化
logger.info("Cookie已失效本次不检查IP")
self._send_cookie_false()
def CheckIP(self, func=None):
if self.wan2:
@@ -404,7 +424,7 @@ class DynamicWeChat(_PluginBase):
else:
url, ip_address = self.get_ip_from_url()
if ip_address == "获取IP失败" or not url:
if not ip_address or ip_address == "获取IP失败" or not url:
logger.error("获取IP失败 不操作可信IP")
return False
@@ -434,6 +454,7 @@ class DynamicWeChat(_PluginBase):
# 检查 IP 是否变化
if ip_address != self._current_ip_address:
logger.info("检测到IP变化")
self._wechat_available = False
return True
return False
@@ -466,7 +487,6 @@ class DynamicWeChat(_PluginBase):
urls = self._input_id_list
else:
urls = self._ip_urls
# 随机化 URL 列表
random.shuffle(urls)
if not self.wan2:
@@ -542,7 +562,7 @@ class DynamicWeChat(_PluginBase):
time.sleep(3)
img_src, refuse_time = self.find_qrc(page)
if img_src:
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发二维码
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发二维码
self._ip_changed = False
self._send_cookie_false()
logger.info("已尝试发送cookie失效通知")
@@ -595,7 +615,7 @@ class DynamicWeChat(_PluginBase):
formatted_cookies[domain] = []
formatted_cookies[domain].append(cookie)
if self._cc_server.update_cookie(formatted_cookies):
logger.info("更新 CookieCloud 成功")
logger.info("更新 CookieCloud 成功如没有CC服务器同步cookie请不要在其他地方登录企业微信")
self._cookie_valid = True
self._is_special_upload = True
else:
@@ -617,7 +637,7 @@ class DynamicWeChat(_PluginBase):
self._is_special_upload = False
return
else:
logger.info("更新本地 Cookie成功")
logger.info("更新本地 Cookie成功,请不要在其他地方登录企业微信")
self._is_special_upload = True
self._saved_cookie = current_cookies # 保存
self._cookie_valid = True
@@ -626,16 +646,24 @@ class DynamicWeChat(_PluginBase):
logger.error(f"更新本地 cookie 发生错误: {e}")
def get_cookie(self):
"""
获取企业微信 Cookie。
获取优先级:
1. 本地内存缓存_saved_cookie 且标记有效)
2. CookieCloud 中 .work.weixin.qq.com 域名的 cookie
Returns:
Playwright 格式的 Cookie 字典列表;获取失败或未启用时返回 None。
"""
if self._saved_cookie and self._cookie_valid:
return self._saved_cookie
try:
cookie_header = ''
if not self._use_cookiecloud:
return
return None
cookies, msg = self._cookiecloud.download()
if not cookies: # CookieCloud获取cookie失败
logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}")
return
return None
for domain, cookie in cookies.items():
if domain == ".work.weixin.qq.com":
cookie_header = cookie
@@ -646,7 +674,7 @@ class DynamicWeChat(_PluginBase):
return cookie
except Exception as e:
logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}")
return
return None
# @staticmethod
def parse_cookie_header(self, cookie_header):
@@ -671,17 +699,17 @@ class DynamicWeChat(_PluginBase):
context = self._launch_browser_context(headless=True)
cookie_used = False
if self._saved_cookie:
# logger.info("尝试使用本地保存的 cookie")
# logger.info("尝试使用内存保存的 cookie")
context.add_cookies(self._saved_cookie)
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3)
if self.check_login_status(page, task='refresh_cookie'):
# logger.info("本地保存的 cookie 有效")
# logger.info("本地内存保存的 cookie 有效")
self._cookie_valid = True
cookie_used = True
else:
# logger.warning("本地保存的 cookie 无效")
# logger.warning("本地内存保存的 cookie 无效")
self._cookie_valid = False
self._saved_cookie = None # 清空无效的 cookie
@@ -776,7 +804,7 @@ class DynamicWeChat(_PluginBase):
except:
continue
else:
logger.error("未收到短信验证码")
logger.error("未收到短信验证码请以问号结尾发送到企业微信应用。如510010? 使用全局AI助手需使用/wxcode 510010的格式发送验证码")
return False
except Exception as e:
# logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志
@@ -841,10 +869,16 @@ class DynamicWeChat(_PluginBase):
if self._ip_changed:
self._wechat_available = True # 标记微信通知重新有效
self._send_notification = False # 重置第三方通知已发送标记
self.cfg.update("WECHAT_NOW_IP", self._current_ip_address)
'''
将填入企业微信的IP写入settings.json
应对MP/NAS长时间关闭后公网IP和可信IP不一致
'''
# self.wan2 = IpLocationParser(self._settings_file_path, max_ips=1)
masked_ips = [self.mask_ip(ip) for ip in self._current_ip_address.split(';')]
masked_ip_string = ";".join(masked_ips)
logger.info(f"应用: {app_id} 输入IP" + self._current_ip_address)
if self._my_send:
if self._my_send and not self._my_send.quiet_flag: # 没有开启安静模式才发通知
self._my_send.send(title="更新可信IP成功",
content='应用: ' + app_id + ' 输入IP' + masked_ip_string,
force_send=True, diy_channel="WeChat")
@@ -1251,6 +1285,11 @@ class DynamicWeChat(_PluginBase):
event_data = event.event_data
if not event_data or event_data.get("action") != "push_qrcode":
return
if self._qr_running:
# logger.warning("二维码任务正在执行,忽略重复触发")
return
self._qr_running = True
context = None
try:
context = self._launch_browser_context(headless=True)
@@ -1260,7 +1299,7 @@ class DynamicWeChat(_PluginBase):
image_src, refuse_time = self.find_qrc(page)
if image_src:
if self._my_send:
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用,但是配置了第三方通知
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
@@ -1271,19 +1310,27 @@ class DynamicWeChat(_PluginBase):
logger.warning(f"通道 {channel} 推送二维码失败,原因:{error}")
else:
break # 发送成功后退出循环
else: # 硬发
else: # 只配置了微信通知 硬发
error = self._my_send.send("企业微信登录二维码", image=image_src)
if error:
logger.info(f"远程推送任务: 二维码发送失败,原因:{error}")
logger.info("----------------------本次任务结束----------------------")
return
logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
logger.info("远程推送任务: 二维码发送成功,等待用户 80 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
# logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301")
time.sleep(90)
if self.check_login_status(page, 'push_qr_code'):
self._update_cookie(page, context) # 刷新cookie
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
self.click_app_management_buttons(page)
# time.sleep(90)
max_attempts = 4
attempt = 0
while attempt < max_attempts:
time.sleep(20)
attempt += 1
if self.check_login_status(page, 'push_qr_code'):
self._update_cookie(page, context) # 刷新cookie
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
self.click_app_management_buttons(page)
break
else:
logger.info("用户可能没有扫码或登录失败")
else:
logger.warning("远程推送任务: 没有找到可用的通知方式")
else:
@@ -1294,6 +1341,43 @@ class DynamicWeChat(_PluginBase):
finally:
if context:
context.close()
self._qr_running = False
@eventmanager.register(EventType.PluginAction)
def receive_code(self, event: Event = None):
"""
接收企业微信验证码
"""
if not self._enabled or not event:
return
event_data = event.event_data or {}
if event_data.get("action") != "wxcode":
return
if not self._qr_running:
return
raw = event_data.get("arg_str") or ""
# 去掉无效日志噪音(只在调试时保留)
# logger.info(f"完整event_data: {event_data}")
# logger.info(f"原始内容: {raw}")
match = re.search(r"\d{6}", raw)
if not match:
logger.warning(f"收到无效验证码: {raw}")
return
code = match.group(0)
# 防重复接收(关键优化)
if getattr(self, "_last_code", None) == code:
return
self._last_code = code
self._verification_code = code
logger.info(f"收到验证码:{code}")
@staticmethod
def get_command() -> List[Dict[str, Any]]:
@@ -1306,6 +1390,15 @@ class DynamicWeChat(_PluginBase):
"data": {
"action": "push_qrcode"
}
},
{
"cmd": "/wxcode",
"event": EventType.PluginAction,
"desc": "提交企业微信验证码",
"category": "",
"data": {
"action": "wxcode"
}
}
]
@@ -1319,12 +1412,18 @@ class DynamicWeChat(_PluginBase):
"""
if not self._enabled:
return
if not self._qr_running:
return
self.text = event.event_data.get("text")
if len(self.text) == 7 and re.fullmatch(r".*\d{6}.*", self.text):
match = re.search(r"\d{6}", self.text)
if match:
self._verification_code = match.group(0)
logger.info(f"收到验证码:{self._verification_code}")
code = match.group(0)
# self._verification_code = match.group(0)
if code != self._last_code:
self._verification_code = code
self._last_code = code
logger.info(f"收到验证码:{code}")
def get_service(self) -> List[Dict[str, Any]]:
"""

View File

@@ -130,6 +130,15 @@ class PyCookieCloud:
class MySender:
def __init__(self, token=None, func=None):
self.raw_token = token or ""
self.quiet_flag = False # 安静模式标志
if self.raw_token.endswith("||Q") or self.raw_token.endswith("||q"):
self.quiet_flag = True
token = self.raw_token.rsplit("||", 1)[0] # 去掉控制段
else:
token = self.raw_token
self.tokens = token.split('||') if token and '||' in token else [token] if token else []
self.channels = [MySender._detect_channel(t) for t in self.tokens]
self.current_index = 0 # 当前使用的 token 和 channel 的索引
@@ -137,6 +146,7 @@ class MySender:
self.init_success = bool(self.tokens) # 标识初始化是否成功
self.post_message_func = func # V2 微信模式的 post_message 方法
@property
def other_channel(self):
"""
@@ -147,15 +157,15 @@ class MySender:
@staticmethod
def _detect_channel(token):
"""根据 token 确定通知渠道"""
if "WeChat" in token or "wechat" in token:
return "WeChat"
"""根据 token 判断通知渠道"""
token = token.lower()
letters_only = ''.join(re.findall(r'[A-Za-z]', token))
if token.lower().startswith("sct"):
if "wechat" in token:
return "WeChat"
if token.startswith("sct"):
return "ServerChan"
elif letters_only.isupper():
return "AnPush"
elif "iyuu" in token:
return "IYUU"
else:
return "PushPlus"
@@ -195,8 +205,8 @@ class MySender:
return self._send_wechat(title, content, image, token)
elif channel == "ServerChan":
return self._send_serverchan(title, content, image, diy_token)
elif channel == "AnPush":
return self._send_anpush(title, content, image, diy_token)
elif channel == "IYUU":
return self._send_iyuu(title, content, image, diy_token)
elif channel == "PushPlus":
return self._send_pushplus(title, content, image, diy_token)
else:
@@ -250,30 +260,38 @@ class MySender:
return f"Server酱通知错误: {result.get('message')}"
return None
def _send_anpush(self, title, content, image, diy_token=None):
def _send_iyuu(self, title, content, image, diy_token=None):
"""
发送爱语飞飞通知
:param title: 通知标题
:param content: 通知内容(无图片时使用)
:param image: 图片URL有图片时使用会覆盖content
:param diy_token: 自定义token若为None则从self.tokens中取
:return: 成功返回None失败返回错误字符串
"""
# 确定使用的token
if diy_token:
token = diy_token
else:
token = self.tokens[self.current_index] # 获取当前通道对应的 token
if ',' in token:
channel, token = token.split(',', 1)
token = self.tokens[self.current_index] # 假设self.tokens存在
# 构造URL和请求体
url = f"https://iyuu.cn/{token}.send"
headers = {"Content-Type": "application/json; charset=UTF-8"}
if image:
desp = f'<img src="{image}" style="max-width: 100%; height: auto;" />'
else:
return "AnPush可能没有配置消息通道ID"
url = f"https://api.anpush.com/push/{token}"
payload = {
"title": title,
"content": f"<img src=\"{image}\" width=\"100%\">" if image else content,
"channel": channel
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, headers=headers, data=payload)
result = response.json()
# 判断返回的code和msgIds
if result.get('code') != 200:
return f"AnPush: {result.get('msg')}"
elif not result.get('data') or not result['data'].get('msgIds'):
return "AnPush 消息通道未找到"
return None
desp = content # 发送文字内容
payload = {"text": title, "desp": desp}
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
result = response.json()
if result.get("errcode") != 0:
return f"爱语飞飞通知错误: {result.get('errmsg')}"
return None
except Exception as e:
return f"爱语飞飞请求异常: {str(e)}"
def _send_pushplus(self, title, content, image, diy_token=None):
if diy_token:
@@ -530,3 +548,69 @@ class IpLocationParser:
# 写入更新后的 IP 地址
self.overwrite_ips(field, updated_ips)
class JsonFieldManager:
"""
通用 JSON 配置文件字段管理器。
所有操作均遵循「读-改-写」模式,确保不修改无关字段。
"""
def __init__(self, settings_file_path: str):
self._settings_file_path = settings_file_path
def _load(self) -> dict:
"""读取完整 JSON 内容;若文件损坏或不存在则返回空字典。"""
try:
with open(self._settings_file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def _save(self, data: dict) -> None:
"""直接写入原文件。"""
with open(self._settings_file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
# -------------------------- 公开接口 --------------------------
def get(self, field: str, default: Any = None) -> Any:
"""
读取指定字段的值。
:param field: 字段名
:param default: 字段不存在时返回的默认值
:return: 字段值或 default
"""
return self._load().get(field, default)
def add(self, field: str, value: Any) -> bool:
"""
添加新字段。若字段已存在则返回 False不会修改任何已有数据。
:param field: 要添加的字段名
:param value: 字段值
:return: 是否添加成功
"""
data = self._load()
if field in data:
return False
data[field] = value
self._save(data)
return True
def update(self, field: str, value: Any) -> None:
"""
修改指定字段的值;若字段不存在则自动创建。
不修改其他字段。
:param field: 要修改或创建的字段名
:param value: 新值
"""
data = self._load()
data[field] = value
self._save(data)
def set(self, field: str, value: Any) -> None:
"""
新增或修改字段(字段不存在则创建,存在则覆盖)。
不修改其他字段。与 update 行为一致。
"""
self.update(field, value)

View File

@@ -105,5 +105,5 @@ POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick
## 依赖
```txt
lark-oapi==1.5.3
lark-oapi>=1.4.0
```

View File

@@ -1 +1 @@
lark-oapi==1.5.3
lark-oapi>=1.4.0

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any, Callable, Coroutine, Dict, Optional, List, Tuple
from urllib.parse import quote
import zhconv
from zhconv_rs import zhconv as zhconv_convert
from apscheduler.triggers.cron import CronTrigger
from fastapi import Query
@@ -35,7 +35,7 @@ class ImdbSource(_PluginBase):
# 插件图标
plugin_icon = "IMDb_IOS-OSX_App.png"
# 插件版本
plugin_version = "1.6.8"
plugin_version = "1.6.9"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -1931,7 +1931,7 @@ class ImdbSource(_PluginBase):
meta.type = mtype
info: Optional[ImdbMediaInfo] = None
# 简体名称
zh_name = zhconv.convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
zh_name = zhconv_convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
media_names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
names: list[str] = [name for name in media_names if isinstance(name, str)]
for name in names:
@@ -2003,7 +2003,7 @@ class ImdbSource(_PluginBase):
meta.type = mtype
info: Optional[ImdbMediaInfo] = None
# 简体名称
zh_name = zhconv.convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
zh_name = zhconv_convert(meta.cn_name, 'zh-hans') if meta.cn_name else None
media_names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
names: list[str] = [name for name in media_names if isinstance(name, str)]
for name in names:

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.15"
plugin_version = "2.17"
# 插件作者
plugin_author = "jxxghp,CKun"
# 作者主页
@@ -88,6 +88,8 @@ class IYUUAutoSeed(_PluginBase):
_success_caches = []
# 辅种缓存出错的种子不再重复辅种且无法清除。种子被删除404等情况
_permanent_error_caches = []
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
_seed_cache_max_items = 10000
# 辅种计数
total = 0
realtotal = 0
@@ -97,6 +99,9 @@ class IYUUAutoSeed(_PluginBase):
cached = 0
def init_plugin(self, config: dict = None):
self._error_caches = []
self._success_caches = []
self._permanent_error_caches = []
# 读取配置
if config:
@@ -118,9 +123,14 @@ class IYUUAutoSeed(_PluginBase):
self._addhosttotag = config.get("addhosttotag")
self._size = float(config.get("size")) if config.get("size") else 0
self._clearcache = config.get("clearcache")
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
self._permanent_error_caches = (
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
)
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
self.__trim_seed_cache(self._permanent_error_caches)
self.__trim_seed_cache(self._error_caches)
self.__trim_seed_cache(self._success_caches)
# 过滤掉已删除的站点
all_sites = [site.id for site in SiteOper().list_order_by_pri()] + [site.get("id") for site in
@@ -130,6 +140,8 @@ class IYUUAutoSeed(_PluginBase):
# 停止现有任务
self.stop_service()
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
self._recheck_torrents = {}
# 启动定时任务 & 立即运行一次
if self.get_state() or self._onlyonce:
@@ -680,6 +692,32 @@ class IYUUAutoSeed(_PluginBase):
"permanent_error_caches": self._permanent_error_caches
})
def __trim_seed_cache(self, cache: list):
"""
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
"""
if not cache:
return
unique_cache = []
seen = set()
for item in reversed(cache):
if not item or item in seen:
continue
seen.add(item)
unique_cache.append(item)
unique_cache.reverse()
cache[:] = unique_cache[-self._seed_cache_max_items:]
def __append_seed_cache(self, cache: list, value: str):
"""
写入辅种缓存并保持上限,重复值只保留一份。
"""
if not value:
return
if value not in cache:
cache.append(value)
self.__trim_seed_cache(cache)
def auto_seed(self):
"""
开始辅种
@@ -1036,7 +1074,7 @@ class IYUUAutoSeed(_PluginBase):
site_url, download_page = self.iyuu_helper.get_torrent_url(seed.get("sid"))
if not site_url or not download_page:
# 加入缓存
self._error_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
self.fail += 1
self.cached += 1
return False
@@ -1071,7 +1109,7 @@ class IYUUAutoSeed(_PluginBase):
base_url=download_page)
if not torrent_url:
# 加入失败缓存
self._error_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
self.fail += 1
self.cached += 1
return False
@@ -1092,10 +1130,10 @@ class IYUUAutoSeed(_PluginBase):
self.fail += 1
# 加入失败缓存
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
self._error_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
else:
# 种子不存在的情况
self._permanent_error_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._permanent_error_caches, seed.get("info_hash"))
logger.error(f"下载种子文件失败:{torrent_url}")
return False
# 添加下载,辅种任务默认暂停
@@ -1109,7 +1147,7 @@ class IYUUAutoSeed(_PluginBase):
# 下载失败
self.fail += 1
# 加入失败缓存
self._error_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._error_caches, seed.get("info_hash"))
return False
else:
self.success += 1
@@ -1130,7 +1168,7 @@ class IYUUAutoSeed(_PluginBase):
# 下载成功
logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}")
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
self._success_caches.append(seed.get("info_hash"))
self.__append_seed_cache(self._success_caches, seed.get("info_hash"))
return True
def __add_recheck_torrents(self, service: ServiceInfo, download_id: str):

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):
"""

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]:
"""
获取媒体项目的播放链接

8
plugins.v2/oidcauth/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# oidcauth plugin - source only
src/
package.json
package-lock.json
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

@@ -0,0 +1,837 @@
import hashlib
import json
import secrets
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app import schemas
from app.core.auth_bridge import create_plugin_auth_ticket
from app.core.config import settings
from app.db.models.user import User
from app.db.user_oper import get_current_active_user
from app.log import logger
from app.plugins import _PluginBase
class OidcAuth(_PluginBase):
"""
OIDC 认证插件。
"""
plugin_name = "OIDC 认证"
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"
plugin_order = 36
_STATE_TTL_SECONDS = 300
_PLUGIN_ID = "OidcAuth"
def __init__(self):
"""
初始化插件运行状态。
"""
super().__init__()
self._enabled = False
self._config: Dict[str, Any] = {}
self._states: Dict[str, Dict[str, Any]] = {}
self._state_lock = threading.RLock()
def init_plugin(self, config: dict = None):
"""
初始化插件配置。
:param config: 插件配置
"""
self._config = self._normalize_config(config or {})
self._enabled = bool(self._config.get("enabled"))
def get_state(self) -> bool:
"""
获取插件启用状态。
:return: 是否启用
"""
return bool(self._enabled)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
获取插件命令列表。
:return: 命令列表
"""
return []
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件 API。
:return: API 路由声明列表
"""
return [
{
"path": "/public/status",
"endpoint": self.public_status,
"methods": ["GET"],
"summary": "查询 OIDC 登录公开状态",
"allow_anonymous": True,
},
{
"path": "/authorize",
"endpoint": self.authorize,
"methods": ["GET"],
"summary": "发起 OIDC 登录",
"allow_anonymous": True,
},
{
"path": "/callback",
"endpoint": self.callback,
"methods": ["GET"],
"summary": "OIDC 回调",
"allow_anonymous": True,
},
{
"path": "/status",
"endpoint": self.status,
"methods": ["GET"],
"auth": "bear",
"summary": "查询 OIDC 插件状态",
},
{
"path": "/config",
"endpoint": self.save_config_api,
"methods": ["POST"],
"auth": "bear",
"summary": "保存 OIDC 插件配置",
},
{
"path": "/test",
"endpoint": self.test_api,
"methods": ["POST"],
"auth": "bear",
"summary": "测试 OIDC Provider",
},
{
"path": "/bind/start",
"endpoint": self.bind_start,
"methods": ["POST"],
"auth": "bear",
"summary": "发起 OIDC 账号绑定",
},
{
"path": "/unbind",
"endpoint": self.unbind,
"methods": ["POST"],
"auth": "bear",
"summary": "解绑 OIDC 账号",
},
]
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""
声明插件使用 Vue 联邦组件。
:return: 渲染模式与构建产物路径
"""
return "vue", "assets"
def get_auth_providers(self) -> List[Dict[str, Any]]:
"""
声明未登录页面可展示的认证入口。
:return: 认证入口列表
"""
if not self._is_login_ready():
return []
return [
{
"id": "oidc",
"name": self._config.get("provider_name") or "OIDC 登录",
"icon": "mdi-openid",
"component": "AuthPage",
"enabled": True,
}
]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
返回 Vue 模式配置表单占位。
:return: 表单配置与默认模型
"""
return [], self._config
def get_page(self) -> List[dict]:
"""
返回 Vue 模式详情页占位。
:return: 页面配置
"""
return []
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""
声明插件侧栏管理入口。
不设置 permission 限制,所有登录用户均可访问。
:return: 侧栏导航项
"""
if not self.get_state():
return []
return [
{
"nav_key": "main",
"title": "OIDC 认证",
"icon": "mdi-openid",
"section": "system",
"order": 47,
}
]
def stop_service(self):
"""
停止插件服务。
"""
with self._state_lock:
self._states.clear()
def public_status(self) -> schemas.Response:
"""
查询匿名可见的 OIDC 登录状态。
:return: 公开状态响应
"""
return schemas.Response(
success=True,
data={
"enabled": self._is_login_ready(),
"name": self._config.get("provider_name") or "OIDC 登录",
"icon": "mdi-openid",
},
)
async def authorize(self, request: Request) -> RedirectResponse:
"""
发起 OIDC 登录授权。
:param request: 当前请求
:return: IdP 授权跳转响应
"""
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
)
return RedirectResponse(authorize_url)
async def callback(
self,
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
) -> HTMLResponse:
"""
处理 OIDC 登录或绑定回调。
:param request: 当前请求
:param code: 授权码
:param state: CSRF state
:param error: IdP 返回的错误码
:param error_description: IdP 返回的错误描述
:return: 回调 HTML
"""
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 回调参数不完整"
)
state_data = self._pop_state(state)
if not state_data:
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", event_type=event_type
)
if action == "bind":
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), event_type=event_type)
def status(
self, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
查询当前用户绑定状态和管理员配置。
:param current_user: 当前登录用户
:return: 插件状态响应
"""
binding = self._get_user_binding(current_user.id)
data: Dict[str, Any] = {
"public": {
"enabled": self._is_login_ready(),
"name": self._config.get("provider_name") or "OIDC 登录",
"redirect_uri": self._configured_or_display_redirect_uri(),
},
"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:
"""
保存 OIDC 插件配置。
:param config: 前端提交的配置
:param current_user: 当前登录用户
:return: 保存结果
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="用户权限不足")
normalized = self._normalize_config(config or {})
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:
"""
测试 OIDC Provider 发现文档。
:param body: 待测试配置
:param current_user: 当前登录用户
:return: 测试结果
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="用户权限不足")
test_config = self._normalize_config({**self._config, **(body or {})})
try:
discovery = await self._get_discovery(test_config)
missing = [
key
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=True, message="OIDC Provider 连接正常")
except Exception as err:
return schemas.Response(success=False, message=str(err))
async def bind_start(
self,
request: Request,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
发起当前用户的 OIDC 绑定流程。
:param request: 当前请求
:param current_user: 当前登录用户
:return: 授权地址
"""
self._ensure_login_ready()
if self._get_user_binding(current_user.id):
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
)
return schemas.Response(success=True, data={"authorize_url": authorize_url})
def unbind(
self, current_user: User = Depends(get_current_active_user)
) -> schemas.Response:
"""
解绑当前用户的 OIDC 账号。
:param current_user: 当前登录用户
:return: 解绑结果
"""
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._user_key(current_user.id))
return schemas.Response(success=True)
def _normalize_config(self, config: dict) -> Dict[str, Any]:
"""
规范化插件配置。
:param config: 原始配置
:return: 规范化后的配置
"""
return {
"enabled": bool(config.get("enabled")),
"provider_name": str(config.get("provider_name") or "OIDC 登录"),
"issuer": str(config.get("issuer") or "").strip().rstrip("/"),
"client_id": str(config.get("client_id") or "").strip(),
"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(),
"email_claim": str(config.get("email_claim") or "email").strip(),
"allow_auto_bind_by_username": bool(
config.get("allow_auto_bind_by_username")
),
}
def _is_login_ready(self) -> bool:
"""
判断 OIDC 登录是否具备最小可用配置。
:return: 是否可用
"""
return bool(
self._enabled
and self._config.get("issuer")
and self._config.get("client_id")
and self._config.get("client_secret")
)
def _ensure_login_ready(self) -> None:
"""
确认 OIDC 登录已可用。
"""
if not self._is_login_ready():
raise HTTPException(status_code=400, detail="OIDC 登录未启用或配置不完整")
async def _get_discovery(self, config: Optional[dict] = None) -> dict:
"""
获取 OIDC Provider 发现文档。
:param config: 指定配置,未传入时使用当前配置
:return: 发现文档
"""
oidc_config = config or self._config
issuer = str(oidc_config.get("issuer") or "").rstrip("/")
if not issuer:
raise ValueError("OIDC issuer 未配置")
discovery_url = (
issuer
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 or None
) as client:
response = await client.get(discovery_url)
response.raise_for_status()
return response.json()
async def _build_authorize_url(self, redirect_uri: str, state: str) -> str:
"""
构造 OIDC 授权地址。
:param redirect_uri: 回调地址
:param state: CSRF state
:return: 授权地址
"""
discovery = await self._get_discovery()
authorization_endpoint = discovery.get("authorization_endpoint")
if not authorization_endpoint:
raise ValueError("OIDC 发现文档缺少 authorization_endpoint")
params = {
"client_id": self._config.get("client_id"),
"response_type": "code",
"scope": self._config.get("scopes") or "openid profile email",
"redirect_uri": redirect_uri,
"state": state,
}
return f"{authorization_endpoint}?{urlencode(params)}"
async def _exchange_code(self, code: str, redirect_uri: str) -> dict:
"""
使用授权码换取 Token。
:param code: 授权码
:param redirect_uri: 回调地址
:return: Token 响应
"""
discovery = await self._get_discovery()
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 or None
) as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._config.get("client_id"),
"client_secret": self._config.get("client_secret"),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
async def _fetch_userinfo(self, token_data: dict) -> dict:
"""
使用 Access Token 获取用户信息。
:param token_data: Token 响应
:return: 用户信息
"""
access_token = token_data.get("access_token")
if not access_token:
raise ValueError("OIDC Token 响应缺少 access_token")
discovery = await self._get_discovery()
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 or None
) as client:
response = await client.get(
userinfo_endpoint, headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()
return response.json()
def _handle_login_callback(self, userinfo: dict, sub: str) -> HTMLResponse:
"""
处理 OIDC 登录回调。
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 回调 HTML
"""
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
)
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 用户"
)
if not user.is_active:
return self._callback_html(False, "user_inactive", "用户已被禁用")
ticket = create_plugin_auth_ticket(
user_id=user.id,
provider_id=f"{self._PLUGIN_ID}:oidc",
metadata={"sub": sub, "issuer": issuer},
)
return self._callback_html(True, data={"ticket": ticket})
def _handle_bind_callback(
self, state_data: dict, userinfo: dict, sub: str
) -> HTMLResponse:
"""
处理 OIDC 绑定回调。
:param state_data: state 中保存的绑定上下文
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 回调 HTML
"""
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",
)
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",
)
if self._get_user_binding(user.id):
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"
)
def _auto_bind_by_username(self, userinfo: dict, sub: str) -> Optional[User]:
"""
按用户名 claim 自动绑定已有用户。
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 绑定成功的用户
"""
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)
if not user or not user.is_active or self._get_user_binding(user.id):
return None
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
issuer = self._config.get("issuer") or ""
self.save_data(self._user_key(user.id), binding)
self.save_data(self._sub_key(issuer, sub), binding)
return user
def _binding_payload(self, user_id: int, userinfo: dict, sub: str) -> dict:
"""
构造绑定数据。
:param user_id: 本地用户 ID
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 绑定数据
"""
return {
"user_id": user_id,
"issuer": self._config.get("issuer") or "",
"sub": sub,
"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()),
}
def _create_state(self, action: str, user_id: Optional[int] = None) -> str:
"""
创建并缓存 OIDC state。
:param action: login 或 bind
:param user_id: 绑定用户 ID
:return: state 字符串
"""
state = secrets.token_urlsafe(32)
with self._state_lock:
self._cleanup_states()
self._states[state] = {
"action": action,
"user_id": user_id,
"created_at": time.time(),
}
return state
def _pop_state(self, state: str) -> Optional[dict]:
"""
取出并删除 OIDC state。
:param state: state 字符串
:return: state 数据
"""
with self._state_lock:
data = self._states.pop(state, None)
self._cleanup_states()
if not data:
return None
if time.time() - float(data.get("created_at") or 0) > self._STATE_TTL_SECONDS:
return None
return data
def _cleanup_states(self) -> None:
"""
清理过期 state。
"""
now = time.time()
expired = [
key
for key, value in self._states.items()
if now - float(value.get("created_at") or 0) > self._STATE_TTL_SECONDS
]
for key in expired:
self._states.pop(key, None)
def _callback_url(self, request: Request) -> str:
"""
生成 OIDC 回调地址。
:param request: 当前请求
:return: 回调地址
"""
if self._config.get("redirect_uri"):
return self._config["redirect_uri"]
path = f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
if settings.MP_DOMAIN(path):
return settings.MP_DOMAIN(path)
return f"{str(request.base_url).rstrip('/')}{path}"
def _configured_or_display_redirect_uri(self) -> str:
"""
获取展示用回调地址。
:return: 回调地址或默认路径
"""
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]:
"""
获取用户绑定信息。
:param user_id: 本地用户 ID
:return: 绑定信息
"""
return self.get_data(self._user_key(user_id))
@staticmethod
def _user_key(user_id: int) -> str:
"""
构造用户绑定数据键。
:param user_id: 本地用户 ID
:return: 数据键
"""
return f"binding:user:{user_id}"
@staticmethod
def _sub_key(issuer: str, sub: str) -> str:
"""
构造 OIDC subject 反查数据键。
:param issuer: OIDC issuer
:param sub: OIDC subject
:return: 数据键
"""
digest = hashlib.sha256(f"{issuer}|{sub}".encode("utf-8")).hexdigest()
return f"binding:sub:{digest}"
@staticmethod
def _mask_sub(sub: Optional[str]) -> str:
"""
脱敏 OIDC subject。
:param sub: OIDC subject
:return: 脱敏字符串
"""
if not sub:
return ""
value = str(sub)
return f"{value[:6]}***" if len(value) > 6 else f"{value}***"
def _callback_html(
self,
success: bool,
error: Optional[str] = None,
message: Optional[str] = None,
data: Optional[dict] = None,
event_type: str = "oidcauth_callback",
) -> HTMLResponse:
"""
构造回调 HTML通过 postMessage 通知插件联邦页面。
:param success: 是否成功
:param error: 错误码
:param message: 错误信息
:param data: 成功数据
:param event_type: postMessage 事件类型
:return: HTML 响应
"""
payload = {
"type": event_type,
"success": success,
"error": error,
"message": message,
"data": data or {},
}
payload_json = json.dumps(payload, ensure_ascii=False)
html = f"""<!doctype html>
<html>
<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);
setTimeout(function() {{ window.close(); }}, 200);
}} else {{
document.getElementById('msg').innerText = payload.success ? '认证成功,请关闭此窗口' : (payload.message || '认证失败');
}}
}})();
</script>
</body>
</html>"""
return HTMLResponse(content=html)

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

@@ -0,0 +1,219 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
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 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,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AuthPage',
props: {
api: {
type: Object,
default: () => ({}),
},
provider: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
emits: ['authenticated', 'error', 'close'],
setup(__props, { emit: __emit }) {
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 登录');
/** 拼接 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
messageReceived = true;
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 是否已启用,再决定是否发起授权弹窗。 */
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`),
'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 && !messageReceived) {
loading.value = false;
errorMessage.value = '认证窗口已关闭';
emit('error', { message: errorMessage.value });
}
}, 500);
}
/** 组件挂载后自检,通过后自动发起登录。 */
onMounted(() => {
checkAndStart();
});
/** 组件卸载时清理监听器和定时器。 */
onUnmounted(() => {
clearPopupTimer();
window.removeEventListener('message', handleOidcMessage);
});
return (_ctx, _cache) => {
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
const _component_VAlert = _resolveComponent("VAlert");
const _component_VBtn = _resolveComponent("VBtn");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(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-2"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
(!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)
]))
}
}
};
export { _sfc_main as default };

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

@@ -0,0 +1,90 @@
const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./AuthPage":()=>{
dynamicLoadingCss([], false, './AuthPage');
return __federation_import('./__federation_expose_AuthPage-ByDbUb5c.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./AppPage":()=>{
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-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-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;
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__OidcAuth__' + 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,5 @@
<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

@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, List, Dict, Tuple, Optional
import pytz
import zhconv
from zhconv_rs import zhconv as zhconv_convert
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from requests import RequestException
@@ -38,7 +38,7 @@ class PersonMeta(_PluginBase):
# 插件图标
plugin_icon = "actor.png"
# 插件版本
plugin_version = "2.2.2"
plugin_version = "2.2.4"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -62,6 +62,10 @@ class PersonMeta(_PluginBase):
_type = "all"
_remove_nozh = False
_mediaservers = []
_rt_lock = threading.Lock()
_rt_running_keys = set()
_rt_recent_keys: Dict[str, float] = {}
_rt_dedup_seconds = 600
def init_plugin(self, config: dict = None):
@@ -297,7 +301,8 @@ class PersonMeta(_PluginBase):
"cron": "",
"type": "all",
"delay": 30,
"remove_nozh": False
"remove_nozh": False,
"mediaservers": []
}
def get_page(self) -> List[dict]:
@@ -329,37 +334,97 @@ class PersonMeta(_PluginBase):
return active_services
@eventmanager.register(EventType.TransferComplete)
def __try_lock_rt_item(self, key: str, title: str = "") -> bool:
"""
登记实时刮削中的媒体项,避免同一媒体项在短时间内反复处理
"""
now = time.time()
with self._rt_lock:
if self._rt_dedup_seconds > 0:
for item_key, timestamp in list(self._rt_recent_keys.items()):
if now - timestamp >= self._rt_dedup_seconds:
self._rt_recent_keys.pop(item_key, None)
recent_time = self._rt_recent_keys.get(key)
if recent_time and now - recent_time < self._rt_dedup_seconds:
logger.info(f"{title or key} 最近已触发演职人员刮削,跳过重复处理")
return False
if key in self._rt_running_keys:
logger.info(f"{title or key} 正在执行演职人员刮削,跳过重复处理")
return False
self._rt_running_keys.add(key)
return True
def __unlock_rt_item(self, key: str, completed: bool = True):
"""
释放实时刮削占用,并在完成后记录防重时间
"""
with self._rt_lock:
self._rt_running_keys.discard(key)
if completed and self._rt_dedup_seconds > 0:
self._rt_recent_keys[key] = time.time()
@eventmanager.register([EventType.MetadataScrape, EventType.TransferComplete])
def scrap_rt(self, event: Event):
"""
根据事件实时刮削演员信息
根据刮削事件实时刮削演员信息
"""
if not self._enabled:
return
# 事件数据
if not event or not event.event_data:
logger.warn("TransferComplete事件数据为空")
logger.warn("演职人员刮削事件数据为空")
return
mediainfo: MediaInfo = event.event_data.get("mediainfo")
meta: MetaBase = event.event_data.get("meta")
if not mediainfo or not meta:
event_data = event.event_data
# 已开启元数据刮削的整理事件由 MetadataScrape 统一处理,避免 TransferComplete 逐文件重复触发
if event.event_type == EventType.TransferComplete:
transferinfo = event_data.get("transferinfo")
if transferinfo and getattr(transferinfo, "need_scrape", False):
logger.debug("整理完成事件已开启元数据刮削,等待 MetadataScrape 事件处理演职人员")
return
mediainfo: MediaInfo = event_data.get("mediainfo")
meta: MetaBase = event_data.get("meta")
if not mediainfo:
return
# 延迟
if self._delay:
time.sleep(int(self._delay))
# 查询媒体服务器中的条目
existsinfo = self.chain.media_exists(mediainfo=mediainfo)
if not existsinfo or not existsinfo.itemid:
logger.warn(f"{mediainfo.title_year} 在媒体库中不存在")
# 查询已配置媒体服务器中的条目
service_infos = self.service_infos()
if not service_infos:
return
# 查询条目详情
iteminfo = MediaServerChain().iteminfo(server=existsinfo.server, item_id=existsinfo.itemid)
if not iteminfo:
logger.warn(f"{mediainfo.title_year} 条目详情获取失败")
return
# 刮削演职人员信息
self.__update_item(server=existsinfo.server, server_type=existsinfo.server_type,
item=iteminfo, mediainfo=mediainfo, season=meta.begin_season)
matched = False
season = getattr(meta, "begin_season", None)
mediaserverchain = MediaServerChain()
title = getattr(mediainfo, "title_year", None) or getattr(mediainfo, "title", "") or "未知媒体"
for server, service in service_infos.items():
try:
existsinfo = self.chain.media_exists(mediainfo=mediainfo, server=server)
except Exception as err:
logger.error(f"查询媒体服务器 {server} 中的 {title} 失败:{err}")
continue
if not existsinfo or not existsinfo.itemid:
continue
matched = True
exists_server = existsinfo.server or server
server_type = existsinfo.server_type or service.type
item_key = f"{exists_server}:{server_type}:{existsinfo.itemid}"
if not self.__try_lock_rt_item(item_key, title):
continue
completed = False
try:
# 查询条目详情
iteminfo = mediaserverchain.iteminfo(server=exists_server, item_id=existsinfo.itemid)
if not iteminfo:
logger.warn(f"{title} 条目详情获取失败")
continue
# 刮削演职人员信息
self.__update_item(server=exists_server, server_type=server_type,
item=iteminfo, mediainfo=mediainfo, season=season)
completed = True
finally:
self.__unlock_rt_item(item_key, completed=completed)
if not matched:
logger.warn(f"{title} 在已配置媒体服务器中不存在")
def scrap_library(self):
"""
@@ -426,7 +491,7 @@ class PersonMeta(_PluginBase):
elif not self._remove_nozh:
peoples.append(people)
# 保存媒体项信息
if peoples:
if peoples or self._remove_nozh:
iteminfo["People"] = peoples
self.set_iteminfo(server=server, server_type=server_type,
itemid=itemid, iteminfo=iteminfo)
@@ -717,7 +782,10 @@ class PersonMeta(_PluginBase):
获得媒体项详情
"""
service = self.service_infos(server_type).get(server)
services = self.service_infos(server_type)
if not services:
return {}
service = services.get(server)
if not service:
logger.warn(f"未找到媒体服务器 {server} 的实例")
return {}
@@ -797,7 +865,10 @@ class PersonMeta(_PluginBase):
"""
获得媒体的所有子媒体项
"""
service = self.service_infos(server_type).get(server)
services = self.service_infos(server_type)
if not services:
return {}
service = services.get(server)
if not service:
logger.warn(f"未找到媒体服务器 {server} 的实例")
return {}
@@ -912,7 +983,10 @@ class PersonMeta(_PluginBase):
更新媒体项详情
"""
service = self.service_infos(server_type).get(server)
services = self.service_infos(server_type)
if not services:
return {}
service = services.get(server)
if not service:
logger.warn(f"未找到媒体服务器 {server} 的实例")
return {}
@@ -990,7 +1064,10 @@ class PersonMeta(_PluginBase):
更新媒体项图片
"""
service = self.service_infos(server_type).get(server)
services = self.service_infos(server_type)
if not services:
return {}
service = services.get(server)
if not service:
logger.warn(f"未找到媒体服务器 {server} 的实例")
return {}
@@ -1092,8 +1169,8 @@ class PersonMeta(_PluginBase):
if also_known_as:
for name in also_known_as:
if name and StringUtils.is_chinese(name):
# 使用cn2an将繁体转化为简体
return zhconv.convert(name, "zh-hans")
# 将繁体别名统一转为简体,便于媒体库名称匹配。
return zhconv_convert(name, "zh-hans")
except Exception as err:
logger.error(f"获取人物中文名失败:{err}")
return ""

View File

@@ -1 +0,0 @@
fast-bencode~=1.1.7

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

@@ -1,4 +1,6 @@
import base64
import hashlib
import json
from collections import deque
import threading
import time
@@ -44,6 +46,8 @@ class WechatClawBot(_PluginBase):
_LOGIN_WATCH_SECONDS = 240
_LOGIN_WATCH_INTERVAL_SECONDS = 3
_MAX_API_RETRY_FAILURES = 10
_INCOMING_DEDUP_TTL_SECONDS = 120
_MAX_INCOMING_CACHE_ITEMS = 4096
def __init__(self):
super().__init__()
@@ -61,6 +65,9 @@ class WechatClawBot(_PluginBase):
self._qrcode_prepare_lock = threading.Lock()
self._command_login_wait_threads: Dict[str, threading.Thread] = {}
self._command_login_wait_lock = threading.Lock()
self._incoming_seen_cache: Dict[str, int] = {}
self._incoming_seen_order = deque()
self._incoming_seen_lock = threading.Lock()
def _log(self, level: str, message: str):
"""记录插件日志到内存并输出到全局日志。"""
@@ -1351,6 +1358,71 @@ class WechatClawBot(_PluginBase):
except Exception as err:
self._log("warning", f"入站消息写入记录失败: {err}")
def _cleanup_incoming_seen_cache_locked(self, now_ts: int) -> None:
"""清理过期/溢出的入站消息去重缓存。"""
ttl = self._INCOMING_DEDUP_TTL_SECONDS
while self._incoming_seen_order:
key, ts = self._incoming_seen_order[0]
if (
now_ts - ts <= ttl
and len(self._incoming_seen_order) <= self._MAX_INCOMING_CACHE_ITEMS
):
break
self._incoming_seen_order.popleft()
cached_ts = self._incoming_seen_cache.get(key)
if cached_ts == ts:
self._incoming_seen_cache.pop(key, None)
def _build_incoming_dedup_key(
self, msg: ILinkIncomingMessage, text: str
) -> Optional[str]:
"""构建入站消息去重 key。优先使用 message_id缺失时退化为消息指纹。"""
user_id = str(msg.user_id or "").strip()
message_id = str(msg.message_id or "").strip()
if user_id and message_id:
return f"id:{user_id}:{message_id}"
if not user_id:
return None
raw_payload = msg.raw if isinstance(msg.raw, dict) else {"raw": str(msg.raw)}
payload = {
"user_id": user_id,
"chat_id": str(msg.chat_id or ""),
"context_token": str(msg.context_token or ""),
"text": text or "",
"raw": raw_payload,
}
try:
fingerprint = hashlib.sha1(
json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
except Exception:
return None
return f"fp:{fingerprint}"
def _is_duplicate_incoming(self, msg: ILinkIncomingMessage, text: str) -> bool:
"""判断入站消息是否为短时间内重复投递。"""
key = self._build_incoming_dedup_key(msg=msg, text=text)
if not key:
return False
now_ts = int(time.time())
with self._incoming_seen_lock:
self._cleanup_incoming_seen_cache_locked(now_ts)
last_ts = self._incoming_seen_cache.get(key)
if (
last_ts is not None
and now_ts - last_ts <= self._INCOMING_DEDUP_TTL_SECONDS
):
return True
self._incoming_seen_cache[key] = now_ts
self._incoming_seen_order.append((key, now_ts))
self._cleanup_incoming_seen_cache_locked(now_ts)
return False
def _is_plugin_command(self, text: str) -> bool:
"""判断是否为插件内置命令(需要插件自行处理)。"""
if not text or not text.startswith("/"):
@@ -1435,6 +1507,12 @@ class WechatClawBot(_PluginBase):
text = (msg.text or "").strip()
if not text:
return
if self._is_duplicate_incoming(msg=msg, text=text):
self._log(
"warning",
f"检测到重复入站消息,已忽略: user={msg.user_id}, message_id={msg.message_id or '-'}",
)
return
self._log("info", f"收到入站消息: user={msg.user_id}, text={text[:64]}")
self._touch_user(msg.user_id, msg.context_token)

View File

@@ -847,37 +847,17 @@ class ILinkClient:
"events",
"msg_list",
"msgList",
"msgs",
"add_msgs",
"addMsgs",
"records",
"list",
]
candidates = [
data.get("msgs"),
data.get("updates"),
data.get("messages"),
data.get("items"),
data.get("events"),
data.get("msg_list"),
data.get("msgList"),
data.get("msgs"),
data.get("add_msgs"),
data.get("addMsgs"),
payload.get("msgs"),
payload.get("updates"),
payload.get("messages"),
payload.get("events"),
payload.get("msg_list"),
payload.get("msgList"),
payload.get("msgs"),
payload.get("add_msgs"),
payload.get("addMsgs"),
]
for item in candidates:
if isinstance(item, list):
return item, sync_buf
for obj in [data, payload]:
for key in list_keys:
value = obj.get(key)
if isinstance(value, list):
return value, sync_buf
nested = self._find_first_list(data, prefer_keys=list_keys)
if isinstance(nested, list):

View File

@@ -33,7 +33,7 @@ except Exception:
_LARK_IMPORT_LOCK = threading.Lock()
_LARK_AUTO_INSTALL_ATTEMPTED = False
_LARK_PACKAGE_SPEC = "lark-oapi==1.5.3"
_LARK_PACKAGE_SPEC = "lark-oapi>=1.4.0"
try:
from app.chain.download import DownloadChain

View File

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

View File

@@ -50,6 +50,36 @@
- `request_timeout`
- `max_retries`
- `save_failed_samples`
- `save_title_only_samples`
- `max_failed_samples`
- `auto_remove_applied_sample`
- `clear_failed_samples_once`
## 新增数据面
### 可处理失败样本
用于承载低置信度、可继续分析和出队的样本数据,支持:
- 摘要列表
- 洞察汇总
- 重放复查
- 批量复查
- 批量建议
- 批量写入
- 清空与按索引出队
### LLM 错误诊断记录
用于承载超时、网络错误、模型不可用等 LLM 调用失败信息,和可处理失败样本分开存储,避免噪音污染主样本池。
### 样本来源标注
失败样本与诊断记录都会保留轻量 provenance 标记,便于区分:
- 路径样本
- 仅标题样本
- 来自哪个 source plugin
## 二期规划
@@ -78,6 +108,8 @@
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
- 已支持样本来源标注,便于区分路径样本与仅标题样本
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升

View File

@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- 用当前 LLM 结构化判断标题、年份、类型、季集
- 回写 `name / year / season / episode`
- 交回 MoviePilot 原生链路继续二次识别
- 保存低置信度失败样本
- 保存低置信度失败样本(可处理)
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
- 提供失败样本工作清单、洞察、重放、删除和清空能力
- 生成并应用 `CustomIdentifiers` 建议
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
## 主要接口
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
- 对单个标题做一次本地结构化识别测试
### 可处理失败样本接口
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
- 查看最近保存的失败样本
- 查看最近保存的可处理失败样本
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
- 返回适合继续处理的失败样本摘要列表
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
- 把建议规则写入系统 `CustomIdentifiers`
### LLM 诊断错误接口
这些接口返回因 LLM 调用异常(如超时、网络错误、模型不可用)而产生的诊断记录。它们不参与识别词生成流程,仅供排查 LLM 问题使用。
- `GET /api/v1/plugin/AIRecognizerEnhancer/llm_errors`
- 查看 LLM 调用失败的诊断记录
- `POST /api/v1/plugin/AIRecognizerEnhancer/clear_llm_errors`
- 清空 LLM 错误诊断记录
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
## 配置建议
- 先确认 MoviePilot 本身已经配置好可用的 LLM
- 建议保持保存失败样本”开启
- 建议保持保存失败样本”开启
- 默认情况下”保存仅标题样本”是关闭的,这可以减少没有真实文件路径的低价值噪音;如果你的使用场景以纯标题匹配为主,可以在设置中手动开启
- 如果失败样本池已经积累了大量历史噪音,可在设置页勾选“一次性清空”后保存
- 如果你经常处理历史资源或网盘资源,建议定期查看:
- `failed_samples`
- `sample_worklist`
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
## 已验证情况
当前版本:`0.1.12`
当前版本:`0.1.13`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
这版已经验证过:

View File

@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
plugin_name = "AI识别增强"
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
plugin_version = "0.1.12"
plugin_version = "0.1.13"
plugin_author = "liuyuexi1987"
plugin_level = 1
author_url = "https://github.com/liuyuexi1987"
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
_request_timeout = 25
_max_retries = 2
_save_failed_samples = True
_save_title_only_samples = False
_max_failed_samples = 200
_auto_remove_applied_sample = True
_clear_failed_samples_once = False
_systemconfig: Optional[SystemConfigOper] = None
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
self._save_failed_samples = bool(config.get("save_failed_samples", True))
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
self._systemconfig = SystemConfigOper()
self._register_events()
if self._clear_failed_samples_once:
cleared = self._clear_failed_samples()
self._clear_failed_samples_once = False
self.update_config(self._build_config({"clear_failed_samples_once": False}))
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared}")
def get_state(self) -> bool:
return self._enabled
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
if body:
for key in ("apikey", "api_key"):
for key in ("apikey", "api_key", "token"):
token = str(body.get(key) or "").strip()
if token:
return token
return str(request.query_params.get("apikey") or "").strip()
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = {
"enabled": self._enabled,
"debug": self._debug,
"confidence_threshold": self._confidence_threshold,
"request_timeout": self._request_timeout,
"max_retries": self._max_retries,
"save_failed_samples": self._save_failed_samples,
"save_title_only_samples": self._save_title_only_samples,
"max_failed_samples": self._max_failed_samples,
"auto_remove_applied_sample": self._auto_remove_applied_sample,
"clear_failed_samples_once": self._clear_failed_samples_once,
}
if overrides:
config.update(overrides)
return config
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
)
return str(title or "").strip(), str(path or "").strip()
@staticmethod
def _extract_provenance(event_data: Any) -> Dict[str, str]:
"""Extract lightweight provenance metadata from event data for sample recording."""
source_plugin = ""
if isinstance(event_data, dict):
source_plugin = str(event_data.get("source_plugin") or "").strip()
else:
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
title = ""
path = ""
if isinstance(event_data, dict):
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
else:
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
return {
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
"sample_source_plugin": source_plugin,
}
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
try:
meta = MetaInfo(raw_text)
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
def _sample_path(self) -> Path:
return self.get_data_path() / "failed_samples.jsonl"
def _llm_errors_path(self) -> Path:
return self.get_data_path() / "llm_errors.jsonl"
def _failed_sample_cap(self) -> int:
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
@staticmethod
def _sample_identity(payload: Dict[str, Any]) -> str:
return json.dumps(
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
sample_path = self._sample_path()
sample_path.parent.mkdir(parents=True, exist_ok=True)
trimmed = rows[-self._max_failed_samples:]
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
trimmed = filtered[-self._failed_sample_cap():]
with sample_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
except Exception as exc:
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
try:
error_path = self._llm_errors_path()
error_path.parent.mkdir(parents=True, exist_ok=True)
provenance = provenance or {}
entry = {
"title": title,
"path": path,
"meta_hint": meta_hint,
"reason": f"llm_error:{error}",
"timestamp": __import__("datetime").datetime.now().isoformat(),
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
existing = self._read_llm_errors(limit=1000)
existing.reverse()
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
existing = [
row for row in existing
if {
"title": row.get("title"),
"path": row.get("path"),
"reason": row.get("reason"),
} != new_identity
]
existing.append(entry)
trimmed = existing[-self._failed_sample_cap():]
with error_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
error_path = self._llm_errors_path()
if not error_path.exists():
return []
rows: List[Dict[str, Any]] = []
try:
with error_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except Exception:
continue
except Exception as exc:
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
return []
if limit > 0:
rows = rows[-limit:]
rows.reverse()
return rows
def _clear_llm_errors(self) -> int:
rows = self._read_llm_errors(limit=10000)
error_path = self._llm_errors_path()
if error_path.exists():
error_path.unlink()
return len(rows)
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
sample_path = self._sample_path()
if not sample_path.exists():
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
sample_index: Optional[Any] = None,
limit: int = 100,
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
if not samples:
return None, None, "暂无失败样本"
index = self._safe_int(sample_index, 0)
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
self,
sample_indexes: Optional[List[Any]] = None,
limit: int = 10,
pool_limit: int = 200,
pool_limit: int = 0,
) -> Tuple[List[int], List[Dict[str, Any]], str]:
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
if pool_limit <= 0:
pool_limit = self._failed_sample_cap()
current_samples = self._inject_sample_indices(
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
)
if not current_samples:
return [], [], "暂无失败样本"
if isinstance(sample_indexes, list) and sample_indexes:
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
"title": sample.get("title"),
"path": sample.get("path"),
"reason": sample.get("reason"),
"sample_source_kind": sample.get("sample_source_kind", ""),
"sample_source_plugin": sample.get("sample_source_plugin", ""),
"guess_name": guess.get("name"),
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
"verified_title": verified.get("title"),
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
label = self._sample_display_name(summary)
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
source_plugin = summary.get("sample_source_plugin") or ""
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
lines.append("下一步:可直接调用批量建议或批量复查接口。")
return "\n".join(lines)
@@ -937,7 +1066,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
return None
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
def _recognize(
self, title: str, path: str = "", record_failed_sample: bool = True,
provenance: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
title = str(title or "").strip()
path = str(path or "").strip()
if not title and path:
title = Path(path).name
if not title:
return {"success": False, "message": "标题为空"}
provenance = provenance or {}
sample_source_kind = provenance.get("sample_source_kind")
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
try:
guess = self._invoke_llm(title, path)
except Exception as exc:
if record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"reason": f"llm_error:{exc}",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
else:
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
return {"success": False, "message": f"LLM 调用失败: {exc}"}
verified = self._verify_guess(title, path, guess)
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
if not passed and record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
else:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
)
return {
"success": passed,
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
title, path = self._extract_title_path(event_data)
if not title and not path:
return
result = self._recognize(title=title, path=path)
provenance = self._extract_provenance(event_data)
result = self._recognize(title=title, path=path, provenance=provenance)
if not result.get("success"):
if self._debug:
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 50)
limit = max(1, min(limit, 200))
limit = max(1, min(limit, self._failed_sample_cap()))
top = self._safe_int(request.query_params.get("top"), 10)
top = max(1, min(top, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 5)
limit = max(1, min(limit, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
return {
"success": True,
"data": {
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
},
}
async def api_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 20)
limit = max(1, min(limit, 100))
errors = self._read_llm_errors(limit=limit)
return {
"success": True,
"data": {
"count": len(errors),
"errors": errors,
},
}
async def api_clear_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
cleared = self._clear_llm_errors()
return {
"success": True,
"message": "success",
"data": {
"cleared_count": cleared,
},
}
async def api_remove_failed_sample(self, request: Request):
body = await request.json()
ok, message = self._check_api_access(request, body)
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
"methods": ["POST"],
"summary": "清空失败样本文件",
},
{
"path": "/llm_errors",
"endpoint": self.api_llm_errors,
"methods": ["GET"],
"summary": "查看 LLM 调用失败的诊断记录",
},
{
"path": "/clear_llm_errors",
"endpoint": self.api_clear_llm_errors,
"methods": ["POST"],
"summary": "清空 LLM 错误诊断记录",
},
{
"path": "/remove_failed_sample",
"endpoint": self.api_remove_failed_sample,
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
def get_page(self) -> List[dict]:
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
failed_samples_count = len(self._read_failed_samples(limit=200))
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
custom_identifiers_count = len(self._get_custom_identifiers())
llm_provider = getattr(settings, "LLM_PROVIDER", "")
llm_model = getattr(settings, "LLM_MODEL", "")
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 可用", "" if llm_ready else "", f"{llm_provider} / {llm_model}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"content": [stat_card("失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("可处理失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 错误", f"{llm_errors_count}", "诊断记录")],
},
{
"component": "VCol",
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("自定义识别词", f"{custom_identifiers_count}", "系统 CustomIdentifiers")],
},
],
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"props": {"variant": "outlined", "class": "pa-4 h-100"},
"content": [
{
"component": "div",
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
"text": "识别兜底",
},
{
"component": "div",
"props": {"class": "text-body-2 text-medium-emphasis"},
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode供 MoviePilot 继续原生二次识别。",
},
{
"component": "div",
"props": {"class": "text-caption text-medium-emphasis mt-3"},
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout}",
},
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"props": {"cols": 12, "md": 12},
"content": [
{
"component": "VCard",
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
return "vuetify", None
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
form = [
{
"component": "VForm",
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VAlert",
"props": {
"type": "warning",
"variant": "tonal",
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "save_title_only_samples",
"label": "保存仅标题样本",
},
}
],
},
],
},
{
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VSwitch",
"props": {
"model": "clear_failed_samples_once",
"label": "保存时清空失败样本(一次性)",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
"request_timeout": 25,
"max_retries": 2,
"save_failed_samples": True,
"save_title_only_samples": False,
"max_failed_samples": 200,
"auto_remove_applied_sample": True,
"clear_failed_samples_once": False,
}

View File

@@ -180,7 +180,7 @@ class CrossSeed(_PluginBase):
# 插件图标
plugin_icon = "qingwa.png"
# 插件版本
plugin_version = "2.4"
plugin_version = "2.4.1"
# 插件作者
plugin_author = "233@qingwa"
# 作者主页
@@ -224,6 +224,8 @@ class CrossSeed(_PluginBase):
_success_caches = []
# 辅种缓存出错的种子不再重复辅种且无法清除。种子被删除404等情况
_permanent_error_caches = []
# 辅种缓存最大保存条数,避免长期运行时配置缓存无限增长
_seed_cache_max_items = 10000
_torrentpaths = []
_site_cs_infos = []
# 辅种计数
@@ -238,6 +240,11 @@ class CrossSeed(_PluginBase):
self.sites = SitesHelper()
self.siteoper = SiteOper()
self.torrent = TorrentHelper()
self._error_caches = []
self._success_caches = []
self._permanent_error_caches = []
self._torrentpaths = []
self._site_cs_infos = []
# 读取配置
if config:
self._enabled = config.get("enabled")
@@ -253,9 +260,14 @@ class CrossSeed(_PluginBase):
self._nolabels = config.get("nolabels")
self._nopaths = config.get("nopaths")
self._clearcache = config.get("clearcache")
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
self._permanent_error_caches = (
[] if self._clearcache else list(config.get("permanent_error_caches") or [])
)
self._error_caches = [] if self._clearcache else list(config.get("error_caches") or [])
self._success_caches = [] if self._clearcache else list(config.get("success_caches") or [])
self.__trim_seed_cache(self._permanent_error_caches)
self.__trim_seed_cache(self._error_caches)
self.__trim_seed_cache(self._success_caches)
# 过滤掉已删除的站点
inner_site_list = self.siteoper.list_order_by_pri()
@@ -325,6 +337,8 @@ class CrossSeed(_PluginBase):
# 停止现有任务
self.stop_service()
# 重新初始化运行期校验队列,避免类级字典跨插件重载残留。
self._recheck_torrents = {}
# 启动定时任务 & 立即运行一次
if self.get_state() or self._onlyonce:
@@ -741,6 +755,32 @@ class CrossSeed(_PluginBase):
"permanent_error_caches": self._permanent_error_caches
})
def __trim_seed_cache(self, cache: list):
"""
去重并限制辅种缓存大小,避免长期任务把配置缓存无限撑大。
"""
if not cache:
return
unique_cache = []
seen = set()
for item in reversed(cache):
if not item or item in seen:
continue
seen.add(item)
unique_cache.append(item)
unique_cache.reverse()
cache[:] = unique_cache[-self._seed_cache_max_items:]
def __append_seed_cache(self, cache: list, value: str):
"""
写入辅种缓存并保持上限,重复值只保留一份。
"""
if not value:
return
if value not in cache:
cache.append(value)
self.__trim_seed_cache(cache)
def __get_downloader(self, dtype: str):
"""
根据类型返回下载器实例
@@ -1087,10 +1127,10 @@ class CrossSeed(_PluginBase):
self.cached += 1
# 加入失败缓存
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
self._error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
else:
# 种子不存在的情况
self._permanent_error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._permanent_error_caches, tor.get_name_id_tag())
logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
return False
@@ -1100,7 +1140,7 @@ class CrossSeed(_PluginBase):
tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash])
if tors:
self.exist += 1
self._success_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
return True
else:
@@ -1116,7 +1156,7 @@ class CrossSeed(_PluginBase):
self.fail += 1
self.cached += 1
# 加入失败缓存
self._error_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._error_caches, tor.get_name_id_tag())
return False
else:
self.success += 1
@@ -1132,7 +1172,7 @@ class CrossSeed(_PluginBase):
# 开始校验种子
self.__get_downloader(downloader).recheck_torrents(ids=[download_id])
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
self._success_caches.append(tor.get_name_id_tag())
self.__append_seed_cache(self._success_caches, tor.get_name_id_tag())
return True
@staticmethod

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