diff --git a/README.md b/README.md
index ab89f02..d41afab 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,46 @@
MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/
+# 保持插件最新
+
+- 安装并开启`插件自动更新`插件,每次重启会更新已安装插件最新版本。也可设置cron定时任务更新插件。
+- 如未刷新到插件更新,或者插件更新版本未变,可用`插件强制重装`插件进行重装。
+
### 插件新增
-- 云盘助手(docs%2FCloudAssistant.md) v1.7
\ No newline at end of file
+- 站点数据统计 v1.4 (无未读消息版本)(废弃)
+- 站点未读消息 v1.9 (依赖于[站点数据统计]插件)
+- [云盘Strm生成 v4.4](docs%2FCloudStrm.md)
+- [云盘Strm生成(增量版) v1.0](docs%2FCloudStrmIncrement.md)
+- [Strm文件模式转换 v1.0](docs%2FStrmConvert.md)
+- 清理订阅缓存 v1.0
+- 添加种子下载 v1.0
+- 删除站点种子 v1.2
+- 插件更新管理 v1.9
+- 插件强制重装 v1.7
+- 群辉Webhook通知 v1.1
+- 同步CookieCloud v1.2
+- 日程提醒 v1.0
+- 订阅提醒 v1.1
+- [Emby观影报告 v1.5](docs%2FEmbyReporter.md)
+- 演员订阅 v2.1
+- [短剧刮削 v3.2](docs%2FShortPlayMonitor.md)
+- 云盘实时监控 v2.2
+- 源文件恢复 v1.2
+- [微信消息转发 v2.7](docs%2FWeChatForward.md)
+- 订阅下载统计 v1.5
+- [自定义命令 v1.7](docs%2FCustomCommand.md)
+- docker自定义任务 v1.3
+- 插件彻底卸载 v1.0
+- 实时软连接 v1.8
+- 订阅规则自动填充 v2.7
+- Emby元数据刷新 v1.1
+- Emby媒体标签 v1.2
+- 热门媒体订阅 v1.7
+- [HomePage v1.2](docs%2FHomePage.md)
+- 目录监控(统一入库消息增强版) v1.0
+- Sql执行器 v1.2
+- 命令执行器 v1.2
+- 云盘助手(docs%2FCloudAssistant.md) v1.7
+- CloudDrive2助手 v1.1
+- 软连接重定向 v1.0
\ No newline at end of file
diff --git a/data/EmbyReporter/res/PingFang Bold.ttf b/data/EmbyReporter/res/PingFang Bold.ttf
new file mode 100644
index 0000000..accaf1f
Binary files /dev/null and b/data/EmbyReporter/res/PingFang Bold.ttf differ
diff --git a/data/EmbyReporter/res/bg/0.jpg b/data/EmbyReporter/res/bg/0.jpg
new file mode 100644
index 0000000..91038ee
Binary files /dev/null and b/data/EmbyReporter/res/bg/0.jpg differ
diff --git a/data/EmbyReporter/res/bg/1.jpg b/data/EmbyReporter/res/bg/1.jpg
new file mode 100644
index 0000000..8db3393
Binary files /dev/null and b/data/EmbyReporter/res/bg/1.jpg differ
diff --git a/data/EmbyReporter/res/bg/2.jpg b/data/EmbyReporter/res/bg/2.jpg
new file mode 100644
index 0000000..5d114ee
Binary files /dev/null and b/data/EmbyReporter/res/bg/2.jpg differ
diff --git a/data/EmbyReporter/res/bg/3.jpg b/data/EmbyReporter/res/bg/3.jpg
new file mode 100644
index 0000000..f964c26
Binary files /dev/null and b/data/EmbyReporter/res/bg/3.jpg differ
diff --git a/data/EmbyReporter/res/bg/4.jpg b/data/EmbyReporter/res/bg/4.jpg
new file mode 100644
index 0000000..bc665b6
Binary files /dev/null and b/data/EmbyReporter/res/bg/4.jpg differ
diff --git a/data/EmbyReporter/res/bg/5.jpg b/data/EmbyReporter/res/bg/5.jpg
new file mode 100644
index 0000000..95ef6a5
Binary files /dev/null and b/data/EmbyReporter/res/bg/5.jpg differ
diff --git a/data/EmbyReporter/res/bg/6.jpg b/data/EmbyReporter/res/bg/6.jpg
new file mode 100644
index 0000000..1343af2
Binary files /dev/null and b/data/EmbyReporter/res/bg/6.jpg differ
diff --git a/data/EmbyReporter/res/cover-ranks-mask-2.png b/data/EmbyReporter/res/cover-ranks-mask-2.png
new file mode 100644
index 0000000..335c61b
Binary files /dev/null and b/data/EmbyReporter/res/cover-ranks-mask-2.png differ
diff --git a/docs/CloudStrm.md b/docs/CloudStrm.md
new file mode 100644
index 0000000..29a8869
--- /dev/null
+++ b/docs/CloudStrm.md
@@ -0,0 +1,25 @@
+# 云盘strm生成
+
+### 使用说明
+
+目录监控格式:
+
+- 1.监控目录#目的目录#媒体服务器内源文件路径
+- 2.监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址
+- 3.监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址
+
+路径:
+
+- 监控目录:源文件目录即云盘挂载到MoviePilot中的路径
+- 目的路径:MoviePilot中strm生成路径
+- 媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径
+
+示例:
+
+- MoviePilot上云盘源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- MoviePilot上strm生成路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm`
+
+- 媒体服务器内源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- 监控配置为:/mount/cloud/aliyun/emby#/mnt/link/aliyun#/mount/cloud/aliyun/emby
diff --git a/docs/CloudStrmIncrement.md b/docs/CloudStrmIncrement.md
new file mode 100644
index 0000000..dbb8b71
--- /dev/null
+++ b/docs/CloudStrmIncrement.md
@@ -0,0 +1,42 @@
+# 云盘strm生成(增量版)
+
+### 使用说明
+
+目录监控格式:
+
+- 1.增量目录#监控目录#目的目录#媒体服务器内源文件路径
+- 2.增量目录#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址
+- 3.增量目录#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址
+
+路径:
+
+- 增量目录:转存到云盘的路径,插件只会扫描该路径下的文件,移动到监控路径,生成目的路径的strm文件
+- 监控目录:源文件目录即云盘挂载到MoviePilot中的路径
+- 目的路径:MoviePilot中strm生成路径
+- 媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径
+
+示例:
+
+- 增量目录:/increment`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- MoviePilot上云盘源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- MoviePilot上strm生成路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm`
+
+- 媒体服务器内源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- 监控配置为:/increment#/mount/cloud/aliyun/emby#/mnt/link/aliyun#/mount/cloud/aliyun/emby
+
+
+保留路径:
+
+扫描到增量目录的文件,会移动到监控目录,并生成目的路径的strm文件,删除空的增量目录,如果想保留某些父目录,可以将它们添加到保留路径中。
+
+例如:
+
+/increment/series/庆余年/Season 1/1.第一集.mp4
+
+保留路径为series
+
+则文件移动到目的路径名后,会删除庆余年/Season 1,父路径/increment/series保留
+
diff --git a/docs/CustomCommand.md b/docs/CustomCommand.md
new file mode 100644
index 0000000..0d5c7ed
--- /dev/null
+++ b/docs/CustomCommand.md
@@ -0,0 +1,11 @@
+# 自定义命令
+
+### 使用说明
+
+默认把python脚本最后一个print作为返回值
+
+命令名#0 9 * * *#python main.py
+命令名#0 9 * * *#python main.py#1-600
+
+
+1-600为随机延时,单位秒
\ No newline at end of file
diff --git a/docs/EmbyReporter.md b/docs/EmbyReporter.md
new file mode 100644
index 0000000..034b2f6
--- /dev/null
+++ b/docs/EmbyReporter.md
@@ -0,0 +1,31 @@
+# Emby观影报告
+
+### 使用说明
+
+**注意**:需 `Emby` 安装 `Playback Report` 插件
+
+将本项目**下载到本地**,并将 `/data/EmbyReporter/res` 下文件路径映射到 `MoviePilot` 容器可访问的目录下,如 `/config/plugins/EmbyReporter`
+
+
+ 具体步骤
+
+ 1. 下载源码:`git clone https://github.com/thsrite/MoviePilot-Plugins.git` 或者从网页直接下载并解压
+ 2. 复制 `/data/EmbyReporter/res` 到容器可访问目录,如 `/config/plugins/EmbyReporter`
+ 3. 配置该插件的素材路径 `/config/plugins/EmbyReporter/`,如下面图中所示
+ 4. 立即运行一次,如果网络正常,`tg` 通道已配置的话,`tg` 即可收到推送
+
+
+
+
+
+
+每日一言推荐
+``
+https://v.api.aa1.cn/api/yiyan/index.php
+https://yijuzhan.com/api/word.php
+``
+
+点点推荐舔狗
+``
+https://v.api.aa1.cn/api/tiangou/index.php
+``
diff --git a/docs/HomePage.md b/docs/HomePage.md
new file mode 100644
index 0000000..78c51d1
--- /dev/null
+++ b/docs/HomePage.md
@@ -0,0 +1,51 @@
+# HomePage自定义API
+
+
+
+HomePage services.yaml配置
+```angular2html
+- Media:
+ - MoviePilot:
+ icon: /icons/icon/MoviePilot.png
+ href: http://MoviePilot_IP:NGINX_PORT
+ ping: http://MoviePilot_IP:NGINX_PORT
+ # server: unraid
+ # container: MoviePilot
+ showStats: true
+ widget:
+ type: customapi
+ url: http://MoviePilot_IP:NGINX_PORT/api/v1/plugin/HomePage/statistic?apikey=api_token
+ method: GET
+ mappings:
+ - field: movie_subscribes
+ label: 电影订阅
+ - field: tv_subscribes
+ label: 电视剧订阅
+ - field: movie_count
+ label: 电影数量
+ - field: tv_count
+ label: 电视剧数量
+ # - field: episode_count
+ # label: 电影剧集数量
+ # - field: user_count
+ # label: 用户数量
+ # - field: total_storage
+ # label: 总空间
+ # - field: free_storage
+ # label: 剩余空间
+ # - field: now_tasks
+```
+
+### 自定义API Response字段
+- movie_subscribes: 电影订阅
+- tv_subscribes: 电视剧订阅
+- movie_count: 电影数量
+- tv_count: 电视剧数量
+- episode_count: 电影剧集数量
+- user_count: 用户数量
+- total_storage: 总空间
+- used_storage: 已用空间
+- free_storage: 剩余空间
+
+### HomePage自定义API文档
+https://gethomepage.dev/latest/widgets/services/customapi/#custom-request-body
\ No newline at end of file
diff --git a/docs/ShortPlayMonitor.md b/docs/ShortPlayMonitor.md
new file mode 100644
index 0000000..9b59840
--- /dev/null
+++ b/docs/ShortPlayMonitor.md
@@ -0,0 +1,17 @@
+# 短剧刮削
+
+### 使用说明
+
+监控方式:
+
+- fast:性能模式,内部处理系统操作类型选择最优解
+- compatibility:兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB (建议使用)
+
+是否重命名
+
+- true 自定义识别词
+- false
+- smart 我看着取 (尝试从agsv、萝莉站获取封面)
+
+封面比例:
+2:3
\ No newline at end of file
diff --git a/docs/StrmConvert.md b/docs/StrmConvert.md
new file mode 100644
index 0000000..f47c673
--- /dev/null
+++ b/docs/StrmConvert.md
@@ -0,0 +1,18 @@
+# Strm文件模式转换
+
+### 使用说明
+
+#### 本地模式
+- MoviePilot上strm视频路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm`
+- 云盘源文件挂载本地后 挂载`进媒体服务器的路径`,与上方对应 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- 转换配置为:`/mnt/link/aliyun#/mount/cloud/aliyun/emby`
+
+#### API模式
+- MoviePilot上strm视频根路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm`
+- cd2挂载后路径 /aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4`
+
+- 转换配置为:`/mnt/link/aliyun#/aliyun/emby#cd2#192.168.31.103:19798`
+
+
+## 具体自己多尝试吧。
\ No newline at end of file
diff --git a/docs/WeChatForward.md b/docs/WeChatForward.md
new file mode 100644
index 0000000..1cfbd5e
--- /dev/null
+++ b/docs/WeChatForward.md
@@ -0,0 +1,37 @@
+# 微信消息转发
+
+### 使用说明
+
+#### 消息转发插件加强版
+
+根据正则表达式将对应title的消息转发到不同的企业微信应用上
+
+企业微信应用配置与正则表达式一一对应(一行对应一行)
+
+如果某条消息不想指定userid发送,则填写忽略userid正则表达式.
+
+#### 额外消息配置
+
+`开始下载 > userid > 后台下载任务已提交,请耐心等候入库通知。 > appid`
+
+`已添加订阅 > userid > 电视剧正在更新,已添加订阅,待更新后自动下载。 > appid`
+
+中间用` > `分割
+
+消息title匹配到`开始下载`的正则
+
+且消息text中的`用户:`匹配到userid,
+
+则发送`后台下载任务已提交,请耐心等候入库通知。`额外通知。
+
+发送给appid为`appid`的企业微信应用。(环境变量配置或者本插件配置均可。)
+
+#### 特定消息指定用户
+
+`title正则 > text|title正则 > userid`
+
+当要发送的消息的`title`和`text|title`均匹配正则,则强制指定该消息的userid
+
+#### 2.0版本兼容旧版配置
+
+如更新到2.0版本,设置微信配置界面配置没有格式化,无须担心,重启下即可(不重启功能也正常使用)。
\ No newline at end of file
diff --git a/icons/SpeedLimiter.jpg b/icons/SpeedLimiter.jpg
new file mode 100644
index 0000000..c295f91
Binary files /dev/null and b/icons/SpeedLimiter.jpg differ
diff --git a/icons/actor.png b/icons/actor.png
new file mode 100644
index 0000000..da023fb
Binary files /dev/null and b/icons/actor.png differ
diff --git a/icons/autosubtitles.jpeg b/icons/autosubtitles.jpeg
new file mode 100644
index 0000000..bdd1232
Binary files /dev/null and b/icons/autosubtitles.jpeg differ
diff --git a/icons/backup.png b/icons/backup.png
new file mode 100644
index 0000000..efef44f
Binary files /dev/null and b/icons/backup.png differ
diff --git a/icons/bark.webp b/icons/bark.webp
new file mode 100644
index 0000000..0d6bb2f
Binary files /dev/null and b/icons/bark.webp differ
diff --git a/icons/broom.png b/icons/broom.png
new file mode 100644
index 0000000..98cb4ec
Binary files /dev/null and b/icons/broom.png differ
diff --git a/icons/brush.jpg b/icons/brush.jpg
new file mode 100644
index 0000000..8d79a06
Binary files /dev/null and b/icons/brush.jpg differ
diff --git a/icons/chatgpt.png b/icons/chatgpt.png
new file mode 100644
index 0000000..2bff26e
Binary files /dev/null and b/icons/chatgpt.png differ
diff --git a/icons/chinesesubfinder.png b/icons/chinesesubfinder.png
new file mode 100644
index 0000000..5ba3ea5
Binary files /dev/null and b/icons/chinesesubfinder.png differ
diff --git a/icons/clean.png b/icons/clean.png
new file mode 100644
index 0000000..fcebe9d
Binary files /dev/null and b/icons/clean.png differ
diff --git a/icons/cloud.png b/icons/cloud.png
new file mode 100644
index 0000000..1ed7308
Binary files /dev/null and b/icons/cloud.png differ
diff --git a/icons/cloudassistant.png b/icons/cloudassistant.png
new file mode 100644
index 0000000..1ff4b1d
Binary files /dev/null and b/icons/cloudassistant.png differ
diff --git a/icons/clouddisk.png b/icons/clouddisk.png
new file mode 100644
index 0000000..3f4feb2
Binary files /dev/null and b/icons/clouddisk.png differ
diff --git a/icons/clouddrive.png b/icons/clouddrive.png
new file mode 100644
index 0000000..ce78fc4
Binary files /dev/null and b/icons/clouddrive.png differ
diff --git a/icons/cloudflare.jpg b/icons/cloudflare.jpg
new file mode 100644
index 0000000..c57cbd4
Binary files /dev/null and b/icons/cloudflare.jpg differ
diff --git a/icons/cloudstrm.png b/icons/cloudstrm.png
new file mode 100644
index 0000000..3b3eaa3
Binary files /dev/null and b/icons/cloudstrm.png differ
diff --git a/icons/code.png b/icons/code.png
new file mode 100644
index 0000000..145190c
Binary files /dev/null and b/icons/code.png differ
diff --git a/icons/command.png b/icons/command.png
new file mode 100644
index 0000000..f077761
Binary files /dev/null and b/icons/command.png differ
diff --git a/icons/convert.png b/icons/convert.png
new file mode 100644
index 0000000..5293068
Binary files /dev/null and b/icons/convert.png differ
diff --git a/icons/cookiecloud.png b/icons/cookiecloud.png
new file mode 100644
index 0000000..00c7408
Binary files /dev/null and b/icons/cookiecloud.png differ
diff --git a/icons/create.png b/icons/create.png
new file mode 100644
index 0000000..1e95c81
Binary files /dev/null and b/icons/create.png differ
diff --git a/icons/database.png b/icons/database.png
new file mode 100644
index 0000000..1073aea
Binary files /dev/null and b/icons/database.png differ
diff --git a/icons/delete.png b/icons/delete.png
new file mode 100644
index 0000000..efc47ec
Binary files /dev/null and b/icons/delete.png differ
diff --git a/icons/directory.png b/icons/directory.png
new file mode 100644
index 0000000..20e5897
Binary files /dev/null and b/icons/directory.png differ
diff --git a/icons/diskusage.jpg b/icons/diskusage.jpg
new file mode 100644
index 0000000..ceb145d
Binary files /dev/null and b/icons/diskusage.jpg differ
diff --git a/icons/douban.png b/icons/douban.png
new file mode 100644
index 0000000..4dfac61
Binary files /dev/null and b/icons/douban.png differ
diff --git a/icons/download.png b/icons/download.png
new file mode 100644
index 0000000..ac55b0f
Binary files /dev/null and b/icons/download.png differ
diff --git a/icons/downloadmsg.png b/icons/downloadmsg.png
new file mode 100644
index 0000000..e070dff
Binary files /dev/null and b/icons/downloadmsg.png differ
diff --git a/icons/emby-icon.png b/icons/emby-icon.png
new file mode 100644
index 0000000..1f4a9f1
Binary files /dev/null and b/icons/emby-icon.png differ
diff --git a/icons/emby.png b/icons/emby.png
new file mode 100644
index 0000000..3eb232b
Binary files /dev/null and b/icons/emby.png differ
diff --git a/icons/fileupload.png b/icons/fileupload.png
new file mode 100644
index 0000000..6d3e6cc
Binary files /dev/null and b/icons/fileupload.png differ
diff --git a/icons/forward.png b/icons/forward.png
new file mode 100644
index 0000000..ccf655f
Binary files /dev/null and b/icons/forward.png differ
diff --git a/icons/homepage.png b/icons/homepage.png
new file mode 100644
index 0000000..aa475b5
Binary files /dev/null and b/icons/homepage.png differ
diff --git a/icons/hosts.png b/icons/hosts.png
new file mode 100644
index 0000000..6cfbb6c
Binary files /dev/null and b/icons/hosts.png differ
diff --git a/icons/invites.png b/icons/invites.png
new file mode 100644
index 0000000..d3ea6d4
Binary files /dev/null and b/icons/invites.png differ
diff --git a/icons/iyuu.png b/icons/iyuu.png
new file mode 100644
index 0000000..1904a7d
Binary files /dev/null and b/icons/iyuu.png differ
diff --git a/icons/like.jpg b/icons/like.jpg
new file mode 100644
index 0000000..ad08156
Binary files /dev/null and b/icons/like.jpg differ
diff --git a/icons/login.png b/icons/login.png
new file mode 100644
index 0000000..5c00763
Binary files /dev/null and b/icons/login.png differ
diff --git a/icons/media.png b/icons/media.png
new file mode 100644
index 0000000..bc8b5eb
Binary files /dev/null and b/icons/media.png differ
diff --git a/icons/mediaplay.png b/icons/mediaplay.png
new file mode 100644
index 0000000..d1a1a8b
Binary files /dev/null and b/icons/mediaplay.png differ
diff --git a/icons/mediasyncdel.png b/icons/mediasyncdel.png
new file mode 100644
index 0000000..f1236a9
Binary files /dev/null and b/icons/mediasyncdel.png differ
diff --git a/icons/movie.jpg b/icons/movie.jpg
new file mode 100644
index 0000000..02bf36f
Binary files /dev/null and b/icons/movie.jpg differ
diff --git a/icons/nfo.png b/icons/nfo.png
new file mode 100644
index 0000000..37f0276
Binary files /dev/null and b/icons/nfo.png differ
diff --git a/icons/opensubtitles.png b/icons/opensubtitles.png
new file mode 100644
index 0000000..85e0099
Binary files /dev/null and b/icons/opensubtitles.png differ
diff --git a/icons/pluginupdate.png b/icons/pluginupdate.png
new file mode 100644
index 0000000..33f063f
Binary files /dev/null and b/icons/pluginupdate.png differ
diff --git a/icons/popular.png b/icons/popular.png
new file mode 100644
index 0000000..3bf5f15
Binary files /dev/null and b/icons/popular.png differ
diff --git a/icons/pushdeer.png b/icons/pushdeer.png
new file mode 100644
index 0000000..771a37a
Binary files /dev/null and b/icons/pushdeer.png differ
diff --git a/icons/random.png b/icons/random.png
new file mode 100644
index 0000000..99d8e6a
Binary files /dev/null and b/icons/random.png differ
diff --git a/icons/refresh.png b/icons/refresh.png
new file mode 100644
index 0000000..d70bb0f
Binary files /dev/null and b/icons/refresh.png differ
diff --git a/icons/refresh2.png b/icons/refresh2.png
new file mode 100644
index 0000000..61342d6
Binary files /dev/null and b/icons/refresh2.png differ
diff --git a/icons/regex.png b/icons/regex.png
new file mode 100644
index 0000000..178abb4
Binary files /dev/null and b/icons/regex.png differ
diff --git a/icons/reinstall.png b/icons/reinstall.png
new file mode 100644
index 0000000..cf59ad4
Binary files /dev/null and b/icons/reinstall.png differ
diff --git a/icons/reminder.png b/icons/reminder.png
new file mode 100644
index 0000000..001cf87
Binary files /dev/null and b/icons/reminder.png differ
diff --git a/icons/removetorrent.png b/icons/removetorrent.png
new file mode 100644
index 0000000..56cb60b
Binary files /dev/null and b/icons/removetorrent.png differ
diff --git a/icons/rss.png b/icons/rss.png
new file mode 100644
index 0000000..d13b4fc
Binary files /dev/null and b/icons/rss.png differ
diff --git a/icons/scraper.png b/icons/scraper.png
new file mode 100644
index 0000000..99d0ba5
Binary files /dev/null and b/icons/scraper.png differ
diff --git a/icons/seed.png b/icons/seed.png
new file mode 100644
index 0000000..0aa907a
Binary files /dev/null and b/icons/seed.png differ
diff --git a/icons/signin.png b/icons/signin.png
new file mode 100644
index 0000000..005432d
Binary files /dev/null and b/icons/signin.png differ
diff --git a/icons/sitesafe.png b/icons/sitesafe.png
new file mode 100644
index 0000000..72e6770
Binary files /dev/null and b/icons/sitesafe.png differ
diff --git a/icons/softlink.png b/icons/softlink.png
new file mode 100644
index 0000000..1fb2b9c
Binary files /dev/null and b/icons/softlink.png differ
diff --git a/icons/softlinkredirect.png b/icons/softlinkredirect.png
new file mode 100644
index 0000000..28c3870
Binary files /dev/null and b/icons/softlinkredirect.png differ
diff --git a/icons/sqlite.png b/icons/sqlite.png
new file mode 100644
index 0000000..eb2e910
Binary files /dev/null and b/icons/sqlite.png differ
diff --git a/icons/statistic.png b/icons/statistic.png
new file mode 100644
index 0000000..01daf9b
Binary files /dev/null and b/icons/statistic.png differ
diff --git a/icons/subscribe_reminder.png b/icons/subscribe_reminder.png
new file mode 100644
index 0000000..6513579
Binary files /dev/null and b/icons/subscribe_reminder.png differ
diff --git a/icons/subscribeclear.png b/icons/subscribeclear.png
new file mode 100644
index 0000000..a48c9cd
Binary files /dev/null and b/icons/subscribeclear.png differ
diff --git a/icons/subscribestatistic.png b/icons/subscribestatistic.png
new file mode 100644
index 0000000..cb4a97d
Binary files /dev/null and b/icons/subscribestatistic.png differ
diff --git a/icons/sync.png b/icons/sync.png
new file mode 100644
index 0000000..309205b
Binary files /dev/null and b/icons/sync.png differ
diff --git a/icons/sync_file.png b/icons/sync_file.png
new file mode 100644
index 0000000..090319b
Binary files /dev/null and b/icons/sync_file.png differ
diff --git a/icons/synology.png b/icons/synology.png
new file mode 100644
index 0000000..53b23d3
Binary files /dev/null and b/icons/synology.png differ
diff --git a/icons/tag.png b/icons/tag.png
new file mode 100644
index 0000000..fd73431
Binary files /dev/null and b/icons/tag.png differ
diff --git a/icons/teamwork.png b/icons/teamwork.png
new file mode 100644
index 0000000..3995bfb
Binary files /dev/null and b/icons/teamwork.png differ
diff --git a/icons/torrent.png b/icons/torrent.png
new file mode 100644
index 0000000..0bee011
Binary files /dev/null and b/icons/torrent.png differ
diff --git a/icons/torrenttransfer.jpg b/icons/torrenttransfer.jpg
new file mode 100644
index 0000000..088aa37
Binary files /dev/null and b/icons/torrenttransfer.jpg differ
diff --git a/icons/uninstall.png b/icons/uninstall.png
new file mode 100644
index 0000000..5f36821
Binary files /dev/null and b/icons/uninstall.png differ
diff --git a/icons/unread.png b/icons/unread.png
new file mode 100644
index 0000000..af247f9
Binary files /dev/null and b/icons/unread.png differ
diff --git a/icons/update.png b/icons/update.png
new file mode 100644
index 0000000..5804f34
Binary files /dev/null and b/icons/update.png differ
diff --git a/icons/upload.png b/icons/upload.png
new file mode 100644
index 0000000..0f2aba2
Binary files /dev/null and b/icons/upload.png differ
diff --git a/icons/webhook.png b/icons/webhook.png
new file mode 100644
index 0000000..f52a25f
Binary files /dev/null and b/icons/webhook.png differ
diff --git a/icons/world.png b/icons/world.png
new file mode 100644
index 0000000..2322d01
Binary files /dev/null and b/icons/world.png differ
diff --git a/img/EmbyReporter/img.png b/img/EmbyReporter/img.png
new file mode 100644
index 0000000..f9d4b6d
Binary files /dev/null and b/img/EmbyReporter/img.png differ
diff --git a/img/EmbyReporter/img_1.png b/img/EmbyReporter/img_1.png
new file mode 100644
index 0000000..9240b23
Binary files /dev/null and b/img/EmbyReporter/img_1.png differ
diff --git a/img/HomePage/img.png b/img/HomePage/img.png
new file mode 100644
index 0000000..c7b9ad8
Binary files /dev/null and b/img/HomePage/img.png differ
diff --git a/package.json b/package.json
index 4b9c220..da9ce6a 100644
--- a/package.json
+++ b/package.json
@@ -1,4 +1,522 @@
{
+ "CloudStrm": {
+ "name": "云盘Strm生成",
+ "description": "监控文件创建,生成Strm文件。",
+ "labels": "云盘",
+ "version": "4.4",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v4.4": "修复bug",
+ "v4.3": "回滚自定义媒体类型",
+ "v4.2": "扩展名转小写",
+ "v4.1": "支持自定义媒体类型",
+ "v4.0": "回归老版本",
+ "v3.8": "支持增量路径、支持自定义媒体类型(注:本次更新需修改配置使用)",
+ "v3.7": "api模式支持启用https",
+ "v3.6": "支持重建索引周期运行",
+ "v3.4": "交互命令",
+ "v3.1": "注册交互命令、注册公共服务",
+ "v3.0": "实现改为定时扫描"
+ }
+ },
+ "CloudStrmIncrement": {
+ "name": "云盘Strm生成(增量版)",
+ "description": "监控文件创建,生成Strm文件(增量版)。",
+ "labels": "云盘",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "增量监控"
+ }
+ },
+ "StrmConvert": {
+ "name": "Strm文件模式转换",
+ "description": "Strm文件内容转为本地路径或者cd2/alist API路径。",
+ "labels": "云盘",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/convert.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "Strm文件内容转为本地路径或者cd2/alist API路径"
+ }
+ },
+ "SiteUnreadMsg": {
+ "name": "站点未读消息",
+ "description": "发送站点未读消息。",
+ "labels": "站点",
+ "version": "1.9",
+ "icon": "Synomail_A.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v1.9": "同步主仓库",
+ "v1.8": "自定义保留消息天数",
+ "v1.7": "删除重复代码、依赖于[站点数据统计]插件",
+ "v1.6": "增加解析失败日志",
+ "v1.5": "修复馒头未读消息1",
+ "v1.4": "sync主仓库",
+ "v1.3": "feat mtorrent",
+ "v1.2": "站点消息历史存库",
+ "v1.1": "防止同一消息重复发送",
+ "v1.0": "定时获取站点消息"
+ }
+ },
+ "SubscribeClear": {
+ "name": "清理订阅缓存",
+ "description": "清理订阅已下载集数。",
+ "labels": "订阅",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/broom.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v1.0": "清理订阅已下载集数"
+ }
+ },
+ "DownloadTorrent": {
+ "name": "添加种子下载",
+ "description": "选择下载器,添加种子任务。",
+ "labels": "站点",
+ "version": "1.0",
+ "icon": "download.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "删除下载器中该站点辅种,保留该站点没有辅种的种子"
+ }
+ },
+ "RemoveTorrent": {
+ "name": "删除站点种子",
+ "description": "删除下载器中某站点种子。",
+ "labels": "站点",
+ "version": "1.2",
+ "icon": "delete.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "修复删除种子bug",
+ "v1.1": "可选择删除有无辅种",
+ "v1.0": "选择下载器,添加种子任务"
+ }
+ },
+ "PluginAutoUpdate": {
+ "name": "插件更新管理",
+ "description": "监测已安装插件,推送更新提醒,可配置自动更新。",
+ "labels": "自动更新,插件管理",
+ "version": "1.9",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/pluginupdate.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.9": "过滤相同ID插件,保留最新版本检查更新",
+ "v1.8": "修复已安装插件列表",
+ "v1.7": "插件API立即生效",
+ "v1.6": "插件重载,插件自动更新注册成为服务、命令",
+ "v1.5": "自动更新增加排除列表",
+ "v1.4": "正在运行的插件跳过更新,可选更新插件列表",
+ "v1.3": "配置更新提醒",
+ "v1.2": "重启后立即执行一遍更新插件",
+ "v1.1": "修复插件重载",
+ "v1.0": "监测已安装插件,自动更新最新版本"
+ }
+ },
+ "PluginReInstall": {
+ "name": "插件强制重装",
+ "description": "卸载当前插件,强制重装。",
+ "labels": "插件管理",
+ "version": "1.7",
+ "icon": "refresh.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.7": "使用主程序GITHUB_PROXY代理",
+ "v1.6": "插件API立即生效",
+ "v1.5": "支持插件热重载",
+ "v1.4": "支持代理地址",
+ "v1.3": "插件重载",
+ "v1.2": "支持指定插件仓库地址",
+ "v1.1": "修复插件重载",
+ "v1.0": "卸载当前插件,强制重装"
+ }
+ },
+ "SynologyNotify": {
+ "name": "群辉Webhook通知",
+ "description": "接收群辉webhook通知并推送。",
+ "labels": "消息通知",
+ "version": "1.1",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/synology.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.1": "修复bug",
+ "v1.0": "接收群辉webhook通知并推送"
+ }
+ },
+ "SyncCookieCloud": {
+ "name": "同步CookieCloud",
+ "description": "同步MoviePilot站点Cookie到本地CookieCloud。",
+ "labels": "站点",
+ "version": "1.2",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cookiecloud.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "同步到本地CookieCloud",
+ "v1.1": "修复CookieCloud覆盖到浏览器",
+ "v1.0": "同步MoviePilot站点Cookie到CookieCloud"
+ }
+ },
+ "ScheduleReminder": {
+ "name": "日程提醒",
+ "description": "自定义提醒事项、提醒时间。",
+ "labels": "消息通知",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/reminder.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "自定义提醒事项、提醒时间"
+ }
+ },
+ "SubscribeReminder": {
+ "name": "订阅提醒",
+ "description": "推送当天订阅更新内容。",
+ "labels": "订阅",
+ "version": "1.1",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribe_reminder.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.1": "fix icon",
+ "v1.0": "推送当天订阅更新内容"
+ }
+ },
+ "EmbyReporter": {
+ "name": "Emby观影报告",
+ "description": "推送Emby观影报告,需Emby安装Playback Report 插件。",
+ "labels": "Emby",
+ "version": "1.5",
+ "icon": "Pydiocells_A.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.5": "按观影市场排序",
+ "v1.4": "支持自定义emby && 支持每日一言",
+ "v1.3": "修复bug",
+ "v1.2": "过滤已删除媒体",
+ "v1.1": "修复推送",
+ "v1.0": "推送Emby观影报告"
+ }
+ },
+ "ActorSubscribe": {
+ "name": "演员订阅",
+ "description": "自动订阅指定演员热映电影、电视剧。",
+ "labels": "订阅",
+ "version": "2.1",
+ "icon": "Mdcng_A.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v2.1": "逻辑优化",
+ "v2.0": "修复订阅",
+ "v1.8": "支持自定义订阅username,默认`演员订阅`",
+ "v1.7": "修复bug",
+ "v1.6": "增加历史删除按钮",
+ "v1.5": "rename",
+ "v1.4": "支持多个订阅源",
+ "v1.3": "修复bug",
+ "v1.2": "修复订阅重复处理的bug",
+ "v1.1": "支持自定义分辨率、质量、特效",
+ "v1.0": "自动订阅豆瓣演员最新电影"
+ }
+ },
+ "ShortPlayMonitor": {
+ "name": "短剧刮削",
+ "description": "监控视频短剧创建,刮削。",
+ "labels": "刮削",
+ "version": "3.2",
+ "icon": "Amule_B.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v3.2": "支持消息发送",
+ "v3.1": "支持自定义转移方式",
+ "v3.0": "默认从tmdb刮削,刮削失败则从pt站刮削"
+ }
+ },
+ "CloudLinkMonitor": {
+ "name": "云盘实时监控",
+ "description": "监控云盘目录文件变化,自动转移链接。",
+ "labels": "云盘",
+ "version": "2.2",
+ "icon": "Linkease_A.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v2.2": "优化配置一二级分类流程",
+ "v2.1": "可配置是否存储转移记录",
+ "v2.0": "修复不刮削不生效bug",
+ "v1.8": "fix S00转移",
+ "v1.7": "fix 刮削",
+ "v1.6": "可配置是否刮削",
+ "v1.5": "fix 消息推送",
+ "v1.4": "fix 转移后路径",
+ "v1.3": "修复bug",
+ "v1.2": "修复订阅重复处理的bug",
+ "v1.1": "自动转移链接(不刮削)",
+ "v1.0": "监控云盘目录文件变化,按原文件名软连接"
+ }
+ },
+ "LinkToSrc": {
+ "name": "源文件恢复",
+ "description": "根据MoviePilot的转移记录中的硬链文件恢复源文件。",
+ "labels": "媒体库",
+ "version": "1.2",
+ "icon": "Time_machine_A.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "fix 路径",
+ "v1.1": "支持指定需要恢复的硬链接目录",
+ "v1.0": "根据MoviePilot的转移记录中的硬链文件恢复源文件"
+ }
+ },
+ "WeChatForward": {
+ "name": "微信消息转发",
+ "description": "根据正则转发通知到其他WeChat应用。",
+ "labels": "消息通知",
+ "version": "2.7",
+ "icon": "Wechat_A.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v2.7": "特殊消息指定用户支持title匹配",
+ "v2.6": "已完成订阅额外消息查询订阅历史订阅用户",
+ "v2.5.1": "修复token过期重发未存储userid问题",
+ "v2.5": "增强额外消息发送",
+ "v2.4": "修复配置修改后不重建缓存bug",
+ "v2.3": "增加重建缓存,丰富转发历史",
+ "v2.2": "增加消息发送历史",
+ "v2.1": "微信配置持久化存库",
+ "v2.0": "优化微信配置,兼容旧版本配置",
+ "v1.6": "修改获取指定用户订阅列表方法",
+ "v1.5": "丰富日志",
+ "v1.4": "特定消息强制指定userid",
+ "v1.3": "防重复发送额外消息",
+ "v1.2": "fix规则",
+ "v1.1": "自定义发送额外消息",
+ "v1.0": "根据正则转发通知到其他WeChat应用"
+ }
+ },
+ "SubscribeStatistic": {
+ "name": "订阅下载统计",
+ "description": "统计指定时间内各站点订阅及下载情况。",
+ "labels": "订阅",
+ "version": "1.5",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribestatistic.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.5": "增加消息推送",
+ "v1.4": "无订阅站点也统计数量",
+ "v1.3": "fix 数据统计",
+ "v1.2": "fix 订阅数量",
+ "v1.1": "站点去重",
+ "v1.0": "统计指定时间内各站点订阅及下载情况"
+ }
+ },
+ "CustomCommand": {
+ "name": "自定义命令",
+ "description": "自定义执行周期执行命令并推送结果。",
+ "labels": "自定义命令",
+ "version": "1.7",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/code.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.7": "自定义通知关键词",
+ "v1.6": "自定义保留消息天数",
+ "v1.5": "修复多个任务立即运行一次",
+ "v1.4": "fix icon",
+ "v1.3": "清除历史记录",
+ "v1.2": "增加执行历史",
+ "v1.1": "打印命令日志",
+ "v1.0": "自定义执行周期执行命令并推送结果"
+ }
+ },
+ "DockerManager": {
+ "name": "docker自定义任务",
+ "description": "管理宿主机docker,自定义容器定时任务。",
+ "labels": "自定义命令",
+ "version": "1.3",
+ "icon": "Docker_F.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.3": "自定义保留消息天数",
+ "v1.2": "多个容器名,拼接",
+ "v1.1": "修复多个任务立即运行一次",
+ "v1.0": "init"
+ }
+ },
+ "PluginUnInstall": {
+ "name": "插件彻底卸载",
+ "description": "删除数据库中已安装插件记录、清理插件文件。",
+ "labels": "插件管理",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "init"
+ }
+ },
+ "FileSoftLink": {
+ "name": "实时软连接",
+ "description": "监控目录文件变化,媒体文件软连接,其他文件可选复制。",
+ "labels": "文件管理",
+ "version": "1.8",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.8": "修复bug",
+ "v1.6": "bug修复",
+ "v1.5": "优化性能,提高处理速度",
+ "v1.4": "支持自定义视频格式",
+ "v1.3": "异步启动"
+ }
+ },
+ "SubscribeGroup": {
+ "name": "订阅规则自动填充",
+ "description": "电视剧下载后自动添加官组等信息到订阅;添加订阅后根据二级分类名称自定义订阅规则。",
+ "labels": "订阅",
+ "version": "2.7",
+ "icon": "teamwork.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v2.7": "下载填充判断当前站点是否在已选订阅站点范围内",
+ "v2.6": "兼容属性值包含:号",
+ "v2.5": "操作历史Unicode编码转中文",
+ "v2.4": "保存路径支持变量{name} (订阅名称 (年份))",
+ "v2.3": "二级分类自定义填充支持保存路径",
+ "v2.1": "站点与官组分开,修复质量无填充",
+ "v2.0": "种子下载自定义填充支持自定义占位符",
+ "v1.8": "修复种子下载不填充bug",
+ "v1.7": "操作历史Unicode编码转中文",
+ "v1.6": "支持一行配置多个二级分类名称",
+ "v1.5": "支持操作历史",
+ "v1.4": "支持根据二级分类名称自定义订阅规则",
+ "v1.3": "增加质量、分辨率、特效信息填充",
+ "v1.2": "修复订阅已存在包含关键词和订阅站点"
+ }
+ },
+ "EmbyMetaRefresh": {
+ "name": "Emby元数据刷新",
+ "description": "定时刷新Emby媒体库元数据。",
+ "labels": "Emby",
+ "version": "1.1",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.1": "添加远程交互命令",
+ "v1.0": "定时刷新Emby媒体库元数据"
+ }
+ },
+ "EmbyMetaTag": {
+ "name": "Emby媒体标签",
+ "description": "自动给媒体库媒体添加标签。",
+ "labels": "Emby",
+ "version": "1.2",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/tag.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "支持指定特殊媒体名称添加标签",
+ "v1.1": "添加远程交互命令",
+ "v1.0": "自动给媒体库媒体添加标签"
+ }
+ },
+ "PopularSubscribe": {
+ "name": "热门媒体订阅",
+ "description": "自定添加热门媒体到订阅。",
+ "labels": "订阅",
+ "version": "1.7",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/popular.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.7": "调整订阅用户名,默认:热门订阅",
+ "v1.6": "调整历史unique唯一索引(可删除本次更新后的历史)",
+ "v1.5": "修复电视剧订阅、订阅历史展示",
+ "v1.4": "动漫单独订阅(本子佬启动!)",
+ "v1.3": "增加立即运行、历史删除按钮",
+ "v1.2": "增加历史删除按钮",
+ "v1.1": "修正流行度校验",
+ "v1.0": "自定添加热门媒体到订阅"
+ }
+ },
+ "HomePage": {
+ "name": "HomePage",
+ "description": "HomePage自定义API。",
+ "labels": "工具",
+ "version": "1.2",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/homepage.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "适配v1.9.1-beta(不生效就重启)",
+ "v1.1": "支持更多返回值、插件展示数据",
+ "v1.0": "HomePage自定义API"
+ }
+ },
+ "DirMonitorEnhanced": {
+ "name": "目录监控",
+ "description": "监控目录文件发生变化时实时整理到媒体库。(统一入库消息增强版)(测试中-.-)",
+ "labels": "文件整理",
+ "version": "1.0",
+ "icon": "directory.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.0": "同步merge主仓库[目录监控]插件,增加统一发送消息逻辑(Testing…)"
+ }
+ },
+ "SqlExecute": {
+ "name": "Sql执行器",
+ "description": "自定义MoviePilot数据库Sql执行。",
+ "labels": "工具",
+ "version": "1.2",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/sqlite.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "调整交互命令返回信息",
+ "v1.1": "支持交互命令/sql [command]执行,需主程序1.9.4+",
+ "v1.0": "自定义MoviePilot数据库Sql执行"
+ }
+ },
+ "CommandExecute": {
+ "name": "命令执行器",
+ "description": "自定义容器命令执行。",
+ "labels": "工具",
+ "version": "1.2",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/command.png",
+ "author": "thsrite",
+ "level": 1,
+ "history": {
+ "v1.2": "调整交互命令返回信息",
+ "v1.1": "支持交互命令/cmd [sql]执行,需主程序1.9.4+",
+ "v1.0": "自定义容器命令执行"
+ }
+ },
"CloudAssistant": {
"name": "云盘助手",
"description": "本地文件定时转移到云盘,软连接/strm回本地,定时清理无效软连接。",
@@ -17,5 +535,30 @@
"v1.1": "支持cd2上传、支持定时清理无效软连接、支持strm生成方式",
"v1.0": "定时移动到云盘,软连接回本地(清理无效软连接暂未开发)"
}
+ },
+ "Cd2Assistant": {
+ "name": "CloudDrive2助手",
+ "description": "监控上传任务,检测是否有异常,发送通知。",
+ "labels": "云盘",
+ "version": "1.1",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/clouddrive.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v1.1": "交互命令重启cd2、获取cd2系统信息,支持仪表盘",
+ "v1.0": "监控上传任务,检测是否有异常,发送通知"
+ }
+ },
+ "SoftLinkRedirect": {
+ "name": "软连接重定向",
+ "description": "重定向软连接指向。",
+ "labels": "云盘",
+ "version": "1.0",
+ "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlinkredirect.png",
+ "author": "thsrite",
+ "level": 2,
+ "history": {
+ "v1.0": "重定向软连接指向"
+ }
}
}
diff --git a/plugins/actorsubscribe/__init__.py b/plugins/actorsubscribe/__init__.py
new file mode 100644
index 0000000..e5aceca
--- /dev/null
+++ b/plugins/actorsubscribe/__init__.py
@@ -0,0 +1,891 @@
+import time
+from datetime import datetime, timedelta
+
+import pytz
+
+from app import schemas
+from app.chain.douban import DoubanChain
+from app.chain.tmdb import TmdbChain
+from app.chain.download import DownloadChain
+from app.chain.subscribe import SubscribeChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+from app.core.metainfo import MetaInfo
+from app.schemas import MediaType
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+
+class ActorSubscribe(_PluginBase):
+ # 插件名称
+ plugin_name = "演员订阅"
+ # 插件描述
+ plugin_desc = "自动订阅指定演员热映电影、电视剧。"
+ # 插件图标
+ plugin_icon = "Mdcng_A.png"
+ # 插件版本
+ plugin_version = "2.1"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "actorsubscribe_"
+ # 加载顺序
+ plugin_order = 25
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _cron: str = ""
+ _actors = None
+ subscribechain = None
+ downloadchain = None
+ _scheduler: Optional[BackgroundScheduler] = None
+ _quality = None
+ _resolution = None
+ _effect = None
+ _username = None
+ _clear = False
+ _clear_already_handle = False
+ _source = ["douban_showing"]
+ # 质量选择框数据
+ _qualityOptions = {
+ '全部': '',
+ '蓝光原盘': 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
+ 'Remux': 'Remux',
+ 'BluRay': 'Blu-?Ray',
+ 'UHD': 'UHD|UltraHD',
+ 'WEB-DL': 'WEB-?DL|WEB-?RIP',
+ 'HDTV': 'HDTV',
+ 'H265': '[Hx].?265|HEVC',
+ 'H264': '[Hx].?264|AVC'
+ }
+
+ # 分辨率选择框数据
+ _resolutionOptions = {
+ '全部': '',
+ '4k': '4K|2160p|x2160',
+ '1080p': '1080[pi]|x1080',
+ '720p': '720[pi]|x720'
+ }
+
+ # 特效选择框数据
+ _effectOptions = {
+ '全部': '',
+ '杜比视界': 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
+ '杜比全景声': 'Dolby[\\s.]*\\+?Atmos|Atmos',
+ 'HDR': '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
+ 'SDR': '[\\s.]+SDR[\\s.]+',
+ }
+
+ def init_plugin(self, config: dict = None):
+ self.downloadchain = DownloadChain()
+ self.subscribechain = SubscribeChain()
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._actors = config.get("actors")
+ self._quality = config.get("quality")
+ self._resolution = config.get("resolution")
+ self._effect = config.get("effect")
+ self._clear = config.get("clear")
+ self._clear_already_handle = config.get("clear_already_handle")
+ self._source = config.get("source")
+ self._username = config.get("username") or '演员订阅'
+
+ # 清理插件订阅历史
+ if self._clear:
+ self.del_data(key="history")
+
+ self._clear = False
+ self.__update_config()
+ logger.info("订阅历史清理完成")
+
+ # 清理已处理历史
+ if self._clear_already_handle:
+ self.del_data(key="already_handle")
+
+ self._clear_already_handle = False
+ self.__update_config()
+ logger.info("已处理历史清理完成")
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"明星热映订阅服务启动,立即运行一次")
+ self._scheduler.add_job(self.__actor_subscribe, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="明星热映订阅")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.__actor_subscribe,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="明星热映订阅")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __actor_subscribe(self):
+ """
+ 明星热映订阅
+ """
+ if not self._actors:
+ logger.warn("暂无订阅明星,停止运行")
+ return
+
+ history: List[dict] = self.get_data('history') or []
+ already_handle: List[dict] = self.get_data('already_handle') or []
+
+ medias = []
+ for source in self._source:
+ if source.strip() == "douban_showing":
+ medias += self.__douban_movie_showing()
+ elif source.strip() == "douban_movies":
+ medias += self.__douban_movies()
+ elif source.strip() == "douban_tvs":
+ medias += self.__douban_tvs()
+ elif source.strip() == "douban_movie_top250":
+ medias += self.__douban_movie_top250()
+ elif source.strip() == "douban_tv_weekly_chinese":
+ medias += self.__douban_tv_weekly_chinese()
+ elif source.strip() == "douban_tv_weekly_global":
+ medias += self.__douban_tv_weekly_global()
+ elif source.strip() == "douban_tv_animation":
+ medias += self.__douban_tv_animation()
+ elif source.strip() == "douban_movie_hot":
+ medias += self.__douban_movie_hot()
+ elif source.strip() == "douban_tv_hot":
+ medias += self.__douban_tv_hot()
+ elif source.strip() == "tmdb_movies":
+ medias += self.__tmdb_movies()
+ elif source.strip() == "tmdb_tvs":
+ medias += self.__tmdb_tvs()
+ elif source.strip() == "tmdb_trending":
+ medias += self.__tmdb_trending()
+ else:
+ logger.warn(f"未知的订阅源:{source}")
+
+ # 检查订阅
+ subscribe_actors = str(self._actors).split(",")
+ for mediainfo in medias:
+ if mediainfo.title_year in already_handle:
+ logger.info(f"{mediainfo.type.value} {mediainfo.title_year} 已被处理,跳过")
+ continue
+
+ already_handle.append(mediainfo.title_year)
+ logger.info(f"开始处理电影 {mediainfo.title_year}")
+
+ mediainfo_actors = []
+ if mediainfo.actors or mediainfo.directors:
+ mediainfo_actors = mediainfo.actors + mediainfo.directors
+
+ # 元数据
+ meta = MetaInfo(mediainfo.title)
+
+ # 判断有无tmdbid
+ if not mediainfo.tmdb_id:
+ oldmediainfo = mediainfo
+ # 主要获取tmdbid
+ mediainfo = self.chain.recognize_media(meta=meta, mtype=mediainfo.type)
+ if not mediainfo:
+ logger.warn(f'未识别到媒体信息,标题:{oldmediainfo.title},豆瓣ID:{oldmediainfo.douban_id}')
+ continue
+
+ oldmediainfo.tmdb_id = mediainfo.tmdb_id
+ mediainfo = oldmediainfo
+
+ # 演员中文名
+ if not mediainfo_actors:
+ # 查询豆瓣中文演员名
+ mediainfo_actors += self.__get_douban_actors(mediainfo)
+
+ if not mediainfo_actors:
+ logger.warn(f'未识别到演员信息,标题:{mediainfo.title},{mediainfo.tmdb_id or mediainfo.douban_id}')
+ continue
+
+ logger.info(f'获取到 {mediainfo.title} 演员:{mediainfo_actors}')
+
+ # 查询缺失的媒体信息
+ exist_flag, _ = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo)
+ if exist_flag:
+ logger.info(f'{mediainfo.title_year} 媒体库中已存在')
+ continue
+
+ # 判断用户是否已经添加订阅
+ if self.subscribechain.exists(mediainfo=mediainfo):
+ logger.info(f'{mediainfo.title_year} 订阅已存在')
+ continue
+
+ if mediainfo_actors:
+ is_subscribe = False
+ for actor in mediainfo_actors:
+ # logger.info(f'正在处理 {mediainfo.title_year} 演员 {actor}')
+ if actor and actor in subscribe_actors:
+ # 开始订阅
+ logger.info(
+ f"{mediainfo.type.value} {mediainfo.title_year} TMDBID {mediainfo.tmdb_id} DOUBANID {mediainfo.douban_id} 命中订阅演员 {actor},"
+ f"开始订阅。订阅规则:{self._quality} {self._resolution} {self._effect} {self._username}")
+ is_subscribe = True
+ # 添加订阅
+ self.subscribechain.add(title=mediainfo.title,
+ year=mediainfo.year,
+ mtype=mediainfo.type,
+ tmdbid=mediainfo.tmdb_id,
+ doubanid=mediainfo.douban_id,
+ exist_ok=True,
+ quality=self._quality,
+ resolution=self._resolution,
+ effect=self._effect,
+ username=self._username)
+ # 存储历史记录
+ history.append({
+ "title": mediainfo.title,
+ "type": mediainfo.type.value,
+ "year": mediainfo.year,
+ "poster": mediainfo.get_poster_image(),
+ "overview": mediainfo.overview,
+ "tmdbid": mediainfo.tmdb_id,
+ "doubanid": mediainfo.douban_id,
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "unique": f"actorsubscribe: {mediainfo.title} (DB:{mediainfo.tmdb_id})"
+ })
+
+ if not is_subscribe:
+ logger.info(
+ f"{mediainfo.type.value} {mediainfo.title_year} TMDBID {mediainfo.tmdb_id} DOUBANID {mediainfo.douban_id} 未命中订阅演员,跳过")
+
+ # 保存历史记录
+ self.save_data('history', history)
+ self.save_data('already_handle', already_handle)
+ logger.info(f"演员订阅任务完成")
+
+ def __get_douban_actors(self, mediainfo: MediaInfo, season: int = None) -> List[dict]:
+ """
+ 获取豆瓣演员信息
+ """
+ sleep_time = 3 + int(time.time()) % 7
+ logger.debug(f"随机休眠 {sleep_time}秒 ...")
+ time.sleep(sleep_time)
+ if mediainfo.douban_id:
+ doubanitem = DoubanChain().douban_info(mediainfo.douban_id) or {}
+ else:
+ # 匹配豆瓣信息
+ doubaninfo = DoubanChain().match_doubaninfo(name=mediainfo.title,
+ imdbid=mediainfo.imdb_id,
+ mtype=mediainfo.type,
+ year=mediainfo.year,
+ season=season)
+ # 豆瓣演员
+ if doubaninfo:
+ mediainfo.douban_id = doubaninfo.get("id")
+ doubanitem = DoubanChain().douban_info(doubaninfo.get("id")) or {}
+ else:
+ doubanitem = None
+
+ if doubanitem:
+ actors = (doubanitem.get("actors") or []) + (doubanitem.get("directors") or [])
+ return [actor.get("name") for actor in actors]
+ else:
+ logger.debug(f"未找到豆瓣信息:{mediainfo.title_year}")
+ return []
+
+ def __douban_movie_showing(self):
+ """
+ 豆瓣正在热映
+ """
+ movies = DoubanChain().movie_showing(page=1, count=30)
+ if not movies:
+ return []
+ medias = [media for media in movies]
+ logger.info(f"获取到豆瓣正在热映 {len(medias)} 部")
+ return medias
+
+ def __douban_movies(self):
+ """
+ 豆瓣电影
+ """
+ movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
+ sort="R", tags="", page=1, count=30)
+ if not movies:
+ return []
+ medias = [media for media in movies]
+ logger.info(f"获取到豆瓣电影 {len(medias)} 部")
+ return medias
+
+ def __douban_tvs(self):
+ """
+ 豆瓣剧集
+ """
+ tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
+ sort="R", tags="", page=1, count=30)
+ if not tvs:
+ return []
+ medias = [media for media in tvs]
+ logger.info(f"获取到豆瓣剧集 {len(medias)} 部")
+ return medias
+
+ def __douban_movie_top250(self):
+ """
+ 豆瓣电影TOP250
+ """
+ movies = DoubanChain().movie_top250(page=1, count=30)
+ if not movies:
+ return []
+ medias = [media for media in movies]
+ logger.info(f"获取到豆瓣电影TOP250 {len(medias)} 部")
+ return medias
+
+ def __douban_tv_weekly_chinese(self):
+ """
+ 豆瓣国产剧集周榜
+ """
+ tvs = DoubanChain().tv_weekly_chinese(page=1, count=30)
+ if not tvs:
+ return []
+ medias = [media for media in tvs]
+ logger.info(f"获取到豆瓣国产剧集周榜 {len(medias)} 部")
+ return medias
+
+ def __douban_tv_weekly_global(self):
+ """
+ 全球每周剧集口碑榜
+ """
+ tvs = DoubanChain().tv_weekly_global(page=1, count=30)
+ if not tvs:
+ return []
+ medias = [media for media in tvs]
+ logger.info(f"获取到全球每周剧集口碑榜 {len(medias)} 部")
+ return medias
+
+ def __douban_tv_animation(self):
+ """
+ 豆瓣动画剧集
+ """
+ tvs = DoubanChain().tv_animation(page=1, count=30)
+ if not tvs:
+ return []
+ medias = [media for media in tvs]
+ logger.info(f"获取到豆瓣动画剧集 {len(medias)} 部")
+ return medias
+
+ def __douban_movie_hot(self):
+ """
+ 豆瓣热门电影
+ """
+ movies = DoubanChain().movie_hot(page=1, count=30)
+ if not movies:
+ return []
+ medias = [media for media in movies]
+ logger.info(f"获取到豆瓣热门电影 {len(medias)} 部")
+ return medias
+
+ def __douban_tv_hot(self):
+ """
+ 豆瓣热门电视剧
+ """
+ tvs = DoubanChain().tv_hot(page=1, count=30)
+ if not tvs:
+ return []
+ medias = [media for media in tvs]
+ logger.info(f"获取到豆瓣热门电视剧 {len(medias)} 部")
+ return medias
+
+ def __tmdb_movies(self):
+ """
+ TMDB电影
+ """
+ movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
+ sort_by="popularity.desc",
+ with_genres="",
+ with_original_language="",
+ page=1)
+ if not movies:
+ return []
+ medias = [movie for movie in movies]
+ logger.info(f"获取到TMDB电影 {len(medias)} 部")
+ return medias
+
+ def __tmdb_tvs(self):
+ """
+ TMDB剧集
+ """
+ tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
+ sort_by="popularity.desc",
+ with_genres="",
+ with_original_language="",
+ page=1)
+ if not tvs:
+ return []
+ medias = [tv for tv in tvs]
+ logger.info(f"获取到TMDB剧集 {len(medias)} 部")
+ return medias
+
+ def __tmdb_trending(self):
+ """
+ TMDB流行趋势
+ """
+ tvs = TmdbChain().tmdb_trending(page=1)
+ if not tvs:
+ return []
+ medias = [tv for tv in tvs]
+ logger.info(f"获取到TMDB流行趋势 {len(medias)} 部")
+ return medias
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "actors": self._actors,
+ "quality": self._quality,
+ "resolution": self._resolution,
+ "effect": self._effect,
+ "clear": self._clear,
+ "clear_already_handle": self._clear_already_handle,
+ "source": self._source,
+ "username": self._username,
+ })
+
+ def delete_history(self, key: str, apikey: str):
+ """
+ 删除同步历史记录
+ """
+ if apikey != settings.API_TOKEN:
+ return schemas.Response(success=False, message="API密钥错误")
+ # 历史记录
+ historys = self.get_data('history')
+ if not historys:
+ return schemas.Response(success=False, message="未找到历史记录")
+ # 删除指定记录
+ historys = [h for h in historys if h.get("unique") != key]
+ self.save_data('history', historys)
+ return schemas.Response(success=True, message="删除成功")
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "path": "/delete_history",
+ "endpoint": self.delete_history,
+ "methods": ["GET"],
+ "summary": "删除订阅历史记录"
+ }
+ ]
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ qualityOptions = [{"title": i, "value": self._qualityOptions.get(i)} for i in self._qualityOptions.keys()]
+ resolutionOptions = [{"title": i, "value": self._resolutionOptions.get(i)} for i in
+ self._resolutionOptions.keys()]
+ effectOptions = [{"title": i, "value": self._effectOptions.get(i)} for i in self._effectOptions.keys()]
+
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear',
+ 'label': '清理订阅记录',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear_already_handle',
+ 'label': '清理已处理记录',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 9
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'actors',
+ 'label': '明星',
+ 'placeholder': '多个英文逗号分割'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'quality',
+ 'label': '质量',
+ 'items': qualityOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'resolution',
+ 'label': '分辨率',
+ 'items': resolutionOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'effect',
+ 'label': '特效',
+ 'items': effectOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'username',
+ 'label': '订阅用户',
+ 'placeholder': '默认为`演员订阅`'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'source',
+ 'label': '订阅来源',
+ 'items': [
+ {'title': '豆瓣正在热映', 'value': 'douban_showing'},
+ {'title': '豆瓣电影', 'value': 'douban_movies'},
+ {'title': '豆瓣剧集', 'value': 'douban_tvs'},
+ {'title': '豆瓣电影TOP250', 'value': 'douban_movie_top250'},
+ {'title': '豆瓣国产剧集周榜', 'value': 'douban_tv_weekly_chinese'},
+ {'title': '豆瓣全球剧集周榜', 'value': 'douban_tv_weekly_global'},
+ {'title': '豆瓣动画剧集', 'value': 'douban_tv_animation'},
+ {'title': '豆瓣热门电影', 'value': 'douban_movie_hot'},
+ {'title': '豆瓣热门电视剧', 'value': 'douban_tv_hot'},
+ {'title': 'TMDB电影', 'value': 'tmdb_movies'},
+ {'title': 'TMDB剧集', 'value': 'tmdb_tvs'},
+ {'title': 'TMDB流行趋势', 'value': 'tmdb_trending'},
+ ]
+ }
+ }
+ ]
+ },
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ "actors": "",
+ "quality": "",
+ "resolution": "",
+ "effect": "",
+ "username": "演员订阅",
+ "clear": False,
+ "clear_already_handle": False,
+ "source": ["douban_showing"]
+ }
+
+ def get_page(self) -> List[dict]:
+ """
+ 拼装插件详情页面,需要返回页面配置,同时附带数据
+ """
+ # 查询历史记录
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+ # 数据按时间降序排序
+ historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
+ # 拼装页面
+ contents = []
+ for history in historys:
+ title = history.get("title")
+ poster = history.get("poster")
+ mtype = history.get("type")
+ time_str = history.get("time")
+ tmdbid = history.get("tmdbid")
+ doubanid = history.get("doubanid")
+ contents.append(
+ {
+ 'component': 'VCard',
+ 'content': [
+ {
+ "component": "VDialogCloseBtn",
+ "props": {
+ 'innerClass': 'absolute top-0 right-0',
+ },
+ 'events': {
+ 'click': {
+ 'api': 'plugin/ActorSubscribe/delete_history',
+ 'method': 'get',
+ 'params': {
+ 'key': f"actorsubscribe: {title} (DB:{tmdbid})",
+ 'apikey': settings.API_TOKEN
+ }
+ }
+ },
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex justify-space-start flex-nowrap flex-row',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': poster,
+ 'height': 120,
+ 'width': 80,
+ 'aspect-ratio': '2/3',
+ 'class': 'object-cover shadow ring-gray-500',
+ 'cover': True
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VCardSubtitle',
+ 'props': {
+ 'class': 'pa-2 font-bold break-words whitespace-break-spaces'
+ },
+ 'content': [
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': f"https://movie.douban.com/subject/{doubanid}",
+ 'target': '_blank'
+ },
+ 'text': title
+ }
+ ]
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'类型:{mtype}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'时间:{time_str}'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ )
+
+ return [
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'grid gap-3 grid-info-card',
+ },
+ 'content': contents
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/cd2assistant/__init__.py b/plugins/cd2assistant/__init__.py
new file mode 100644
index 0000000..8cc3716
--- /dev/null
+++ b/plugins/cd2assistant/__init__.py
@@ -0,0 +1,1440 @@
+import re
+from datetime import datetime, timedelta
+
+import pytz
+from clouddrive import CloudDriveClient, Client
+
+from app.core.config import settings
+from app.core.event import eventmanager, Event
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.schemas import NotificationType
+from app.schemas.types import EventType
+
+class Cd2Assistant(_PluginBase):
+ # 插件名称
+ plugin_name = "CloudDrive2助手"
+ # 插件描述
+ plugin_desc = "监控上传任务,检测是否有异常,发送通知。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/clouddrive.png"
+ # 插件版本
+ plugin_version = "1.1"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cd2assistant_"
+ # 加载顺序
+ plugin_order = 5
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 任务执行间隔
+ _enabled = False
+ _onlyonce: bool = False
+ _cd2_restart: bool = False
+ _cron = None
+ _notify = False
+ _msgtype = None
+ _keyword = None
+ _cd2_url = None
+ _cd2_username = None
+ _cd2_password = None
+ _cd2_client = None
+ _client = None
+
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._msgtype = config.get("msgtype")
+ self._onlyonce = config.get("onlyonce")
+ self._cd2_restart = config.get("cd2_restart")
+ self._cron = config.get("cron")
+ self._keyword = config.get("keyword")
+ self._cd2_url = config.get("cd2_url")
+ self._cd2_username = config.get("cd2_username")
+ self._cd2_password = config.get("cd2_password")
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce or self._cd2_restart:
+ if not self._cd2_url or not self._cd2_username or not self._cd2_password:
+ logger.error("CloudDrive2助手配置错误,请检查配置")
+ return
+
+ self._cd2_client = CloudDriveClient(self._cd2_url, self._cd2_username, self._cd2_password)
+ if not self._cd2_client:
+ logger.error("CloudDrive2助手连接失败,请检查配置")
+ return
+
+ self._client = Client(self._cd2_url, self._cd2_username, self._cd2_password)
+ if not self._client:
+ logger.error("CloudDrive2助手连接失败,请检查配置")
+ return
+
+ # 周期运行
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.check,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="CloudDrive2助手定时任务")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"CloudDrive2助手定时任务,立即运行一次")
+ self._scheduler.add_job(self.check, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="CloudDrive2助手定时任务")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 立即运行一次
+ if self._cd2_restart:
+ logger.info(f"CloudDrive2重启任务,立即运行一次")
+ self._scheduler.add_job(self.restart_cd2(), 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="CloudDrive2重启任务")
+ # 关闭一次性开关
+ self._cd2_restart = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cd2_restart": self._cd2_restart,
+ "cron": self._cron,
+ "msgtype": self._msgtype,
+ "keyword": self._keyword,
+ "notify": self._notify,
+ "cd2_url": self._cd2_url,
+ "cd2_username": self._cd2_username,
+ "cd2_password": self._cd2_password,
+ })
+
+ def check(self):
+ """
+ 检查上传任务
+ """
+ logger.info("开始检查CloudDrive2上传任务")
+ # 获取上传任务列表
+ upload_tasklist = self._cd2_client.upload_tasklist.list(page=0, page_size=10, filter="")
+ if not upload_tasklist:
+ logger.info("没有发现上传任务")
+ return
+
+ for task in upload_tasklist:
+ if task.get("status") == "FatalError" and self._keyword and re.search(self._keyword,
+ task.get("errorMessage")):
+ logger.info(f"发现异常上传任务:{task.get('errorMessage')}")
+ # 发送通知
+ if self._notify:
+ self.__send_notify(task)
+ break
+
+ @eventmanager.register(EventType.PluginAction)
+ def restart_cd2(self, event: Event = None):
+ """
+ 重启CloudDrive2
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "cd2_restart":
+ return
+
+ logger.info("CloudDrive2重启成功")
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="CloudDrive2重启成功!", userid=event.event_data.get("user"))
+
+ self._client.RestartService()
+
+
+ @eventmanager.register(EventType.PluginAction)
+ def cd2_info(self, event: Event = None):
+ """
+ 获取CloudDrive2信息
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "cd2_info":
+ return
+
+ # 运行信息
+ system_info = self._client.GetRunningInfo()
+ if system_info:
+ pattern = re.compile(r'(\w+): ([\d.]+)')
+ matches = pattern.findall(str(system_info))
+ # 将匹配到的结果转换为字典
+ system_info = {key: float(value) for key, value in matches}
+
+ # 上传任务数量
+ upload_count = self._client.GetUploadFileCount()
+ # 下载任务数量
+ download_count = self._client.GetDownloadFileCount()
+
+ system_info_dict = {
+ "cpuUsage": f"{system_info.get('cpuUsage'):.2f}%" if system_info.get(
+ "cpuUsage") else "0.00%" if system_info else None,
+ "memUsageKB": f"{system_info.get('memUsageKB') / 1024:.2f}MB" if system_info.get(
+ "memUsageKB") else "0MB" if system_info else None,
+ "uptime": self.convert_seconds(system_info.get('uptime')) if system_info.get(
+ "uptime") else "0秒" if system_info else None,
+ "fhTableCount": system_info.get('fhTableCount') if system_info.get(
+ "fhTableCount") else 0 if system_info else None,
+ "dirCacheCount": int(system_info.get('dirCacheCount')) if system_info.get(
+ "dirCacheCount") else 0 if system_info else None,
+ "tempFileCount": system_info.get('tempFileCount') if system_info.get(
+ "tempFileCount") else 0 if system_info else None,
+ "upload_count": str(upload_count).replace("fileCount: ", "") or 0 if upload_count and "fileCount" in str(
+ upload_count) else 0,
+ "download_count": str(download_count).replace("fileCount: ",
+ "") or 0 if download_count and "fileCount" in str(
+ download_count) else 0,
+ }
+
+ logger.info(f"获取CloudDrive2系统信息:\n{system_info_dict}")
+
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="CloudDrive2系统信息",
+ userid=event.event_data.get("user"),
+ text=f"CPU占用:{system_info_dict.get('cpuUsage')}\n"
+ f"内存占用:{system_info_dict.get('memUsageKB')}\n"
+ f"运行时间:{system_info_dict.get('uptime')}\n"
+ f"打开文件数量:{system_info_dict.get('fhTableCount')}\n"
+ f"目录缓存数量:{system_info_dict.get('dirCacheCount')}\n"
+ f"临时文件数量:{system_info_dict.get('tempFileCount')}\n"
+ f"上传任务数量:{system_info_dict.get('upload_count')}\n"
+ f"下载任务数量:{system_info_dict.get('download_count')}\n")
+
+ return system_info_dict
+
+ def __send_notify(self, task):
+ """
+ 发送通知
+ """
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+ self.post_message(title="CloudDrive2助手通知",
+ mtype=mtype,
+ text=task.get("errorMessage"))
+
+ @staticmethod
+ def convert_seconds(seconds):
+ days, seconds = divmod(seconds, 86400) # 86400秒 = 1天
+ hours, seconds = divmod(seconds, 3600) # 3600秒 = 1小时
+ minutes, seconds = divmod(seconds, 60) # 60秒 = 1分钟
+ parts = []
+ if days > 0:
+ parts.append(f"{int(days)}天")
+ if hours > 0:
+ parts.append(f"{int(hours)}小时")
+ if minutes > 0:
+ parts.append(f"{int(minutes)}分钟")
+ if seconds > 0 or not parts: # 添加秒数或只有秒数时
+ parts.append(f"{seconds:.0f}秒")
+
+ return ''.join(parts)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ return [
+ {
+ "cmd": "/cd2_restart",
+ "event": EventType.PluginAction,
+ "desc": "CloudDrive2重启",
+ "category": "",
+ "data": {
+ "action": "cd2_restart"
+ }
+ },
+ {
+ "cmd": "/cd2_info",
+ "event": EventType.PluginAction,
+ "desc": "CloudDrive2系统信息",
+ "category": "",
+ "data": {
+ "action": "cd2_info"
+ }
+ }
+ ]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '开启通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'cd2_restart',
+ 'label': 'cd2重启一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cd2_url',
+ 'label': 'cd2地址',
+ 'placeholder': 'http://127.0.0.1:19798'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cd2_username',
+ 'label': 'cd2用户名'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cd2_password',
+ 'label': 'cd2密码'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '检测周期',
+ 'placeholder': '5位cron表达式'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'keyword',
+ 'label': '检测关键字'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '周期检测CloudDrive2上传任务,检测是否命中检测关键词,发送通知。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "cd2_restart": False,
+ "cron": "*/10 * * * *",
+ "keyword": "账号异常",
+ "cd2_url": "",
+ "cd2_username": "",
+ "cd2_password": "",
+ "msgtype": "Manual"
+ }
+
+ def get_page(self) -> List[dict]:
+ cd2_info = self.cd2_info()
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': 'CPU占用'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('cpuUsage')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '内存占用'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('memUsageKB')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '运行时间'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('uptime')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '打开文件数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('fhTableCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '缓存目录数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('dirCacheCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '临时文件数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('tempFileCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '下载任务数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('download_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '上传任务数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('upload_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }]
+
+ def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
+ """
+ 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
+ 1、col配置参考:
+ {
+ "cols": 12, "md": 6
+ }
+ 2、全局配置参考:
+ {
+ "refresh": 10 // 自动刷新时间,单位秒
+ }
+ 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
+ """
+ # 列配置
+ cols = {
+ "cols": 12,
+ "md": 8
+ }
+ # 全局配置
+ attrs = {
+ "refresh": 10
+ }
+ if not self._client:
+ logger.warn(f"请求CloudDrive2服务失败")
+ elements = [
+ {
+ 'component': 'div',
+ 'text': '无法连接CloudDrive2',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+ else:
+ """
+ Active connections: 62
+ server accepts handled requests
+ 468843 468843 1368256
+ Reading: 0 Writing: 1 Waiting: 61
+ """
+ cd2_info = self.cd2_info()
+ elements = [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': 'CPU占用'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('cpuUsage')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '内存占用'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('memUsageKB')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '运行时间'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('uptime')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '打开文件数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('fhTableCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '缓存目录数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('dirCacheCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '临时文件数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('tempFileCount')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '下载任务数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('download_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 6,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '上传任务数'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': cd2_info.get('upload_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ }
+ ]
+ }]
+
+ return cols, attrs, elements
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/cd2assistant/requirements.txt b/plugins/cd2assistant/requirements.txt
new file mode 100644
index 0000000..765fe4c
--- /dev/null
+++ b/plugins/cd2assistant/requirements.txt
@@ -0,0 +1 @@
+clouddrive
\ No newline at end of file
diff --git a/plugins/cloudlinkmonitor/__init__.py b/plugins/cloudlinkmonitor/__init__.py
new file mode 100644
index 0000000..73da69e
--- /dev/null
+++ b/plugins/cloudlinkmonitor/__init__.py
@@ -0,0 +1,1008 @@
+import datetime
+import re
+import shutil
+import threading
+import traceback
+from pathlib import Path
+from typing import List, Tuple, Dict, Any, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+
+from app import schemas
+from app.chain.tmdb import TmdbChain
+from app.chain.transfer import TransferChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+from app.core.event import eventmanager, Event
+from app.core.metainfo import MetaInfoPath
+from app.db.downloadhistory_oper import DownloadHistoryOper
+from app.db.transferhistory_oper import TransferHistoryOper
+from app.log import logger
+from app.modules.filetransfer import FileTransferModule
+from app.plugins import _PluginBase
+from app.schemas import Notification, NotificationType, TransferInfo
+from app.schemas.types import EventType, MediaType, SystemConfigKey
+from app.utils.string import StringUtils
+from app.utils.system import SystemUtils
+
+lock = threading.Lock()
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, monpath: str, sync: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = monpath
+ self.sync = sync
+
+ def on_created(self, event):
+ self.sync.event_handler(event=event, text="创建",
+ mon_path=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.sync.event_handler(event=event, text="移动",
+ mon_path=self._watch_path, event_path=event.dest_path)
+
+
+class CloudLinkMonitor(_PluginBase):
+ # 插件名称
+ plugin_name = "云盘实时监控"
+ # 插件描述
+ plugin_desc = "监控云盘目录文件变化,自动转移链接。"
+ # 插件图标
+ plugin_icon = "Linkease_A.png"
+ # 插件版本
+ plugin_version = "2.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cloudlinkmonitor_"
+ # 加载顺序
+ plugin_order = 4
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _scheduler = None
+ transferhis = None
+ downloadhis = None
+ transferchian = None
+ tmdbchain = None
+ _observer = []
+ _enabled = False
+ _notify = False
+ _onlyonce = False
+ _cron = None
+ filetransfer = None
+ _size = 0
+ # 模式 compatibility/fast
+ _mode = "compatibility"
+ # 转移方式
+ _transfer_type = settings.TRANSFER_TYPE
+ _monitor_dirs = ""
+ _exclude_keywords = ""
+ _interval: int = 10
+ # 存储源目录与目的目录关系
+ _dirconf: Dict[str, Optional[Path]] = {}
+ # 存储源目录转移方式
+ _transferconf: Dict[str, Optional[str]] = {}
+ _scraperconf: Dict[str, Optional[bool]] = {}
+ _historyconf: Dict[str, Optional[bool]] = {}
+ _categoryconf: Dict[str, Optional[bool]] = {}
+ _medias = {}
+ # 退出事件
+ _event = threading.Event()
+
+ def init_plugin(self, config: dict = None):
+ self.transferhis = TransferHistoryOper()
+ self.downloadhis = DownloadHistoryOper()
+ self.transferchian = TransferChain()
+ self.tmdbchain = TmdbChain()
+ self.filetransfer = FileTransferModule()
+ # 清空配置
+ self._dirconf = {}
+ self._transferconf = {}
+ self._scraperconf = {}
+ self._historyconf = {}
+ self._categoryconf = {}
+
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._onlyonce = config.get("onlyonce")
+ self._mode = config.get("mode")
+ self._transfer_type = config.get("transfer_type")
+ self._monitor_dirs = config.get("monitor_dirs") or ""
+ self._exclude_keywords = config.get("exclude_keywords") or ""
+ self._interval = config.get("interval") or 10
+ self._cron = config.get("cron")
+ self._size = config.get("size") or 0
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务管理器
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ # 追加入库消息统一发送服务
+ self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15)
+
+ # 读取目录配置
+ monitor_dirs = self._monitor_dirs.split("\n")
+ if not monitor_dirs:
+ return
+ for mon_path in monitor_dirs:
+ # 格式源目录:目的目录
+ if not mon_path:
+ continue
+
+ # 是否添加一级二级分类
+ _category = True
+ if mon_path.count("@") == 1:
+ _category = mon_path.split("@")[1]
+ _category = True if _category == "True" else False
+ mon_path = mon_path.split("@")[0]
+
+ # 是否存储历史记录
+ _history = True
+ if mon_path.count("%") == 1:
+ _history = mon_path.split("%")[1]
+ _history = True if _history == "True" else False
+ mon_path = mon_path.split("%")[0]
+
+ # 是否刮削
+ _scraper_type = False
+ if mon_path.count("$") == 1:
+ _scraper_type = mon_path.split("$")[1]
+ _scraper_type = True if _scraper_type == "True" else False
+ mon_path = mon_path.split("$")[0]
+
+ # 自定义转移方式
+ _transfer_type = self._transfer_type
+ if mon_path.count("#") == 1:
+ _transfer_type = mon_path.split("#")[1]
+ mon_path = mon_path.split("#")[0]
+
+ # 存储目的目录
+ if SystemUtils.is_windows():
+ if mon_path.count(":") > 1:
+ paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1],
+ mon_path.split(":")[2] + ":" + mon_path.split(":")[3]]
+ else:
+ paths = [mon_path]
+ else:
+ paths = mon_path.split(":")
+
+ # 目的目录
+ target_path = None
+ if len(paths) > 1:
+ mon_path = paths[0]
+ target_path = Path(paths[1])
+ self._dirconf[mon_path] = target_path
+ else:
+ self._dirconf[mon_path] = None
+
+ # 是否二级分类
+ self._categoryconf[mon_path] = _category
+
+ # 是否存历史
+ self._historyconf[mon_path] = _history
+
+ # 是否刮削
+ self._scraperconf[mon_path] = _scraper_type
+
+ # 转移方式
+ self._transferconf[mon_path] = _transfer_type
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_path and target_path.is_relative_to(Path(mon_path)):
+ logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ try:
+ if self._mode == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{mon_path} 的目录监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{mon_path} 启动目录监控失败:{err_msg}")
+ self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}")
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("云盘实时监控服务启动,立即运行一次")
+ self._scheduler.add_job(name="云盘实时监控",
+ func=self.sync_all, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
+ )
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动定时服务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "notify": self._notify,
+ "onlyonce": self._onlyonce,
+ "mode": self._mode,
+ "transfer_type": self._transfer_type,
+ "monitor_dirs": self._monitor_dirs,
+ "exclude_keywords": self._exclude_keywords,
+ "interval": self._interval,
+ "cron": self._cron,
+ "size": self._size
+ })
+
+ @eventmanager.register(EventType.PluginAction)
+ def remote_sync(self, event: Event):
+ """
+ 远程全量同步
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "cloud_link_sync":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始同步监控目录 ...",
+ userid=event.event_data.get("user"))
+ self.sync_all()
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="监控目录同步完成!", userid=event.event_data.get("user"))
+
+ def sync_all(self):
+ """
+ 立即运行一次,全量同步目录中所有文件
+ """
+ logger.info("开始全量同步监控目录 ...")
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ # 遍历目录下所有文件
+ for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT):
+ self.__handle_file(event_path=str(file_path), mon_path=mon_path)
+ logger.info("全量同步监控目录完成!")
+
+ def event_handler(self, event, mon_path: str, text: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param mon_path: 监控目录
+ :param text: 事件描述
+ :param event_path: 事件文件路径
+ """
+ if not event.is_directory:
+ # 文件发生变化
+ logger.debug("文件%s:%s" % (text, event_path))
+ self.__handle_file(event_path=event_path, mon_path=mon_path)
+
+ def __handle_file(self, event_path: str, mon_path: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param mon_path: 监控目录
+ """
+ file_path = Path(event_path)
+ try:
+ if not file_path.exists():
+ return
+ # 全程加锁
+ with lock:
+ transfer_history = self.transferhis.get_by_src(event_path)
+ if transfer_history:
+ logger.debug("文件已处理过:%s" % event_path)
+ return
+
+ # 回收站及隐藏的文件不处理
+ if event_path.find('/@Recycle/') != -1 \
+ or event_path.find('/#recycle/') != -1 \
+ or event_path.find('/.') != -1 \
+ or event_path.find('/@eaDir') != -1:
+ logger.debug(f"{event_path} 是回收站或隐藏的文件")
+ return
+
+ # 命中过滤关键字不处理
+ if self._exclude_keywords:
+ for keyword in self._exclude_keywords.split("\n"):
+ if keyword and re.findall(keyword, event_path):
+ logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
+ return
+
+ # 整理屏蔽词不处理
+ transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
+ if transfer_exclude_words:
+ for keyword in transfer_exclude_words:
+ if not keyword:
+ continue
+ if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
+ logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
+ return
+
+ # 不是媒体文件不处理
+ if file_path.suffix not in settings.RMT_MEDIAEXT:
+ logger.debug(f"{event_path} 不是媒体文件")
+ return
+
+ # 判断是不是蓝光目录
+ if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
+ # 截取BDMV前面的路径
+ blurray_dir = event_path[:event_path.find("BDMV")]
+ file_path = Path(blurray_dir)
+ logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
+ # 查询历史记录,已转移的不处理
+ if self.transferhis.get_by_src(str(file_path)):
+ logger.info(f"{file_path} 已整理过")
+ return
+
+ # 元数据
+ file_meta = MetaInfoPath(file_path)
+ if not file_meta.name:
+ logger.error(f"{file_path.name} 无法识别有效信息")
+ return
+
+ # 判断文件大小
+ if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3:
+ logger.info(f"{file_path} 文件大小小于监控文件大小,不处理")
+ return
+
+ # 查询转移目的目录
+ target: Path = self._dirconf.get(mon_path)
+ # 查询转移方式
+ transfer_type = self._transferconf.get(mon_path)
+ # 是否刮削
+ scraper_type = self._scraperconf.get(mon_path)
+ # 是否存历史
+ history_type = self._historyconf.get(mon_path)
+ # 是否添加二级分类
+ category_type = self._categoryconf.get(mon_path)
+
+ # 识别媒体信息
+ mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta)
+ if not mediainfo:
+ logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
+ # 新增转移成功历史记录
+ his = self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ meta=file_meta
+ )
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.Manual,
+ title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
+ f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
+ )
+ return
+
+ # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
+ if not settings.SCRAP_FOLLOW_TMDB:
+ transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
+ mtype=mediainfo.type.value)
+ if transfer_history:
+ mediainfo.title = transfer_history.title
+ logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
+
+ # 获取集数据
+ if mediainfo.type == MediaType.TV:
+ episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
+ season=1 if file_meta.begin_season is None else file_meta.begin_season)
+ else:
+ episodes_info = None
+
+ if category_type:
+ # 转移
+ transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
+ path=file_path,
+ transfer_type=transfer_type,
+ target=target,
+ meta=file_meta,
+ episodes_info=episodes_info)
+ else:
+ # 转移
+ transferinfo: TransferInfo = self.filetransfer.transfer_media(in_path=file_path,
+ in_meta=file_meta,
+ mediainfo=mediainfo,
+ transfer_type=transfer_type,
+ target_dir=target,
+ episodes_info=episodes_info)
+ if not transferinfo:
+ logger.error("文件转移模块运行失败")
+ return
+
+ if not transferinfo.success:
+ # 转移失败
+ logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
+
+ if history_type:
+ # 新增转移失败历史记录
+ self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.Manual,
+ title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!",
+ text=f"原因:{transferinfo.message or '未知'}",
+ image=mediainfo.get_message_image()
+ )
+ return
+
+ if history_type:
+ # 新增转移成功历史记录
+ self.transferhis.add_success(
+ src_path=file_path,
+ mode=transfer_type,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+
+ # 刮削
+ if scraper_type:
+ # 更新媒体图片
+ self.chain.obtain_images(mediainfo=mediainfo)
+
+ # 刮削单个文件
+ if settings.SCRAP_METADATA:
+ self.chain.scrape_metadata(path=transferinfo.target_path,
+ mediainfo=mediainfo,
+ transfer_type=transfer_type)
+ """
+ {
+ "title_year season": {
+ "files": [
+ {
+ "path":,
+ "mediainfo":,
+ "file_meta":,
+ "transferinfo":
+ }
+ ],
+ "time": "2023-08-24 23:23:23.332"
+ }
+ }
+ """
+ # 发送消息汇总
+ media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
+ if media_list:
+ media_files = media_list.get("files") or []
+ if media_files:
+ file_exists = False
+ for file in media_files:
+ if str(file_path) == file.get("path"):
+ file_exists = True
+ break
+ if not file_exists:
+ media_files.append({
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ })
+ else:
+ media_files = [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ]
+ media_list = {
+ "files": media_files,
+ "time": datetime.datetime.now()
+ }
+ else:
+ media_list = {
+ "files": [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ],
+ "time": datetime.datetime.now()
+ }
+ self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
+
+ # 广播事件
+ self.eventmanager.send_event(EventType.TransferComplete, {
+ 'meta': file_meta,
+ 'mediainfo': mediainfo,
+ 'transferinfo': transferinfo
+ })
+
+ # 移动模式删除空目录
+ if transfer_type == "move":
+ for file_dir in file_path.parents:
+ if len(str(file_dir)) <= len(str(Path(mon_path))):
+ # 重要,删除到监控目录为止
+ break
+ files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT)
+ if not files:
+ logger.warn(f"移动模式,删除空目录:{file_dir}")
+ shutil.rmtree(file_dir, ignore_errors=True)
+
+ except Exception as e:
+ logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
+
+ def send_msg(self):
+ """
+ 定时检查是否有媒体处理完,发送统一消息
+ """
+ if not self._medias or not self._medias.keys():
+ return
+
+ # 遍历检查是否已刮削完,发送消息
+ for medis_title_year_season in list(self._medias.keys()):
+ media_list = self._medias.get(medis_title_year_season)
+ logger.info(f"开始处理媒体 {medis_title_year_season} 消息")
+
+ if not media_list:
+ continue
+
+ # 获取最后更新时间
+ last_update_time = media_list.get("time")
+ media_files = media_list.get("files")
+ if not last_update_time or not media_files:
+ continue
+
+ transferinfo = media_files[0].get("transferinfo")
+ file_meta = media_files[0].get("file_meta")
+ mediainfo = media_files[0].get("mediainfo")
+ # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息
+ if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \
+ or mediainfo.type == MediaType.MOVIE:
+ # 发送通知
+ if self._notify:
+
+ # 汇总处理文件总大小
+ total_size = 0
+ file_count = 0
+
+ # 剧集汇总
+ episodes = []
+ for file in media_files:
+ transferinfo = file.get("transferinfo")
+ total_size += transferinfo.total_size
+ file_count += 1
+
+ file_meta = file.get("file_meta")
+ if file_meta and file_meta.begin_episode:
+ episodes.append(file_meta.begin_episode)
+
+ transferinfo.total_size = total_size
+ # 汇总处理文件数量
+ transferinfo.file_count = file_count
+
+ # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04
+ season_episode = None
+ # 处理文件多,说明是剧集,显示季入库消息
+ if mediainfo.type == MediaType.TV:
+ # 季集文本
+ season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}"
+ # 发送消息
+ self.transferchian.send_transfer_message(meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo,
+ season_episode=season_episode)
+ # 发送完消息,移出key
+ del self._medias[medis_title_year_season]
+ continue
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/cloud_link_sync",
+ "event": EventType.PluginAction,
+ "desc": "云盘实时监控同步",
+ "category": "",
+ "data": {
+ "action": "cloud_link_sync"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [{
+ "path": "/cloud_link_sync",
+ "endpoint": self.sync,
+ "methods": ["GET"],
+ "summary": "云盘实时监控同步",
+ "description": "云盘实时监控同步",
+ }]
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ if self._enabled and self._cron:
+ return [{
+ "id": "CloudLinkMonitor",
+ "name": "云盘实时监控全量同步服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.sync_all,
+ "kwargs": {}
+ }]
+ return []
+
+ def sync(self) -> schemas.Response:
+ """
+ API调用目录同步
+ """
+ self.sync_all()
+ return schemas.Response(success=True)
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'mode',
+ 'label': '监控模式',
+ 'items': [
+ {'title': '兼容模式', 'value': 'compatibility'},
+ {'title': '性能模式', 'value': 'fast'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'transfer_type',
+ 'label': '转移方式',
+ 'items': [
+ {'title': '移动', 'value': 'move'},
+ {'title': '复制', 'value': 'copy'},
+ {'title': '硬链接', 'value': 'link'},
+ {'title': '软链接', 'value': 'filesoftlink'},
+ {'title': 'Rclone复制', 'value': 'rclone_copy'},
+ {'title': 'Rclone移动', 'value': 'rclone_move'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'interval',
+ 'label': '入库消息延迟',
+ 'placeholder': '10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '定时全量同步周期',
+ 'placeholder': '5位cron表达式,留空关闭'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '监控文件大小(GB)',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_dirs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n'
+ '监控目录:转移目的目录\n'
+ '监控目录:转移目的目录$是否刮削(True/False)\n'
+ '监控目录:转移目的目录#转移方式\n'
+ '监控目录:转移目的目录#转移方式$是否刮削(True/False)\n'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'exclude_keywords',
+ 'label': '排除关键词',
+ 'rows': 2,
+ 'placeholder': '每一行一个关键词'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控目录增加`@False/True`,默认True,拼接一级二级目录,False则不拼接一级二级目录。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '入库消息延迟默认10s,如网络较慢可酌情调大,有助于发送统一入库消息。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "mode": "fast",
+ "transfer_type": settings.TRANSFER_TYPE,
+ "monitor_dirs": "",
+ "exclude_keywords": "",
+ "interval": 10,
+ "cron": "",
+ "size": 0
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._event.set()
+ self._scheduler.shutdown()
+ self._event.clear()
+ self._scheduler = None
diff --git a/plugins/cloudstrm/__init__.py b/plugins/cloudstrm/__init__.py
new file mode 100644
index 0000000..3a3b94c
--- /dev/null
+++ b/plugins/cloudstrm/__init__.py
@@ -0,0 +1,735 @@
+import json
+import os
+import shutil
+import urllib.parse
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytz
+from typing import Any, List, Dict, Tuple, Optional
+
+from app.core.event import eventmanager, Event
+from app.schemas.types import EventType
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.log import logger
+from app.plugins import _PluginBase
+from app.core.config import settings
+
+
+class CloudStrm(_PluginBase):
+ # 插件名称
+ plugin_name = "云盘Strm生成"
+ # 插件描述
+ plugin_desc = "定时扫描云盘文件,生成Strm文件。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png"
+ # 插件版本
+ plugin_version = "4.4"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cloudstrm_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _cron = None
+ _rebuild_cron = None
+ _monitor_confs = None
+ _onlyonce = False
+ _copy_files = False
+ _rebuild = False
+ _https = False
+ _observer = []
+ __cloud_files_json = "cloud_files.json"
+
+ _dirconf = {}
+ _libraryconf = {}
+ _cloudtypeconf = {}
+ _cloudurlconf = {}
+ _cloudpathconf = {}
+ __cloud_files = []
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+ self._libraryconf = {}
+ self._cloudtypeconf = {}
+ self._cloudurlconf = {}
+ self._cloudpathconf = {}
+ self.__cloud_files_json = os.path.join(self.get_data_path(), self.__cloud_files_json)
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._cron = config.get("cron")
+ self._rebuild_cron = config.get("rebuild_cron")
+ self._onlyonce = config.get("onlyonce")
+ self._rebuild = config.get("rebuild")
+ self._https = config.get("https")
+ self._copy_files = config.get("copy_files")
+ self._monitor_confs = config.get("monitor_confs")
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ monitor_confs = self._monitor_confs.split("\n")
+ if not monitor_confs:
+ return
+ for monitor_conf in monitor_confs:
+ # 格式 源目录:目的目录:媒体库内网盘路径:监控模式
+ if not monitor_conf:
+ continue
+ # 注释
+ if str(monitor_conf).startswith("#"):
+ continue
+ if str(monitor_conf).count("#") == 2:
+ source_dir = str(monitor_conf).split("#")[0]
+ target_dir = str(monitor_conf).split("#")[1]
+ library_dir = str(monitor_conf).split("#")[2]
+ self._libraryconf[source_dir] = library_dir
+ elif str(monitor_conf).count("#") == 4:
+ source_dir = str(monitor_conf).split("#")[0]
+ target_dir = str(monitor_conf).split("#")[1]
+ cloud_type = str(monitor_conf).split("#")[2]
+ cloud_path = str(monitor_conf).split("#")[3]
+ cloud_url = str(monitor_conf).split("#")[4]
+ self._cloudtypeconf[source_dir] = cloud_type
+ self._cloudpathconf[source_dir] = cloud_path
+ self._cloudurlconf[source_dir] = cloud_url
+ else:
+ logger.error(f"{monitor_conf} 格式错误")
+ continue
+ # 存储目录监控配置
+ self._dirconf[source_dir] = target_dir
+
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_dir and Path(target_dir).is_relative_to(Path(source_dir)):
+ logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("云盘监控全量执行服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.scan, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="云盘监控全量执行")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.scan,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="云盘监控生成")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 周期运行
+ if self._rebuild_cron:
+ try:
+ self._scheduler.add_job(func=self.__init_cloud_files_json,
+ trigger=CronTrigger.from_crontab(self._rebuild_cron),
+ name="云盘监控重建索引")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ @eventmanager.register(EventType.PluginAction)
+ def scan(self, event: Event = None):
+ """
+ 扫描
+ """
+ if not self._enabled:
+ logger.error("插件未开启")
+ return
+ if not self._dirconf or not self._dirconf.keys():
+ logger.error("未获取到可用目录监控配置,请检查")
+ return
+
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "cloud_strm":
+ return
+ logger.info("收到命令,开始云盘strm生成 ...")
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始云盘strm生成 ...",
+ userid=event.event_data.get("user"))
+
+ logger.info("云盘strm生成任务开始")
+ # 首次扫描或者重建索引
+ __init_flag = False
+ if self._rebuild or not Path(self.__cloud_files_json).exists():
+ logger.info("正在重建索引或初始化运行")
+ self.__init_cloud_files_json()
+ self._rebuild = False
+ self.__update_config()
+ __init_flag = True
+ else:
+ logger.info("尝试加载本地缓存")
+ # 尝试加载本地
+ with open(self.__cloud_files_json, 'r') as file:
+ content = file.read()
+ if content:
+ self.__cloud_files = json.loads(content)
+
+ # 本地没加载到则重建索引
+ if not self.__cloud_files:
+ logger.error("尝试加载本地缓存,开始重建索引")
+ self.__init_cloud_files_json()
+ self._rebuild = False
+ self.__update_config()
+ __init_flag = True
+
+ # 不是首次索引,则重新扫描、判断是否有新文件
+ if not __init_flag:
+ __save_flag = False
+ for source_dir in self._dirconf.keys():
+ logger.info(f"正在处理监控文件 {source_dir}")
+ for root, dirs, files in os.walk(source_dir):
+ # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹
+ if "extrafanart" in dirs:
+ dirs.remove("extrafanart")
+
+ # 处理文件
+ for file in files:
+ source_file = os.path.join(root, file)
+ # 回收站及隐藏的文件不处理
+ if (source_file.find("/@Recycle") != -1
+ or source_file.find("/#recycle") != -1
+ or source_file.find("/.") != -1
+ or source_file.find("/@eaDir") != -1):
+ logger.info(f"{source_file} 是回收站或隐藏的文件,跳过处理")
+ continue
+
+ # 不复制非媒体文件时直接过滤掉非媒体文件
+ if not self._copy_files and Path(file).suffix.lower() not in settings.RMT_MEDIAEXT:
+ continue
+
+ if source_file not in self.__cloud_files:
+ logger.info(f"扫描到新文件 {source_file},正在开始处理")
+ # 云盘文件json新增
+ self.__cloud_files.append(source_file)
+ # 扫描云盘文件,判断是否有对应strm
+ self.__strm(source_file)
+ __save_flag = True
+ else:
+ logger.debug(f"{source_file} 已在缓存中!跳过处理")
+
+ # 重新保存json文件
+ if __save_flag:
+ self.__sava_json()
+
+ logger.info("云盘strm生成任务完成")
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="云盘strm生成任务完成!",
+ userid=event.event_data.get("user"))
+
+ def __init_cloud_files_json(self):
+ """
+ 初始化云盘文件json
+ """
+ # init
+ for source_dir in self._dirconf.keys():
+ logger.info(f"正在处理监控文件 {source_dir}")
+ for root, dirs, files in os.walk(source_dir):
+ # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹
+ if "extrafanart" in dirs:
+ dirs.remove("extrafanart")
+
+ # 处理文件
+ for file in files:
+ source_file = os.path.join(root, file)
+ # 回收站及隐藏的文件不处理
+ if (source_file.find("/@Recycle") != -1
+ or source_file.find("/#recycle") != -1
+ or source_file.find("/.") != -1
+ or source_file.find("/@eaDir") != -1):
+ logger.info(f"{source_file} 是回收站或隐藏的文件,跳过处理")
+ continue
+
+ # 不复制非媒体文件时直接过滤掉非媒体文件
+ if not self._copy_files and Path(file).suffix.lower() not in settings.RMT_MEDIAEXT:
+ continue
+
+ logger.info(f"扫描到新文件 {source_file},正在开始处理")
+ # 云盘文件json新增
+ self.__cloud_files.append(source_file)
+ # 扫描云盘文件,判断是否有对应strm
+ self.__strm(source_file)
+
+ # 写入本地文件
+ if self.__cloud_files:
+ self.__sava_json()
+ else:
+ logger.warning(f"未获取到文件列表")
+
+ def __sava_json(self):
+ """
+ 保存json文件
+ """
+ logger.info(f"开始写入本地文件 {self.__cloud_files_json}")
+ file = open(self.__cloud_files_json, 'w')
+ file.write(json.dumps(self.__cloud_files))
+ file.close()
+
+ def __strm(self, source_file):
+ """
+ 判断文件是否有对应strm
+ """
+ try:
+ # 获取文件的转移路径
+ for source_dir in self._dirconf.keys():
+ if str(source_file).startswith(source_dir):
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+
+ # 转移后文件
+ dest_file = source_file.replace(source_dir, dest_dir)
+ # 如果是文件夹
+ if Path(dest_file).is_dir():
+ if not Path(dest_file).exists():
+ logger.info(f"创建目标文件夹 {dest_file}")
+ os.makedirs(dest_file)
+ continue
+ else:
+ # 非媒体文件
+ if Path(dest_file).exists():
+ logger.info(f"目标文件 {dest_file} 已存在")
+ continue
+
+ # 文件
+ if not Path(dest_file).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(dest_file).parent}")
+ os.makedirs(Path(dest_file).parent)
+
+ # 视频文件创建.strm文件
+ if Path(dest_file).suffix.lower() in settings.RMT_MEDIAEXT:
+ # 创建.strm文件
+ self.__create_strm_file(scheme="https" if self._https else "http",
+ dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=source_file,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ if self._copy_files:
+ # 其他nfo、jpg等复制文件
+ shutil.copy2(source_file, dest_file)
+ logger.info(f"复制其他文件 {source_file} 到 {dest_file}")
+ except Exception as e:
+ logger.error(f"create strm file error: {e}")
+ print(str(e))
+
+ @staticmethod
+ def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None,
+ cloud_type: str = None, cloud_path: str = None, cloud_url: str = None,
+ scheme: str = None):
+ """
+ 生成strm文件
+ :param library_dir:
+ :param dest_dir:
+ :param dest_file:
+ """
+ try:
+ # 获取视频文件名和目录
+ video_name = Path(dest_file).name
+ # 获取视频目录
+ dest_path = Path(dest_file).parent
+
+ if not dest_path.exists():
+ logger.info(f"创建目标文件夹 {dest_path}")
+ os.makedirs(str(dest_path))
+
+ # 构造.strm文件路径
+ strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm")
+ # strm已存在跳过处理
+ if Path(strm_path).exists():
+ logger.info(f"strm文件已存在 {strm_path}")
+ return
+
+ logger.info(f"替换前本地路径:::{dest_file}")
+
+ # 云盘模式
+ if cloud_type:
+ # 替换路径中的\为/
+ dest_file = source_file.replace("\\", "/")
+ dest_file = dest_file.replace(cloud_path, "")
+ # 对盘符之后的所有内容进行url转码
+ dest_file = urllib.parse.quote(dest_file, safe='')
+ if str(cloud_type) == "cd2":
+ # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/"
+ dest_file = f"{scheme}://{cloud_url}/static/{scheme}/{cloud_url}/False/{dest_file}"
+ logger.info(f"替换后cd2路径:::{dest_file}")
+ elif str(cloud_type) == "alist":
+ dest_file = f"{scheme}://{cloud_url}/d/{dest_file}"
+ logger.info(f"替换后alist路径:::{dest_file}")
+ else:
+ logger.error(f"云盘类型 {cloud_type} 错误")
+ return
+ else:
+ # 本地挂载路径转为emby路径
+ dest_file = dest_file.replace(dest_dir, library_dir)
+ logger.info(f"替换后emby容器内路径:::{dest_file}")
+
+ # 写入.strm文件
+ with open(strm_path, 'w') as f:
+ f.write(dest_file)
+
+ logger.info(f"创建strm文件 {strm_path}")
+ except Exception as e:
+ logger.error(f"创建strm文件失败")
+ print(str(e))
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "rebuild": self._rebuild,
+ "copy_files": self._copy_files,
+ "https": self._https,
+ "cron": self._cron,
+ "monitor_confs": self._monitor_confs,
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/cloud_strm",
+ "event": EventType.PluginAction,
+ "desc": "云盘strm文件生成",
+ "category": "",
+ "data": {
+ "action": "cloud_strm"
+ }
+ }]
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ if self._enabled and self._cron:
+ return [{
+ "id": "CloudStrm",
+ "name": "云盘strm文件生成服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.scan,
+ "kwargs": {}
+ }]
+ return []
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ 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': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'rebuild',
+ 'label': '重建索引',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '生成周期',
+ 'placeholder': '0 0 * * *'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'rebuild_cron',
+ 'label': '重建索引周期',
+ 'placeholder': '0 1 * * *'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_confs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'copy_files',
+ 'label': '复制非媒体文件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'https',
+ 'label': '启用https',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '目录监控格式:'
+ '1.监控目录#目的目录#媒体服务器内源文件路径;'
+ '2.监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;'
+ '3.监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '配置说明:'
+ 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "cron": "",
+ "rebuild_cron": "",
+ "onlyonce": False,
+ "rebuild": False,
+ "copy_files": False,
+ "https": False,
+ "monitor_confs": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/cloudstrmapi/__init__.py b/plugins/cloudstrmapi/__init__.py
new file mode 100644
index 0000000..5428d0f
--- /dev/null
+++ b/plugins/cloudstrmapi/__init__.py
@@ -0,0 +1,728 @@
+import os
+import shutil
+import urllib.parse
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytz
+from typing import Any, List, Dict, Tuple, Optional
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+from app.log import logger
+from app.plugins import _PluginBase
+from app.core.config import settings
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, watching_path: str, file_change: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = watching_path
+ self.file_change = file_change
+
+ # def on_any_event(self, event):
+ # logger.info(f"目录监控event_type {event.event_type} 路径 {event.src_path}")
+
+ def on_created(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path)
+
+
+class CloudStrmApi(_PluginBase):
+ # 插件名称
+ plugin_name = "云盘Strm生成(API直链版)"
+ # 插件描述
+ plugin_desc = "监控文件创建,生成Strm文件。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png"
+ # 插件版本
+ plugin_version = "2.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cloudstrm_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _monitor_confs = None
+ _onlyonce = False
+ _relay = 3
+ _observer = []
+ _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts')
+
+ _dirconf = {}
+ _modeconf = {}
+ _libraryconf = {}
+ _cloudtypeconf = {}
+ _cloudurlconf = {}
+ _cloudpathconf = {}
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+ self._modeconf = {}
+ self._libraryconf = {}
+ self._cloudtypeconf = {}
+ self._cloudurlconf = {}
+ self._cloudpathconf = {}
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._monitor_confs = config.get("monitor_confs")
+ self._relay = config.get("relay") or 3
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ monitor_confs = self._monitor_confs.split("\n")
+ if not monitor_confs:
+ return
+ for monitor_conf in monitor_confs:
+ # 格式 源目录:目的目录:媒体库内网盘路径:监控模式
+ if not monitor_conf:
+ continue
+ if str(monitor_conf).count("#") == 3:
+ mode = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ library_dir = str(monitor_conf).split("#")[3]
+ self._libraryconf[source_dir] = library_dir
+ elif str(monitor_conf).count("#") == 5:
+ mode = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ cloud_type = str(monitor_conf).split("#")[3]
+ cloud_path = str(monitor_conf).split("#")[4]
+ cloud_url = str(monitor_conf).split("#")[5]
+ self._cloudtypeconf[source_dir] = cloud_type
+ self._cloudpathconf[source_dir] = cloud_path
+ self._cloudurlconf[source_dir] = cloud_url
+ else:
+ logger.error(f"{monitor_conf} 格式错误")
+ continue
+ # 存储目录监控配置
+ self._dirconf[source_dir] = target_dir
+ self._modeconf[source_dir] = mode
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_dir and Path(target_dir).is_relative_to(Path(source_dir)):
+ logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ # 异步开启云盘监控
+ logger.info(f"异步开启云盘监控 {source_dir} {mode}")
+ self._scheduler.add_job(func=self.start_monitor, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(
+ seconds=int(self._relay)),
+ name=f"云盘监控 {source_dir}",
+ kwargs={
+ "mode": mode,
+ "source_dir": source_dir
+ })
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("云盘监控服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.sync_all, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="云盘监控全量执行")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def start_monitor(self, mode: str, source_dir: str):
+ """
+ 异步开启云盘监控
+ """
+ try:
+ if str(mode) == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{source_dir} 的云盘监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}")
+ self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}")
+
+ def event_handler(self, event, source_dir: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param source_dir: 监控目录
+ :param event_path: 事件文件路径
+ """
+ # 回收站及隐藏的文件不处理
+ if (event_path.find("/@Recycle") != -1
+ or event_path.find("/#recycle") != -1
+ or event_path.find("/.") != -1
+ or event_path.find("/@eaDir") != -1):
+ logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理")
+ return
+
+ # 文件发生变化
+ logger.info(f"变动类型 {event.event_type} 变动路径 {event_path}")
+ self.__handle_file(event=event, event_path=event_path, source_dir=source_dir)
+
+ def __handle_file(self, event, event_path: str, source_dir: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param source_dir: 监控目录
+ """
+ try:
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+ # 文件夹同步创建
+ if event.is_directory:
+ target_path = event_path.replace(source_dir, dest_dir)
+ # 目标文件夹不存在则创建
+ if not Path(target_path).exists():
+ logger.info(f"创建目标文件夹 {target_path}")
+ os.makedirs(target_path)
+ else:
+ # 文件:nfo、图片、视频文件
+ dest_file = event_path.replace(source_dir, dest_dir)
+ if Path(dest_file).exists():
+ logger.debug(f"目标文件 {dest_file} 已存在")
+ return
+
+ # 目标文件夹不存在则创建
+ if not Path(dest_file).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(dest_file).parent}")
+ os.makedirs(Path(dest_file).parent)
+
+ # 视频文件创建.strm文件
+ if event_path.lower().endswith(self._video_formats):
+ # 如果视频文件小于1MB,则直接复制,不创建.strm文件
+ if os.path.getsize(event_path) < 1024 * 1024:
+ shutil.copy2(event_path, dest_file)
+ logger.info(f"复制视频文件 {event_path} 到 {dest_file}")
+ else:
+ # 创建.strm文件
+ self.__create_strm_file(dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=event_path,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ # 其他nfo、jpg等复制文件
+ shutil.copy2(event_path, dest_file)
+ logger.info(f"复制其他文件 {event_path} 到 {dest_file}")
+
+ except Exception as e:
+ logger.error(f"event_handler_created error: {e}")
+ print(str(e))
+
+ def sync_all(self):
+ """
+ 同步所有文件
+ """
+ if not self._dirconf or not self._dirconf.keys():
+ logger.error("未获取到可用目录监控配置,请检查")
+ return
+ for source_dir in self._dirconf.keys():
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+
+ logger.info(f"开始初始化生成strm文件 {source_dir}")
+ self.__handle_all(source_dir=source_dir,
+ dest_dir=dest_dir,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ logger.info(f"{source_dir} 初始化生成strm文件完成")
+
+ def __handle_all(self, source_dir, dest_dir, library_dir, cloud_type=None, cloud_path=None, cloud_url=None):
+ """
+ 遍历生成所有文件的strm
+ """
+ if not os.path.exists(dest_dir):
+ os.makedirs(dest_dir)
+
+ for root, dirs, files in os.walk(source_dir):
+ # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹
+ if "extrafanart" in dirs:
+ dirs.remove("extrafanart")
+
+ for file in files:
+ source_file = os.path.join(root, file)
+ logger.info(f"处理源文件::: {source_file}")
+
+ dest_file = os.path.join(dest_dir, os.path.relpath(source_file, source_dir))
+ if Path(dest_file).exists():
+ logger.debug(f"目标文件 {dest_file} 已存在")
+ return
+ logger.info(f"开始生成目标文件::: {dest_file}")
+
+ # 创建目标目录中缺少的文件夹
+ if not os.path.exists(Path(dest_file).parent):
+ os.makedirs(Path(dest_file).parent)
+
+ # 如果目标文件已存在,跳过处理
+ if os.path.exists(dest_file):
+ logger.warn(f"文件已存在,跳过处理::: {dest_file}")
+ continue
+
+ if file.lower().endswith(self._video_formats):
+ # 如果视频文件小于1MB,则直接复制,不创建.strm文件
+ if os.path.getsize(source_file) < 1024 * 1024:
+ logger.info(f"视频文件小于1MB的视频文件到:::{dest_file}")
+ shutil.copy2(source_file, dest_file)
+ else:
+ # 创建.strm文件
+ self.__create_strm_file(dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=source_file,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ # 复制文件
+ logger.info(f"复制其他文件到:::{dest_file}")
+ shutil.copy2(source_file, dest_file)
+
+ @staticmethod
+ def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None,
+ cloud_type: str = None, cloud_path: str = None, cloud_url: str = None):
+ """
+ 生成strm文件
+ :param library_dir:
+ :param dest_dir:
+ :param dest_file:
+ """
+ try:
+ # 获取视频文件名和目录
+ video_name = Path(dest_file).name
+ # 获取视频目录
+ dest_path = Path(dest_file).parent
+
+ if not dest_path.exists():
+ logger.info(f"创建目标文件夹 {dest_path}")
+ os.makedirs(str(dest_path))
+
+ # 构造.strm文件路径
+ strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm")
+ logger.info(f"替换前本地路径:::{dest_file}")
+
+ # 云盘模式
+ if cloud_type:
+ # 替换路径中的\为/
+ dest_file = source_file.replace("\\", "/")
+ dest_file = dest_file.replace(cloud_path, "")
+ # 对盘符之后的所有内容进行url转码
+ dest_file = urllib.parse.quote(dest_file, safe='')
+ if str(cloud_type) == "cd2":
+ # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/"
+ dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}"
+ logger.info(f"替换后cd2路径:::{dest_file}")
+ elif str(cloud_type) == "alist":
+ dest_file = f"http://{cloud_url}/d/{dest_file}"
+ logger.info(f"替换后alist路径:::{dest_file}")
+ else:
+ logger.error(f"云盘类型 {cloud_type} 错误")
+ return
+ else:
+ # 本地挂载路径转为emby路径
+ dest_file = dest_file.replace(dest_dir, library_dir)
+ logger.info(f"替换后emby容器内路径:::{dest_file}")
+
+ # 写入.strm文件
+ with open(strm_path, 'w') as f:
+ f.write(dest_file)
+
+ logger.info(f"创建strm文件 {strm_path}")
+ except Exception as e:
+ logger.error(f"创建strm文件失败")
+ print(str(e))
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "relay": self._relay,
+ "monitor_confs": self._monitor_confs
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'relay',
+ 'label': '监控延迟',
+ 'placeholder': '3'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_confs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'straight_chain',
+ 'label': '直链API',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'straight_confs',
+ 'label': '直链配置',
+ 'rows': 5,
+ 'placeholder': '媒体服务器内源文件路径#cd2#cd2挂载本地跟路径#cd2服务地址'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '目录监控格式:'
+ '1.监控方式#监控目录#目的目录#媒体服务器内源文件路径;'
+ '2.监控方式#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;'
+ '3.监控方式#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '媒体服务器内源文件路径:'
+ '源文件目录即云盘挂载到媒体服务器的路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控方式:'
+ 'fast:性能模式(快);'
+ 'compatibility:兼容模式(稳,推荐)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '立即运行一次:'
+ '全量运行一次。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '由于unraid开启云盘监控很慢,所以采取异步方式开启磁盘监控,'
+ '具体开启情况可稍等3-5分钟查看日志。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '配置说明:'
+ 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "relay": 3,
+ "onlyonce": False,
+ "monitor_confs": "",
+ "straight_chain": False
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
+
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
diff --git a/plugins/cloudstrmincrement/__init__.py b/plugins/cloudstrmincrement/__init__.py
new file mode 100644
index 0000000..1dff14c
--- /dev/null
+++ b/plugins/cloudstrmincrement/__init__.py
@@ -0,0 +1,746 @@
+import os
+import shutil
+import urllib.parse
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytz
+from typing import Any, List, Dict, Tuple, Optional
+
+from app.core.event import eventmanager, Event
+from app.schemas.types import EventType
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.log import logger
+from app.plugins import _PluginBase
+from app.core.config import settings
+from app.utils.system import SystemUtils
+
+
+class CloudStrmIncrement(_PluginBase):
+ # 插件名称
+ plugin_name = "云盘Strm生成(增量版)"
+ # 插件描述
+ plugin_desc = "定时扫描云盘文件,生成Strm文件(增量版)。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cloudstrm_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _cron = None
+ _monitor_confs = None
+ _onlyonce = False
+ _copy_files = False
+ _https = False
+ _no_del_dirs = None
+ _rmt_mediaext = ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+ _observer = []
+
+ # 公开属性
+ _increment_dir = {}
+ _dirconf = {}
+ _libraryconf = {}
+ _cloudtypeconf = {}
+ _cloudurlconf = {}
+ _cloudpathconf = {}
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+ self._libraryconf = {}
+ self._cloudtypeconf = {}
+ self._cloudurlconf = {}
+ self._cloudpathconf = {}
+ self._increment_dir = {}
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._cron = config.get("cron")
+ self._onlyonce = config.get("onlyonce")
+ self._https = config.get("https")
+ self._copy_files = config.get("copy_files")
+ self._monitor_confs = config.get("monitor_confs")
+ self._no_del_dirs = config.get("no_del_dirs")
+ self._rmt_mediaext = config.get(
+ "rmt_mediaext") or ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ monitor_confs = self._monitor_confs.split("\n")
+ if not monitor_confs:
+ return
+ for monitor_conf in monitor_confs:
+ # 格式 源目录:目的目录:媒体库内网盘路径:监控模式
+ if not monitor_conf:
+ continue
+ # 注释
+ if str(monitor_conf).startswith("#"):
+ continue
+
+ if str(monitor_conf).count("#") == 3:
+ increment_dir = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ library_dir = str(monitor_conf).split("#")[3]
+ self._libraryconf[source_dir] = library_dir
+ elif str(monitor_conf).count("#") == 5:
+ increment_dir = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ cloud_type = str(monitor_conf).split("#")[3]
+ cloud_path = str(monitor_conf).split("#")[4]
+ cloud_url = str(monitor_conf).split("#")[5]
+ self._cloudtypeconf[source_dir] = cloud_type
+ self._cloudpathconf[source_dir] = cloud_path
+ self._cloudurlconf[source_dir] = cloud_url
+ else:
+ logger.error(f"{monitor_conf} 格式错误")
+ continue
+
+ # 存储目录监控配置
+ self._dirconf[source_dir] = target_dir
+
+ # 增量配置
+ self._increment_dir[increment_dir] = source_dir
+
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_dir and Path(target_dir).is_relative_to(Path(source_dir)):
+ logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("云盘增量监控执行服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.scan, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="云盘增量监控")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.scan,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="云盘增量监控")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ @eventmanager.register(EventType.PluginAction)
+ def scan(self, event: Event = None):
+ """
+ 扫描
+ """
+ if not self._enabled:
+ logger.error("插件未开启")
+ return
+ if not self._dirconf or not self._dirconf.keys():
+ logger.error("未获取到可用目录监控配置,请检查")
+ return
+
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "cloud_strm_increment":
+ return
+ logger.info("收到命令,开始云盘strm生成 ...")
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始云盘strm生成 ...",
+ userid=event.event_data.get("user"))
+
+ logger.info("云盘strm生成任务开始")
+ for increment_dir in self._increment_dir.keys():
+ logger.info(f"正在扫描增量目录 {increment_dir}")
+ for root, dirs, files in os.walk(increment_dir):
+ # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹
+ if "extrafanart" in dirs:
+ dirs.remove("extrafanart")
+
+ # 处理文件
+ for file in files:
+ increment_file = os.path.join(root, file)
+ # 回收站及隐藏的文件不处理
+ if (increment_file.find("/@Recycle") != -1
+ or increment_file.find("/#recycle") != -1
+ or increment_file.find("/.") != -1
+ or increment_file.find("/@eaDir") != -1):
+ logger.info(f"{increment_file} 是回收站或隐藏的文件,跳过处理")
+ continue
+
+ # 不复制非媒体文件时直接过滤掉非媒体文件
+ if not self._copy_files and Path(file).suffix not in [ext.strip() for ext in
+ self._rmt_mediaext.split(",")]:
+ continue
+
+ logger.info(f"扫描到增量文件 {increment_file},正在开始处理")
+
+ # 移动到目标目录
+ source_dir = self._increment_dir.get(increment_dir)
+ # 移动后文件
+ source_file = increment_file.replace(increment_dir, source_dir)
+
+ # 判断目标文件是否存在
+ if not Path(source_file).parent.exists():
+ Path(source_file).parent.mkdir(parents=True, exist_ok=True)
+
+ shutil.move(increment_file, source_file, copy_function=shutil.copy2)
+ logger.info(f"移动增量文件 {increment_file} 到 {source_file}")
+
+ # 扫描云盘文件,判断是否有对应strm
+ self.__strm(source_file)
+ logger.info(f"增量文件 {increment_file} 处理完成")
+
+ # 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
+ if not SystemUtils.exits_files(Path(increment_file).parent,
+ [ext.strip() for ext in self._rmt_mediaext.split(",")]):
+ # 判断父目录是否为空, 为空则删除
+ for parent_path in Path(increment_file).parents:
+ if parent_path.name in self._no_del_dirs:
+ break
+ if str(parent_path.name) == str(increment_dir):
+ break
+ if str(parent_path.parent) != str(Path(increment_file).root):
+ # 父目录非根目录,才删除父目录
+ if not SystemUtils.exits_files(parent_path,
+ [ext.strip() for ext in self._rmt_mediaext.split(",")]):
+ # 当前路径下没有媒体文件则删除
+ shutil.rmtree(parent_path)
+ logger.warn(f"增量非保留目录 {parent_path} 已删除")
+
+ logger.info("云盘strm生成任务完成")
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="云盘strm生成任务完成!",
+ userid=event.event_data.get("user"))
+
+ # def move_file(self,
+ # file_path: Path,
+ # dest_path: Path,
+ # is_check_disk_space: bool = True,
+ # min_free_space: int = 300,
+ # wait_time: int = 300,
+ # check_paths: Optional[List[Path]] = None,
+ # ) -> bool:
+ # """
+ # 移动文件,如果父文件夹为空,则删除空父文件夹
+ # """
+ # # 在目标路径存在时,会尝试覆盖它
+ # if not file_path.exists():
+ # logger.debug(f"move文件不存在,跳过处理: {file_path}")
+ #
+ # if is_check_disk_space:
+ # if not check_paths:
+ # check_paths = [dest_path.parent]
+ # check_paths.append(data_path)
+ #
+ # for check_path in check_paths:
+ # while check_disk_space(check_path, min_free_space):
+ # logger.warning(
+ # f"文件 {check_path} 空间不足,等待 {wait_time}s再处理:"
+ # f" {file_path}"
+ # )
+ # sleep(wait_time)
+ #
+ # logger.debug(f"移动文件: {file_path} -> {dest_path}")
+ #
+ # # # 改用copy2,避免移动文件夹时,程序中断导致文件丢失
+ # # is_copyed = copy(file_path, dest_path)
+ # # # 复制成功才继续执行
+ # # if not is_copyed:
+ # # logger.warning(f"移动文件失败: {file_path} -> {dest_path}")
+ # # return False
+ #
+ # # # 复制后再删除文件
+ # # logger.debug(f"已复制文件:{file_path}, 正在删除文件: {file_path}")
+ #
+ # try:
+ # if not dest_path.parent.exists():
+ # dest_path.parent.mkdir(parents=True, exist_ok=True)
+ #
+ # cloud_str = "/mnt/cloud"
+ # if str(file_path).startswith(cloud_str) and str(dest_path).startswith(
+ # cloud_str
+ # ):
+ # # 如果是云盘路径,则使用重命名
+ # file_path.rename(dest_path)
+ # else:
+ # shutil.move(file_path, dest_path, copy_function=shutil.copy2)
+
+ def __strm(self, source_file):
+ """
+ 判断文件是否有对应strm
+ """
+ try:
+ # 获取文件的转移路径
+ for source_dir in self._dirconf.keys():
+ if str(source_file).startswith(source_dir):
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+
+ # 转移后文件
+ dest_file = source_file.replace(source_dir, dest_dir)
+ # 如果是文件夹
+ if Path(dest_file).is_dir():
+ if not Path(dest_file).exists():
+ logger.info(f"创建目标文件夹 {dest_file}")
+ os.makedirs(dest_file)
+ continue
+ else:
+ # 非媒体文件
+ if Path(dest_file).exists():
+ logger.info(f"目标文件 {dest_file} 已存在")
+ continue
+
+ # 文件
+ if not Path(dest_file).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(dest_file).parent}")
+ os.makedirs(Path(dest_file).parent)
+
+ # 视频文件创建.strm文件
+ if Path(dest_file).suffix in [ext.strip() for ext in self._rmt_mediaext.split(",")]:
+ # 创建.strm文件
+ self.__create_strm_file(scheme="https" if self._https else "http",
+ dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=source_file,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ if self._copy_files:
+ # 其他nfo、jpg等复制文件
+ shutil.copy2(source_file, dest_file)
+ logger.info(f"复制其他文件 {source_file} 到 {dest_file}")
+ except Exception as e:
+ logger.error(f"create strm file error: {e}")
+ print(str(e))
+
+ @staticmethod
+ def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None,
+ cloud_type: str = None, cloud_path: str = None, cloud_url: str = None,
+ scheme: str = None):
+ """
+ 生成strm文件
+ :param library_dir:
+ :param dest_dir:
+ :param dest_file:
+ """
+ try:
+ # 获取视频文件名和目录
+ video_name = Path(dest_file).name
+ # 获取视频目录
+ dest_path = Path(dest_file).parent
+
+ if not dest_path.exists():
+ logger.info(f"创建目标文件夹 {dest_path}")
+ os.makedirs(str(dest_path))
+
+ # 构造.strm文件路径
+ strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm")
+ # strm已存在跳过处理
+ if Path(strm_path).exists():
+ logger.info(f"strm文件已存在 {strm_path}")
+ return
+
+ logger.info(f"替换前本地路径:::{dest_file}")
+
+ # 云盘模式
+ if cloud_type:
+ # 替换路径中的\为/
+ dest_file = source_file.replace("\\", "/")
+ dest_file = dest_file.replace(cloud_path, "")
+ # 对盘符之后的所有内容进行url转码
+ dest_file = urllib.parse.quote(dest_file, safe='')
+ if str(cloud_type) == "cd2":
+ # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/"
+ dest_file = f"{scheme}://{cloud_url}/static/{scheme}/{cloud_url}/False/{dest_file}"
+ logger.info(f"替换后cd2路径:::{dest_file}")
+ elif str(cloud_type) == "alist":
+ dest_file = f"{scheme}://{cloud_url}/d/{dest_file}"
+ logger.info(f"替换后alist路径:::{dest_file}")
+ else:
+ logger.error(f"云盘类型 {cloud_type} 错误")
+ return
+ else:
+ # 本地挂载路径转为emby路径
+ dest_file = dest_file.replace(dest_dir, library_dir)
+ logger.info(f"替换后emby容器内路径:::{dest_file}")
+
+ # 写入.strm文件
+ with open(strm_path, 'w') as f:
+ f.write(dest_file)
+
+ logger.info(f"创建strm文件 {strm_path}")
+ except Exception as e:
+ logger.error(f"创建strm文件失败")
+ print(str(e))
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "copy_files": self._copy_files,
+ "https": self._https,
+ "cron": self._cron,
+ "monitor_confs": self._monitor_confs,
+ "no_del_dirs": self._no_del_dirs,
+ "rmt_mediaext": self._rmt_mediaext
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/cloud_strm_increment",
+ "event": EventType.PluginAction,
+ "desc": "云盘strm文件生成(增量版)",
+ "category": "",
+ "data": {
+ "action": "cloud_strm_increment"
+ }
+ }]
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ if self._enabled and self._cron:
+ return [{
+ "id": "CloudStrm",
+ "name": "云盘strm文件生成服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.scan,
+ "kwargs": {}
+ }]
+ return []
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'copy_files',
+ 'label': '复制非媒体文件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'https',
+ 'label': '启用https',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '生成周期',
+ 'placeholder': '0 0 * * *'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'no_del_dirs',
+ 'label': '保留路径',
+ 'placeholder': 'series、movies、downloads、others'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_confs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '增量目录#监控目录#目的目录#媒体服务器内源文件路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'rmt_mediaext',
+ 'label': '视频格式',
+ 'rows': 2,
+ 'placeholder': ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '目录监控格式:'
+ '1.增量目录#监控目录#目的目录#媒体服务器内源文件路径;'
+ '2.增量目录#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;'
+ '3.增量目录#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'success',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '配置教程请参考:'
+ },
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md',
+ 'target': '_blank'
+ },
+ 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ }
+ ], {
+ "enabled": False,
+ "cron": "",
+ "onlyonce": False,
+ "copy_files": False,
+ "https": False,
+ "monitor_confs": "",
+ "no_del_dirs": "",
+ "rmt_mediaext": ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/cloudstrmlocal/__init__.py b/plugins/cloudstrmlocal/__init__.py
new file mode 100644
index 0000000..ae1ea97
--- /dev/null
+++ b/plugins/cloudstrmlocal/__init__.py
@@ -0,0 +1,728 @@
+import os
+import shutil
+import urllib.parse
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytz
+from typing import Any, List, Dict, Tuple, Optional
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+from app.log import logger
+from app.plugins import _PluginBase
+from app.core.config import settings
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, watching_path: str, file_change: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = watching_path
+ self.file_change = file_change
+
+ # def on_any_event(self, event):
+ # logger.info(f"目录监控event_type {event.event_type} 路径 {event.src_path}")
+
+ def on_created(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path)
+
+
+class CloudStrmLocal(_PluginBase):
+ # 插件名称
+ plugin_name = "云盘Strm生成(本地直链版)"
+ # 插件描述
+ plugin_desc = "监控文件创建,生成Strm文件。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png"
+ # 插件版本
+ plugin_version = "2.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cloudstrm_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _monitor_confs = None
+ _onlyonce = False
+ _relay = 3
+ _observer = []
+ _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts')
+
+ _dirconf = {}
+ _modeconf = {}
+ _libraryconf = {}
+ _cloudtypeconf = {}
+ _cloudurlconf = {}
+ _cloudpathconf = {}
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+ self._modeconf = {}
+ self._libraryconf = {}
+ self._cloudtypeconf = {}
+ self._cloudurlconf = {}
+ self._cloudpathconf = {}
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._monitor_confs = config.get("monitor_confs")
+ self._relay = config.get("relay") or 3
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ monitor_confs = self._monitor_confs.split("\n")
+ if not monitor_confs:
+ return
+ for monitor_conf in monitor_confs:
+ # 格式 源目录:目的目录:媒体库内网盘路径:监控模式
+ if not monitor_conf:
+ continue
+ if str(monitor_conf).count("#") == 3:
+ mode = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ library_dir = str(monitor_conf).split("#")[3]
+ self._libraryconf[source_dir] = library_dir
+ elif str(monitor_conf).count("#") == 5:
+ mode = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ cloud_type = str(monitor_conf).split("#")[3]
+ cloud_path = str(monitor_conf).split("#")[4]
+ cloud_url = str(monitor_conf).split("#")[5]
+ self._cloudtypeconf[source_dir] = cloud_type
+ self._cloudpathconf[source_dir] = cloud_path
+ self._cloudurlconf[source_dir] = cloud_url
+ else:
+ logger.error(f"{monitor_conf} 格式错误")
+ continue
+ # 存储目录监控配置
+ self._dirconf[source_dir] = target_dir
+ self._modeconf[source_dir] = mode
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_dir and Path(target_dir).is_relative_to(Path(source_dir)):
+ logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ # 异步开启云盘监控
+ logger.info(f"异步开启云盘监控 {source_dir} {mode}")
+ self._scheduler.add_job(func=self.start_monitor, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(
+ seconds=int(self._relay)),
+ name=f"云盘监控 {source_dir}",
+ kwargs={
+ "mode": mode,
+ "source_dir": source_dir
+ })
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("云盘监控服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.sync_all, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="云盘监控全量执行")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def start_monitor(self, mode: str, source_dir: str):
+ """
+ 异步开启云盘监控
+ """
+ try:
+ if str(mode) == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{source_dir} 的云盘监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}")
+ self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}")
+
+ def event_handler(self, event, source_dir: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param source_dir: 监控目录
+ :param event_path: 事件文件路径
+ """
+ # 回收站及隐藏的文件不处理
+ if (event_path.find("/@Recycle") != -1
+ or event_path.find("/#recycle") != -1
+ or event_path.find("/.") != -1
+ or event_path.find("/@eaDir") != -1):
+ logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理")
+ return
+
+ # 文件发生变化
+ logger.info(f"变动类型 {event.event_type} 变动路径 {event_path}")
+ self.__handle_file(event=event, event_path=event_path, source_dir=source_dir)
+
+ def __handle_file(self, event, event_path: str, source_dir: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param source_dir: 监控目录
+ """
+ try:
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+ # 文件夹同步创建
+ if event.is_directory:
+ target_path = event_path.replace(source_dir, dest_dir)
+ # 目标文件夹不存在则创建
+ if not Path(target_path).exists():
+ logger.info(f"创建目标文件夹 {target_path}")
+ os.makedirs(target_path)
+ else:
+ # 文件:nfo、图片、视频文件
+ dest_file = event_path.replace(source_dir, dest_dir)
+ if Path(dest_file).exists():
+ logger.debug(f"目标文件 {dest_file} 已存在")
+ return
+
+ # 目标文件夹不存在则创建
+ if not Path(dest_file).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(dest_file).parent}")
+ os.makedirs(Path(dest_file).parent)
+
+ # 视频文件创建.strm文件
+ if event_path.lower().endswith(self._video_formats):
+ # 如果视频文件小于1MB,则直接复制,不创建.strm文件
+ if os.path.getsize(event_path) < 1024 * 1024:
+ shutil.copy2(event_path, dest_file)
+ logger.info(f"复制视频文件 {event_path} 到 {dest_file}")
+ else:
+ # 创建.strm文件
+ self.__create_strm_file(dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=event_path,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ # 其他nfo、jpg等复制文件
+ shutil.copy2(event_path, dest_file)
+ logger.info(f"复制其他文件 {event_path} 到 {dest_file}")
+
+ except Exception as e:
+ logger.error(f"event_handler_created error: {e}")
+ print(str(e))
+
+ def sync_all(self):
+ """
+ 同步所有文件
+ """
+ if not self._dirconf or not self._dirconf.keys():
+ logger.error("未获取到可用目录监控配置,请检查")
+ return
+ for source_dir in self._dirconf.keys():
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 媒体库容器内挂载路径
+ library_dir = self._libraryconf.get(source_dir)
+ # 云服务类型
+ cloud_type = self._cloudtypeconf.get(source_dir)
+ # 云服务挂载本地跟路径
+ cloud_path = self._cloudpathconf.get(source_dir)
+ # 云服务地址
+ cloud_url = self._cloudurlconf.get(source_dir)
+
+ logger.info(f"开始初始化生成strm文件 {source_dir}")
+ self.__handle_all(source_dir=source_dir,
+ dest_dir=dest_dir,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ logger.info(f"{source_dir} 初始化生成strm文件完成")
+
+ def __handle_all(self, source_dir, dest_dir, library_dir, cloud_type=None, cloud_path=None, cloud_url=None):
+ """
+ 遍历生成所有文件的strm
+ """
+ if not os.path.exists(dest_dir):
+ os.makedirs(dest_dir)
+
+ for root, dirs, files in os.walk(source_dir):
+ # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹
+ if "extrafanart" in dirs:
+ dirs.remove("extrafanart")
+
+ for file in files:
+ source_file = os.path.join(root, file)
+ logger.info(f"处理源文件::: {source_file}")
+
+ dest_file = os.path.join(dest_dir, os.path.relpath(source_file, source_dir))
+ if Path(dest_file).exists():
+ logger.debug(f"目标文件 {dest_file} 已存在")
+ return
+ logger.info(f"开始生成目标文件::: {dest_file}")
+
+ # 创建目标目录中缺少的文件夹
+ if not os.path.exists(Path(dest_file).parent):
+ os.makedirs(Path(dest_file).parent)
+
+ # 如果目标文件已存在,跳过处理
+ if os.path.exists(dest_file):
+ logger.warn(f"文件已存在,跳过处理::: {dest_file}")
+ continue
+
+ if file.lower().endswith(self._video_formats):
+ # 如果视频文件小于1MB,则直接复制,不创建.strm文件
+ if os.path.getsize(source_file) < 1024 * 1024:
+ logger.info(f"视频文件小于1MB的视频文件到:::{dest_file}")
+ shutil.copy2(source_file, dest_file)
+ else:
+ # 创建.strm文件
+ self.__create_strm_file(dest_file=dest_file,
+ dest_dir=dest_dir,
+ source_file=source_file,
+ library_dir=library_dir,
+ cloud_type=cloud_type,
+ cloud_path=cloud_path,
+ cloud_url=cloud_url)
+ else:
+ # 复制文件
+ logger.info(f"复制其他文件到:::{dest_file}")
+ shutil.copy2(source_file, dest_file)
+
+ @staticmethod
+ def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None,
+ cloud_type: str = None, cloud_path: str = None, cloud_url: str = None):
+ """
+ 生成strm文件
+ :param library_dir:
+ :param dest_dir:
+ :param dest_file:
+ """
+ try:
+ # 获取视频文件名和目录
+ video_name = Path(dest_file).name
+ # 获取视频目录
+ dest_path = Path(dest_file).parent
+
+ if not dest_path.exists():
+ logger.info(f"创建目标文件夹 {dest_path}")
+ os.makedirs(str(dest_path))
+
+ # 构造.strm文件路径
+ strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm")
+ logger.info(f"替换前本地路径:::{dest_file}")
+
+ # 云盘模式
+ if cloud_type:
+ # 替换路径中的\为/
+ dest_file = source_file.replace("\\", "/")
+ dest_file = dest_file.replace(cloud_path, "")
+ # 对盘符之后的所有内容进行url转码
+ dest_file = urllib.parse.quote(dest_file, safe='')
+ if str(cloud_type) == "cd2":
+ # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/"
+ dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}"
+ logger.info(f"替换后cd2路径:::{dest_file}")
+ elif str(cloud_type) == "alist":
+ dest_file = f"http://{cloud_url}/d/{dest_file}"
+ logger.info(f"替换后alist路径:::{dest_file}")
+ else:
+ logger.error(f"云盘类型 {cloud_type} 错误")
+ return
+ else:
+ # 本地挂载路径转为emby路径
+ dest_file = dest_file.replace(dest_dir, library_dir)
+ logger.info(f"替换后emby容器内路径:::{dest_file}")
+
+ # 写入.strm文件
+ with open(strm_path, 'w') as f:
+ f.write(dest_file)
+
+ logger.info(f"创建strm文件 {strm_path}")
+ except Exception as e:
+ logger.error(f"创建strm文件失败")
+ print(str(e))
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "relay": self._relay,
+ "monitor_confs": self._monitor_confs
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'relay',
+ 'label': '监控延迟',
+ 'placeholder': '3'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_confs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'straight_chain',
+ 'label': '直链API',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'straight_confs',
+ 'label': '直链配置',
+ 'rows': 5,
+ 'placeholder': '媒体服务器内源文件路径#cd2#cd2挂载本地跟路径#cd2服务地址'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '目录监控格式:'
+ '1.监控方式#监控目录#目的目录#媒体服务器内源文件路径;'
+ '2.监控方式#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;'
+ '3.监控方式#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '媒体服务器内源文件路径:'
+ '源文件目录即云盘挂载到媒体服务器的路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控方式:'
+ 'fast:性能模式(快);'
+ 'compatibility:兼容模式(稳,推荐)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '立即运行一次:'
+ '全量运行一次。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '由于unraid开启云盘监控很慢,所以采取异步方式开启磁盘监控,'
+ '具体开启情况可稍等3-5分钟查看日志。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '配置说明:'
+ 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "relay": 3,
+ "onlyonce": False,
+ "monitor_confs": "",
+ "straight_chain": False
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
+
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
diff --git a/plugins/commandexecute/__init__.py b/plugins/commandexecute/__init__.py
new file mode 100644
index 0000000..8d16d91
--- /dev/null
+++ b/plugins/commandexecute/__init__.py
@@ -0,0 +1,242 @@
+import subprocess
+
+from app.core.event import eventmanager, Event
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.schemas.types import EventType, MessageChannel
+
+
+class CommandExecute(_PluginBase):
+ # 插件名称
+ plugin_name = "命令执行器"
+ # 插件描述
+ plugin_desc = "自定义容器命令执行。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/command.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "commandexecute_"
+ # 加载顺序
+ plugin_order = 99
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _onlyonce = None
+ _command = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._onlyonce = config.get("onlyonce")
+ self._command = config.get("command")
+
+ if self._onlyonce and self._command:
+ # 执行SQL语句
+ try:
+ for command in self._command.split("\n"):
+ logger.info(f"开始执行命令 {command}")
+ ouptut = self.execute_command(command)
+ # logger.info('\n'.join(ouptut))
+ except Exception as e:
+ logger.error(f"命令执行失败 {str(e)}")
+ return
+ finally:
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": self._onlyonce,
+ "command": self._command
+ })
+
+ @staticmethod
+ def execute_command(command: str):
+ """
+ 执行命令
+ :param command: 命令
+ """
+ result = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ ouptut = []
+ while True:
+ error = result.stderr.readline().decode("utf-8")
+ if error == '' and result.poll() is not None:
+ break
+ if error:
+ logger.info(error.strip())
+ ouptut.append(error.strip())
+ while True:
+ output = result.stdout.readline().decode("utf-8")
+ if output == '' and result.poll() is not None:
+ break
+ if output:
+ logger.info(output.strip())
+ ouptut.append(output.strip())
+
+ return ouptut
+
+ @eventmanager.register(EventType.PluginAction)
+ def execute(self, event: Event = None):
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "command_execute":
+ return
+ logger.info(f"收到命令执行事件 ...{event_data}")
+ args = event_data.get("args")
+ if not args:
+ return
+
+ logger.info(f"收到命令,开始执行命令 ...{args}")
+ ouptut = self.execute_command(args)
+ result = '\n'.join(ouptut)
+
+ if event.event_data.get("channel") == MessageChannel.Telegram:
+ result = f"```plaintext\n{result}\n```"
+ self.post_message(channel=event.event_data.get("channel"),
+ title="命令执行结果",
+ text=result,
+ userid=event.event_data.get("user"))
+
+ def get_state(self) -> bool:
+ return True
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/cmd",
+ "event": EventType.PluginAction,
+ "desc": "自定义命令执行",
+ "category": "",
+ "data": {
+ "action": "command_execute"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '执行命令'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'command',
+ 'rows': '2',
+ 'label': 'command命令',
+ 'placeholder': '一行一条'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '执行日志将会输出到控制台,请谨慎操作。'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '可使用交互命令/cmd ls'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "onlyonce": False,
+ "command": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/customcommand/__init__.py b/plugins/customcommand/__init__.py
new file mode 100644
index 0000000..fa80495
--- /dev/null
+++ b/plugins/customcommand/__init__.py
@@ -0,0 +1,534 @@
+import random
+import re
+import subprocess
+import time
+from datetime import datetime, timedelta
+from typing import Any, List, Dict, Tuple, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.core.config import settings
+from app.log import logger
+from app.plugins import _PluginBase
+from app.schemas import NotificationType
+
+
+class CustomCommand(_PluginBase):
+ # 插件名称
+ plugin_name = "自定义命令"
+ # 插件描述
+ plugin_desc = "自定义执行周期执行命令并推送结果。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/code.png"
+ # 插件版本
+ plugin_version = "1.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "customcommand_"
+ # 加载顺序
+ plugin_order = 39
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _notify: bool = False
+ _clear: bool = False
+ _msgtype: str = None
+ _time_confs = None
+ _history_days = None
+ _notify_keywords = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._notify = config.get("notify")
+ self._msgtype = config.get("msgtype")
+ self._clear = config.get("clear")
+ self._history_days = config.get("history_days") or 30
+ self._notify_keywords = config.get("notify_keywords")
+ self._time_confs = config.get("time_confs")
+
+ # 清除历史
+ if self._clear:
+ self.del_data('history')
+ self._clear = False
+ self.__update_config()
+
+ if (self._enabled or self._onlyonce) and self._time_confs:
+ # 周期运行
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ # 分别执行命令,输入结果
+ for time_conf in self._time_confs.split("\n"):
+ if time_conf:
+ if str(time_conf).startswith("#"):
+ logger.info(f"已被注释,跳过 {time_conf}")
+ continue
+ if str(time_conf).count("#") == 2 or str(time_conf).count("#") == 3:
+ name = str(time_conf).split("#")[0]
+ cron = str(time_conf).split("#")[1]
+ command = str(time_conf).split("#")[2]
+ random_delay = None
+ if str(time_conf).count("#") == 3:
+ random_delay = str(time_conf).split("#")[3]
+
+ if self._onlyonce:
+ # 立即运行一次
+ logger.info(f"{name}服务启动,立即运行一次")
+ self._scheduler.add_job(self.__execute_command, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name=name,
+ args=[name, command])
+ else:
+ try:
+ self._scheduler.add_job(func=self.__execute_command,
+ trigger=CronTrigger.from_crontab(str(cron)),
+ name=name + (
+ f"随机延时{random_delay}秒" if random_delay else ""),
+ args=[name, command, random_delay])
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+ else:
+ logger.error(f"{time_conf} 配置错误,跳过处理")
+
+ if self._onlyonce:
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __execute_command(self, name, command, random_delay=None):
+ """
+ 执行命令
+ """
+ if random_delay:
+ random_delay = random.randint(int(str(random_delay).split("-")[0]), int(str(random_delay).split("-")[1]))
+ logger.info(f"随机延时 {random_delay} 秒")
+ time.sleep(random_delay)
+
+ result = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ last_output = None
+ last_error = None
+ while True:
+ error = result.stderr.readline().decode("utf-8")
+ if error == '' and result.poll() is not None:
+ break
+ if error:
+ logger.info(error.strip())
+ last_error = error.strip()
+ while True:
+ output = result.stdout.readline().decode("utf-8")
+ if output == '' and result.poll() is not None:
+ break
+ if output:
+ logger.info(output.strip())
+ last_output = output.strip()
+
+ logger.info(
+ f"执行命令:{command} {'成功' if result.returncode == 0 else '失败'} 返回值:{last_output if last_output else last_error}")
+
+ # 读取历史记录
+ history = self.get_data('history') or []
+
+ history.append({
+ "name": name,
+ "command": command,
+ "result": last_output if last_output else last_error,
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+
+ thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
+ history = [record for record in history if
+ datetime.strptime(record["time"],
+ '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
+ # 保存历史
+ self.save_data(key="history", value=history)
+
+ if self._notify and self._msgtype:
+ if self._notify_keywords and not re.search(self._notify_keywords,
+ last_output if last_output else last_error):
+ logger.info(f"通知关键词 {self._notify_keywords} 不匹配,跳过通知")
+ return
+
+ # 发送通知
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+
+ self.post_message(title=name,
+ mtype=mtype,
+ text=last_output if last_output else last_error)
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "notify": self._notify,
+ "msgtype": self._msgtype,
+ "time_confs": self._time_confs,
+ "history_days": self._history_days,
+ "notify_keywords": self._notify_keywords,
+ "clear": self._clear
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear',
+ 'label': '清除历史记录',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'history_days',
+ 'label': '保留历史天数'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'notify_keywords',
+ 'label': '通知关键词',
+ 'placeholder': '支持正则表达式,未配置时所有通知均推送'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'time_confs',
+ 'label': '执行命令',
+ 'rows': 2,
+ 'placeholder': '命令名#0 9 * * *#python main.py\n'
+ '命令名#0 9 * * *#python main.py#1-600'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '命令名#cron表达式#命令'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '命令名#cron表达式#命令#随机延时(单位秒)'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "clear": False,
+ "time_confs": "",
+ "history_days": 30,
+ "notify_keywords": "",
+ "msgtype": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ # 查询同步详情
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ if not isinstance(historys, list):
+ historys = [historys]
+
+ # 按照签到时间倒序
+ historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True)
+
+ # 签到消息
+ sign_msgs = [
+ {
+ 'component': 'tr',
+ 'props': {
+ 'class': 'text-sm'
+ },
+ 'content': [
+ {
+ 'component': 'td',
+ 'props': {
+ 'class': 'whitespace-nowrap break-keep text-high-emphasis'
+ },
+ 'text': history.get("time")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("name")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("result")
+ }
+ ]
+ } for history in historys
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTable',
+ 'props': {
+ 'hover': True
+ },
+ 'content': [
+ {
+ 'component': 'thead',
+ 'content': [
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '执行时间'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '命令名称'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '执行结果'
+ },
+ ]
+ },
+ {
+ 'component': 'tbody',
+ 'content': sign_msgs
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/dirmonitorenhanced/__init__.py b/plugins/dirmonitorenhanced/__init__.py
new file mode 100644
index 0000000..6df0af4
--- /dev/null
+++ b/plugins/dirmonitorenhanced/__init__.py
@@ -0,0 +1,1063 @@
+import datetime
+import re
+import shutil
+import threading
+import traceback
+from pathlib import Path
+from typing import List, Tuple, Dict, Any, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+
+from app import schemas
+from app.chain.tmdb import TmdbChain
+from app.chain.transfer import TransferChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+from app.core.event import eventmanager, Event
+from app.core.metainfo import MetaInfoPath
+from app.db.downloadhistory_oper import DownloadHistoryOper
+from app.db.transferhistory_oper import TransferHistoryOper
+from app.log import logger
+from app.plugins import _PluginBase
+from app.schemas import NotificationType, TransferInfo
+from app.schemas.types import EventType, MediaType, SystemConfigKey
+from app.utils.string import StringUtils
+from app.utils.system import SystemUtils
+
+lock = threading.Lock()
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, monpath: str, sync: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = monpath
+ self.sync = sync
+
+ def on_created(self, event):
+ self.sync.event_handler(event=event, text="创建",
+ mon_path=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.sync.event_handler(event=event, text="移动",
+ mon_path=self._watch_path, event_path=event.dest_path)
+
+
+class DirMonitorEnhanced(_PluginBase):
+ # 插件名称
+ plugin_name = "目录监控"
+ # 插件描述
+ plugin_desc = "监控目录文件发生变化时实时整理到媒体库。(统一入库消息增强版)"
+ # 插件图标
+ plugin_icon = "directory.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "dirmonitorenhanced_"
+ # 加载顺序
+ plugin_order = 4
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _scheduler = None
+ transferhis = None
+ downloadhis = None
+ transferchian = None
+ tmdbchain = None
+ _observer = []
+ _enabled = False
+ _notify = False
+ _onlyonce = False
+ _cron = None
+ _size = 0
+ _scrape = True
+ # 模式 compatibility/fast
+ _mode = "fast"
+ # 转移方式
+ _transfer_type = "link"
+ _monitor_dirs = ""
+ _exclude_keywords = ""
+ _interval: int = 10
+ # 存储源目录与目的目录关系
+ _dirconf: Dict[str, Optional[Path]] = {}
+ # 存储源目录转移方式
+ _transferconf: Dict[str, Optional[str]] = {}
+ _medias = {}
+ # 退出事件
+ _event = threading.Event()
+
+ def init_plugin(self, config: dict = None):
+ self.transferhis = TransferHistoryOper()
+ self.downloadhis = DownloadHistoryOper()
+ self.transferchian = TransferChain()
+ self.tmdbchain = TmdbChain()
+ # 清空配置
+ self._dirconf = {}
+ self._transferconf = {}
+
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._onlyonce = config.get("onlyonce")
+ self._mode = config.get("mode")
+ self._transfer_type = config.get("transfer_type")
+ self._monitor_dirs = config.get("monitor_dirs") or ""
+ self._exclude_keywords = config.get("exclude_keywords") or ""
+ self._interval = config.get("interval") or 10
+ self._cron = config.get("cron")
+ self._size = config.get("size") or 0
+ self._scrape = config.get("scrape") or False
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务管理器
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ # 追加入库消息统一发送服务
+ self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15)
+
+ # 读取目录配置
+ monitor_dirs = self._monitor_dirs.split("\n")
+ if not monitor_dirs:
+ return
+ for mon_path in monitor_dirs:
+ # 格式源目录:目的目录
+ if not mon_path:
+ continue
+
+ # 自定义转移方式
+ _transfer_type = self._transfer_type
+ if mon_path.count("#") == 1:
+ _transfer_type = mon_path.split("#")[1]
+ mon_path = mon_path.split("#")[0]
+
+ # 存储目的目录
+ if SystemUtils.is_windows():
+ if mon_path.count(":") > 1:
+ paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1],
+ mon_path.split(":")[2] + ":" + mon_path.split(":")[3]]
+ else:
+ paths = [mon_path]
+ else:
+ paths = mon_path.split(":")
+
+ # 目的目录
+ target_path = None
+ if len(paths) > 1:
+ mon_path = paths[0]
+ target_path = Path(paths[1])
+ self._dirconf[mon_path] = target_path
+ else:
+ self._dirconf[mon_path] = None
+
+ # 转移方式
+ self._transferconf[mon_path] = _transfer_type
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_path and target_path.is_relative_to(Path(mon_path)):
+ logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控",
+ title="目录监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ try:
+ if self._mode == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{mon_path} 的目录监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{mon_path} 启动目录监控失败:{err_msg}")
+ self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="目录监控")
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("目录监控服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.sync_all, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
+ )
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动定时服务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "notify": self._notify,
+ "onlyonce": self._onlyonce,
+ "mode": self._mode,
+ "transfer_type": self._transfer_type,
+ "monitor_dirs": self._monitor_dirs,
+ "exclude_keywords": self._exclude_keywords,
+ "interval": self._interval,
+ "cron": self._cron,
+ "size": self._size,
+ "scrape": self._scrape
+ })
+
+ @eventmanager.register(EventType.PluginAction)
+ def remote_sync(self, event: Event):
+ """
+ 远程全量同步
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "enhanced_directory_sync":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始同步监控目录 ...",
+ userid=event.event_data.get("user"))
+ self.sync_all()
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="监控目录同步完成!", userid=event.event_data.get("user"))
+
+ def sync_all(self):
+ """
+ 立即运行一次,全量同步目录中所有文件
+ """
+ logger.info("开始全量同步监控目录 ...")
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ # 遍历目录下所有文件
+ for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT):
+ self.__handle_file(event_path=str(file_path), mon_path=mon_path)
+ logger.info("全量同步监控目录完成!")
+
+ def event_handler(self, event, mon_path: str, text: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param mon_path: 监控目录
+ :param text: 事件描述
+ :param event_path: 事件文件路径
+ """
+ if not event.is_directory:
+ # 文件发生变化
+ logger.debug("文件%s:%s" % (text, event_path))
+ self.__handle_file(event_path=event_path, mon_path=mon_path)
+
+ def __handle_file(self, event_path: str, mon_path: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param mon_path: 监控目录
+ """
+ file_path = Path(event_path)
+ try:
+ if not file_path.exists():
+ return
+ # 全程加锁
+ with lock:
+ transfer_history = self.transferhis.get_by_src(event_path)
+ if transfer_history:
+ logger.debug("文件已处理过:%s" % event_path)
+ return
+
+ # 回收站及隐藏的文件不处理
+ if event_path.find('/@Recycle/') != -1 \
+ or event_path.find('/#recycle/') != -1 \
+ or event_path.find('/.') != -1 \
+ or event_path.find('/@eaDir') != -1:
+ logger.debug(f"{event_path} 是回收站或隐藏的文件")
+ return
+
+ # 命中过滤关键字不处理
+ if self._exclude_keywords:
+ for keyword in self._exclude_keywords.split("\n"):
+ if keyword and re.findall(keyword, event_path):
+ logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
+ return
+
+ # 整理屏蔽词不处理
+ transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
+ if transfer_exclude_words:
+ for keyword in transfer_exclude_words:
+ if not keyword:
+ continue
+ if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
+ logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
+ return
+
+ # 不是媒体文件不处理
+ if file_path.suffix not in settings.RMT_MEDIAEXT:
+ logger.debug(f"{event_path} 不是媒体文件")
+ return
+
+ # 判断是不是蓝光目录
+ bluray_flag = False
+ if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
+ bluray_flag = True
+ # 截取BDMV前面的路径
+ blurray_dir = event_path[:event_path.find("BDMV")]
+ file_path = Path(blurray_dir)
+ logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
+
+ # 查询历史记录,已转移的不处理
+ if self.transferhis.get_by_src(str(file_path)):
+ logger.info(f"{file_path} 已整理过")
+ return
+
+ # 元数据
+ file_meta = MetaInfoPath(file_path)
+ if not file_meta.name:
+ logger.error(f"{file_path.name} 无法识别有效信息")
+ return
+
+ # 判断文件大小
+ if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3:
+ logger.info(f"{file_path} 文件大小小于监控文件大小,不处理")
+ return
+
+ # 查询转移目的目录
+ target: Path = self._dirconf.get(mon_path)
+ # 查询转移方式
+ transfer_type = self._transferconf.get(mon_path)
+
+ # 根据父路径获取下载历史
+ download_history = None
+ if bluray_flag:
+ # 蓝光原盘,按目录名查询
+ # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询
+ download_history = self.downloadhis.get_by_path(file_path.name)
+ else:
+ # 按文件全路径查询
+ download_file = self.downloadhis.get_file_by_fullpath(str(file_path))
+ if download_file:
+ download_history = self.downloadhis.get_by_hash(download_file.download_hash)
+
+ # 识别媒体信息
+ mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta,
+ mtype=MediaType(
+ download_history.type) if download_history else None,
+ tmdbid=download_history.tmdbid if download_history else None)
+ if not mediainfo:
+ logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
+ # 新增转移成功历史记录
+ his = self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ meta=file_meta
+ )
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.Manual,
+ title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
+ f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
+ )
+ return
+
+ # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
+ if not settings.SCRAP_FOLLOW_TMDB:
+ transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
+ mtype=mediainfo.type.value)
+ if transfer_history:
+ mediainfo.title = transfer_history.title
+ logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
+
+ # 更新媒体图片
+ self.chain.obtain_images(mediainfo=mediainfo)
+
+ # 获取集数据
+ if mediainfo.type == MediaType.TV:
+ episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
+ season=file_meta.begin_season or 1)
+ else:
+ episodes_info = None
+
+ # 获取下载Hash
+ download_hash = None
+ if download_history:
+ download_hash = download_history.download_hash
+
+ # 转移
+ transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
+ path=file_path,
+ transfer_type=transfer_type,
+ target=target,
+ meta=file_meta,
+ episodes_info=episodes_info)
+
+ if not transferinfo:
+ logger.error("文件转移模块运行失败")
+ return
+
+ if not transferinfo.success:
+ # 判断是否转移后文件已存在,补充转移成功历史记录
+ if transferinfo.target_path and transferinfo.target_path.exists():
+ logger.info(f"{file_path.name} 目标文件已存在,补充转移成功历史记录")
+ # 补充转移成功历史记录
+ self.transferhis.add_success(
+ src_path=file_path,
+ mode=transfer_type,
+ download_hash=download_hash,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+ return
+
+ # 转移失败
+ logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
+ # 新增转移失败历史记录
+ self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ download_hash=download_hash,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.Manual,
+ title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!",
+ text=f"原因:{transferinfo.message or '未知'}",
+ image=mediainfo.get_message_image()
+ )
+ return
+
+ # 新增转移成功历史记录
+ self.transferhis.add_success(
+ src_path=file_path,
+ mode=transfer_type,
+ download_hash=download_hash,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+
+ # 刮削单个文件
+ if self._scrape:
+ self.chain.scrape_metadata(path=transferinfo.target_path,
+ mediainfo=mediainfo,
+ transfer_type=transfer_type)
+
+ """
+ {
+ "title_year season": {
+ "files": [
+ {
+ "path":,
+ "mediainfo":,
+ "file_meta":,
+ "transferinfo":
+ }
+ ],
+ "time": "2023-08-24 23:23:23.332",
+ "all_files_cnt": 20
+ }
+ }
+ """
+ # 发送消息汇总
+ media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
+ if media_list:
+ media_files = media_list.get("files") or []
+ if media_files:
+ file_exists = False
+ for file in media_files:
+ if str(file_path) == file.get("path"):
+ file_exists = True
+ break
+ if not file_exists:
+ media_files.append({
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ })
+ else:
+ media_files = [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ]
+ media_list = {
+ "files": media_files,
+ "time": datetime.datetime.now(),
+ "all_files_cnt": media_list.get("all_files_cnt")
+ }
+ else:
+ # 获取当前媒体本次下载的文件数
+ recent_download_files_cnt = self.__get_recent_download_files_cnt(download_hash=download_hash)
+
+ media_list = {
+ "files": [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ],
+ "time": datetime.datetime.now(),
+ "all_files_cnt": recent_download_files_cnt
+ }
+ self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
+
+ # 广播事件
+ self.eventmanager.send_event(EventType.TransferComplete, {
+ 'meta': file_meta,
+ 'mediainfo': mediainfo,
+ 'transferinfo': transferinfo
+ })
+
+ # 移动模式删除空目录
+ if transfer_type == "move":
+ for file_dir in file_path.parents:
+ if len(str(file_dir)) <= len(str(Path(mon_path))):
+ # 重要,删除到监控目录为止
+ break
+ files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT)
+ if not files:
+ logger.warn(f"移动模式,删除空目录:{file_dir}")
+ shutil.rmtree(file_dir, ignore_errors=True)
+
+ except Exception as e:
+ logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
+
+ def __get_recent_download_files_cnt(self, download_hash: str):
+ """
+ 1。根据download_hash查询下载历史
+ 2。查询该下载历史记录创建时间前1分钟及以后的所有的该type和tmdbid下的下载历史(订阅批量下载的话,下载时间间隔应该不会超过一分钟吧。)
+ 3。根据查询到的下载历史列表遍历查询对应的下载文件记录
+ 4。根据统计的下载文件记录数目,等待入库消息统一发送。
+ 5。统一入库消息(如果当前入库的媒体数据 < 本次批量下载的文件数量,暂不处理,等待一会(容错:最大retry 5次))
+ """
+ # 根据download_hash查询下载记录
+ recent_download_files = 0
+ try:
+ download_history = self.downloadhis.get_by_hash(download_hash=download_hash)
+ if download_history:
+ # 根据下载历史查询 下载时间前一分钟及以后的下载记录
+ # 将时间字符串转换为datetime对象 - 减去一分钟
+ new_dt = datetime.datetime.strptime(download_history.date, "%Y-%m-%d %H:%M:%S") - datetime.timedelta(
+ minutes=1)
+ download_historys = self.downloadhis.list_by_date(date=new_dt.strftime("%Y-%m-%d %H:%M:%S"),
+ type=download_history.type,
+ tmdbid=str(download_history.tmdbid),
+ seasons=download_history.seasons)
+ if download_historys:
+ for download_his in download_historys:
+ # 根据download_hash获取下载文件列表
+ download_files = self.downloadhis.get_files_by_hash(
+ download_hash=download_his.download_hash,
+ state=1)
+ if download_files:
+ recent_download_files += len(download_files)
+ except Exception as e:
+ print(str(e))
+
+ return recent_download_files
+
+ def send_msg(self):
+ """
+ 定时检查是否有媒体处理完,发送统一消息
+ """
+ if not self._medias or not self._medias.keys():
+ return
+
+ # 遍历检查是否已刮削完,发送消息
+ for medis_title_year_season in list(self._medias.keys()):
+ media_list = self._medias.get(medis_title_year_season)
+ logger.info(f"开始处理媒体 {medis_title_year_season} 消息")
+
+ if not media_list:
+ continue
+
+ # 获取最后更新时间
+ last_update_time = media_list.get("time")
+ media_files = media_list.get("files")
+ if not last_update_time or not media_files:
+ continue
+
+ all_files_cnt = media_list.get("all_files_cnt") or 0
+ retry_cnt = media_list.get("retry_cnt") or 0
+ transferinfo = media_files[0].get("transferinfo")
+ file_meta = media_files[0].get("file_meta")
+ mediainfo = media_files[0].get("mediainfo")
+ # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息
+ if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \
+ or mediainfo.type == MediaType.MOVIE:
+
+ # 如果当前入库的媒体数据 < 本次批量下载的文件数量,暂不处理,等待一会(容错:最大retry 5次)
+ if all_files_cnt > 0 and len(media_files) < all_files_cnt and retry_cnt < 5:
+ # 更新重试次数
+ media_list['retry_cnt'] = retry_cnt + 1
+ self._medias[medis_title_year_season] = media_list
+ logger.info(
+ f"本次批量下载任务{all_files_cnt}个文件,已转移文件{len(media_files)}个,未完全转移,等待{int(self._interval)}秒开始重试第{retry_cnt + 1}次,最大重试5次")
+ continue
+
+ # 发送通知
+ if self._notify:
+ # 汇总处理文件总大小
+ total_size = 0
+ file_count = 0
+
+ # 剧集汇总
+ episodes = []
+ for file in media_files:
+ transferinfo = file.get("transferinfo")
+ total_size += transferinfo.total_size
+ file_count += 1
+
+ file_meta = file.get("file_meta")
+ if file_meta and file_meta.begin_episode:
+ episodes.append(file_meta.begin_episode)
+
+ transferinfo.total_size = total_size
+ # 汇总处理文件数量
+ transferinfo.file_count = file_count
+
+ # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04
+ season_episode = None
+ # 处理文件多,说明是剧集,显示季入库消息
+ if mediainfo.type == MediaType.TV:
+ # 季集文本
+ season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}"
+ # 发送消息
+ self.transferchian.send_transfer_message(meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo,
+ season_episode=season_episode)
+ # 发送完消息,移出key
+ del self._medias[medis_title_year_season]
+ continue
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/enhanced_directory_sync",
+ "event": EventType.PluginAction,
+ "desc": "目录监控同步(统一入库消息增强版)",
+ "category": "管理",
+ "data": {
+ "action": "enhanced_directory_sync"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [{
+ "path": "/enhanced_directory_sync",
+ "endpoint": self.sync,
+ "methods": ["GET"],
+ "summary": "目录监控(统一入库消息增强版)",
+ "description": "目录监控(统一入库消息增强版)",
+ }]
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ if self._enabled and self._cron:
+ return [{
+ "id": "DirMonitorEnhanced",
+ "name": "目录监控(统一入库消息增强版)全量同步服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.sync_all,
+ "kwargs": {}
+ }]
+ return []
+
+ def sync(self, apikey: str) -> schemas.Response:
+ """
+ API调用目录同步
+ """
+ if apikey != settings.API_TOKEN:
+ return schemas.Response(success=False, message="API密钥错误")
+ self.sync_all()
+ return schemas.Response(success=True)
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'mode',
+ 'label': '监控模式',
+ 'items': [
+ {'title': '兼容模式', 'value': 'compatibility'},
+ {'title': '性能模式', 'value': 'fast'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'transfer_type',
+ 'label': '整理方式',
+ 'items': [
+ {'title': '移动', 'value': 'move'},
+ {'title': '复制', 'value': 'copy'},
+ {'title': '硬链接', 'value': 'link'},
+ {'title': '软链接', 'value': 'softlink'},
+ {'title': 'Rclone复制', 'value': 'rclone_copy'},
+ {'title': 'Rclone移动', 'value': 'rclone_move'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'interval',
+ 'label': '入库消息延迟',
+ 'placeholder': '10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '定时全量同步周期',
+ 'placeholder': '5位cron表达式,留空关闭'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '监控文件大小(GB)',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'scrape',
+ 'label': '刮削元数据',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_dirs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n'
+ '监控目录\n'
+ '监控目录#整理方式\n'
+ '监控目录:整理目的目录\n'
+ '监控目录:整理目的目录#转移方式'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'exclude_keywords',
+ 'label': '排除关键词',
+ 'rows': 2,
+ 'placeholder': '每一行一个关键词'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '支持4种配置方式:1、监控目录,2、监控目录#整理方式,3、监控目录:整理目的目录,4、监控目录:整理目的目录#转移方式。监控目录不指定目的目录时,将按媒体库目录设置整理到媒体库目录,并根据目录的分类设置自动创建一二级分类目录;监控目录指定了目的目录时,会尝试在媒体库目录设定中查找对应路径的目录配置,如存在则以目录设定的分类选项创建子目录,否则直接整理到该目的目录下。建议不设置目的目录,由系统根据目录设定自动分类整理。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '入库消息延迟默认10s,如网络较慢可酌情调大,有助于发送统一入库消息。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "mode": "fast",
+ "transfer_type": "link",
+ "monitor_dirs": "",
+ "exclude_keywords": "",
+ "interval": 10,
+ "cron": "",
+ "size": 0,
+ "scrape": True
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._event.set()
+ self._scheduler.shutdown()
+ self._event.clear()
+ self._scheduler = None
diff --git a/plugins/dockermanager/__init__.py b/plugins/dockermanager/__init__.py
new file mode 100644
index 0000000..fb3063f
--- /dev/null
+++ b/plugins/dockermanager/__init__.py
@@ -0,0 +1,510 @@
+import docker
+import time
+from datetime import datetime, timedelta
+from typing import Any, List, Dict, Tuple, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.core.config import settings
+from app.log import logger
+from app.plugins import _PluginBase
+from app.schemas import NotificationType
+
+
+class DockerManager(_PluginBase):
+ # 插件名称
+ plugin_name = "docker自定义任务"
+ # 插件描述
+ plugin_desc = "管理宿主机docker,自定义容器定时任务。"
+ # 插件图标
+ plugin_icon = "Docker_F.png"
+ # 插件版本
+ plugin_version = "1.3"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "dockermanager_"
+ # 加载顺序
+ plugin_order = 39
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _notify: bool = False
+ _clear: bool = False
+ _msgtype: str = None
+ _time_confs = None
+ _docker_client = None
+ _history_days = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._notify = config.get("notify")
+ self._msgtype = config.get("msgtype")
+ self._clear = config.get("clear")
+ self._time_confs = config.get("time_confs")
+ self._history_days = config.get("history_days") or 30
+
+ # 清除历史
+ if self._clear:
+ self.del_data('history')
+ self._clear = False
+ self.__update_config()
+
+ if (self._enabled or self._onlyonce) and self._time_confs:
+ # 创建 Docker 客户端
+ self._docker_client = docker.DockerClient(base_url='tcp://127.0.0.1:38379')
+ # 周期运行
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ # 分别执行命令,输入结果
+ for time_conf in self._time_confs.split("\n"):
+ if time_conf:
+ if str(time_conf).startswith("#"):
+ logger.info(f"已被注释,跳过 {time_conf}")
+ continue
+ if str(time_conf).count("#") == 2:
+ name = str(time_conf).split("#")[0]
+ cron = str(time_conf).split("#")[1]
+ command = str(time_conf).split("#")[2]
+ if self._onlyonce:
+ # 立即运行一次
+ logger.info(f"容器 {name} 立即执行 {command}")
+ self._scheduler.add_job(self.__execute_command, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name=f"{name} {command}",
+ args=[name, command])
+ else:
+ try:
+ self._scheduler.add_job(func=self.__execute_command,
+ trigger=CronTrigger.from_crontab(str(cron)),
+ name=f"{name} {command}",
+ args=[name, command])
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+ else:
+ logger.error(f"{time_conf} 配置错误,跳过处理")
+
+ if self._onlyonce:
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __execute_command(self, name, command):
+ """
+ 执行命令
+ """
+ # 获取所有容器列表
+ containers = self._docker_client.containers.list(all=True)
+
+ container_names = str(name).split(",")
+
+ container_icon = None
+ log_text = ""
+ # 遍历容器列表,找到对应名称的容器ID
+ for container in containers:
+ for env in container.attrs['Config']['Env']:
+ if str(env.split("=")[0]) == "HOST_CONTAINERNAME":
+ if str(env.split('=')[1]) in container_names:
+ container_id = container.id
+ # 执行命令
+ log_text += f"容器:{env.split('=')[1]} {command}"
+
+ try:
+ state = True
+ if str(command) == "restart":
+ self._docker_client.containers.get(container_id).restart()
+ elif str(command) == "start":
+ self._docker_client.containers.get(container_id).start()
+ elif str(command) == "stop":
+ self._docker_client.containers.get(container_id).stop()
+ elif str(command) == "pause":
+ self._docker_client.containers.get(container_id).pause()
+ elif str(command) == "unpause":
+ self._docker_client.containers.get(container_id).unpause()
+ elif str(command) == "update":
+ self._docker_client.containers.get(container_id).update()
+ else:
+ logger.error(f"不支持的命令:{command}")
+ break
+ except Exception as e:
+ print(str(e))
+ state = False
+
+ if state:
+ log_text += " success\n"
+ logger.info(log_text)
+ else:
+ log_text += " fail\n"
+ logger.error(log_text)
+
+ # 读取历史记录
+ history = self.get_data('history') or []
+
+ history.append({
+ "name": env.split('=')[1],
+ "command": command,
+ "result": 'success' if state else 'fail',
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+
+ thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
+ history = [record for record in history if
+ datetime.strptime(record["time"],
+ '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
+ # 保存历史
+ self.save_data(key="history", value=history)
+
+ container_icon = container.attrs['Config']['Labels']['net.unraid.docker.icon']
+
+ if self._notify and self._msgtype:
+ # 发送通知
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+
+ self.post_message(title="docker任务通知",
+ mtype=mtype,
+ text=log_text,
+ image=container_icon if len(container_names) == 1 and container_icon and str(
+ container_icon).startswith("http") else None)
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "notify": self._notify,
+ "msgtype": self._msgtype,
+ "time_confs": self._time_confs,
+ "history_days": self._history_days,
+ "clear": self._clear
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear',
+ 'label': '清除历史记录',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'history_days',
+ 'label': '保留历史天数'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'time_confs',
+ 'label': '执行命令',
+ 'rows': 2,
+ 'placeholder': '容器名#cron表达式#restart/start/stop/unpause/update'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '容器名(多个容器名,拼接)#cron表达式#restart/start/stop/pause/unpause/update'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "clear": False,
+ "time_confs": "",
+ "history_days": 30,
+ "msgtype": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ # 查询同步详情
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ if not isinstance(historys, list):
+ historys = [historys]
+
+ historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True)
+
+ msgs = [
+ {
+ 'component': 'tr',
+ 'props': {
+ 'class': 'text-sm'
+ },
+ 'content': [
+ {
+ 'component': 'td',
+ 'text': history.get("time")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("name")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("command")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("result")
+ }
+ ]
+ } for history in historys
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTable',
+ 'props': {
+ 'hover': True
+ },
+ 'content': [
+ {
+ 'component': 'thead',
+ 'content': [
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '执行时间'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '容器名称'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '命令'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '执行结果'
+ },
+ ]
+ },
+ {
+ 'component': 'tbody',
+ 'content': msgs
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/downloadtorrent/__init__.py b/plugins/downloadtorrent/__init__.py
new file mode 100644
index 0000000..1056aad
--- /dev/null
+++ b/plugins/downloadtorrent/__init__.py
@@ -0,0 +1,227 @@
+from app.db.site_oper import SiteOper
+from app.modules.qbittorrent import Qbittorrent
+from app.modules.transmission import Transmission
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.utils.string import StringUtils
+
+
+class DownloadTorrent(_PluginBase):
+ # 插件名称
+ plugin_name = "添加种子下载"
+ # 插件描述
+ plugin_desc = "选择下载器,添加种子任务。"
+ # 插件图标
+ plugin_icon = "download.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "downloadtorrent_"
+ # 加载顺序
+ plugin_order = 28
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _downloader = None
+ _is_paused = False
+ _save_path = None
+ _torrent_urls = None
+ qb = None
+ tr = None
+ site = None
+
+ def init_plugin(self, config: dict = None):
+ self.qb = Qbittorrent()
+ self.tr = Transmission()
+ self.site = SiteOper()
+
+ if config:
+ self._downloader = config.get("downloader")
+ self._is_paused = config.get("is_paused")
+ self._save_path = config.get("save_path")
+ self._torrent_urls = config.get("torrent_urls")
+
+ # 下载种子
+ if self._torrent_urls:
+ for torrent_url in str(self._torrent_urls).split("\n"):
+ # 获取种子对应站点cookie
+ domain = StringUtils.get_url_domain(torrent_url)
+ if not domain:
+ logger.error(f"种子 {torrent_url} 获取站点域名失败,跳过处理")
+ continue
+
+ # 查询站点
+ site = self.site.get_by_domain(domain)
+ if not site or not site.cookie:
+ logger.error(f"种子 {torrent_url} 获取站点cookie失败,跳过处理")
+ continue
+
+ # 添加下载
+ if str(self._downloader) == "qb":
+ torrent = self.qb.add_torrent(content=torrent_url,
+ is_paused=self._is_paused,
+ download_dir=self._save_path,
+ cookie=site.cookie)
+ else:
+ torrent = self.tr.add_torrent(content=torrent_url,
+ is_paused=self._is_paused,
+ download_dir=self._save_path,
+ cookie=site.cookie)
+
+ if torrent:
+ logger.info(f"种子添加下载成功 {torrent_url} 保存位置 {self._save_path}")
+ else:
+ logger.error(f"种子添加下载失败 {torrent_url} 保存位置 {self._save_path}")
+
+ self.update_config({
+ "downloader": self._downloader,
+ "save_path": self._save_path,
+ "is_paused": self._is_paused
+ })
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'downloader',
+ 'label': '下载器',
+ 'items': [
+ {'title': 'qb', 'value': 'qb'},
+ {'title': 'tr', 'value': 'tr'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'is_paused',
+ 'label': '暂停种子',
+ 'items': [
+ {'title': '开启', 'value': True},
+ {'title': '不开启', 'value': False}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'save_path',
+ 'label': '保存路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'torrent_urls',
+ 'rows': '3',
+ 'label': '种子链接',
+ 'placeholder': '种子链接,一行一个'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '保存路径为下载器保存路径,种子链接一行一个。'
+ '添加的种子链接需站点已在站点管理维护或公共站点。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "downloader": "qb",
+ "is_paused": False,
+ "save_path": "",
+ "torrent_urls": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/embymetarefresh/__init__.py b/plugins/embymetarefresh/__init__.py
new file mode 100644
index 0000000..627a330
--- /dev/null
+++ b/plugins/embymetarefresh/__init__.py
@@ -0,0 +1,416 @@
+from datetime import datetime, timedelta
+from typing import Optional, Any, List, Dict, Tuple
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.core.event import eventmanager, Event
+from app.db.transferhistory_oper import TransferHistoryOper
+from app.core.config import settings
+from app.log import logger
+from app.plugins import _PluginBase
+from app.modules.emby import Emby
+from app.schemas.types import EventType
+from app.utils.http import RequestUtils
+
+
+class EmbyMetaRefresh(_PluginBase):
+ # 插件名称
+ plugin_name = "Emby元数据刷新"
+ # 插件描述
+ plugin_desc = "定时刷新Emby媒体库元数据。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png"
+ # 插件版本
+ plugin_version = "1.1"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "embymetarefresh_"
+ # 加载顺序
+ plugin_order = 15
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _onlyonce = False
+ _cron = None
+ _days = None
+ _EMBY_HOST = settings.EMBY_HOST
+ _EMBY_APIKEY = settings.EMBY_API_KEY
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._days = config.get("days") or 5
+
+ if self._EMBY_HOST:
+ if not self._EMBY_HOST.endswith("/"):
+ self._EMBY_HOST += "/"
+ if not self._EMBY_HOST.startswith("http"):
+ self._EMBY_HOST = "http://" + self._EMBY_HOST
+
+ # 加载模块
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"媒体库元数据刷新服务启动,立即运行一次")
+ self._scheduler.add_job(self.refresh, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="媒体库元数据")
+
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.refresh,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="媒体库元数据")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{str(err)}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ def __update_config(self):
+ self.update_config(
+ {
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "enabled": self._enabled,
+ "days": self._days
+ }
+ )
+
+ def refresh(self):
+ """
+ 刷新媒体库元数据
+ """
+ if "emby" not in settings.MEDIASERVER:
+ logger.error("未配置Emby媒体服务器")
+ return
+
+ # 获取days内入库的媒体
+ current_date = datetime.now()
+ # 计算几天前的日期
+ target_date = current_date - timedelta(days=int(self._days))
+ transferhistorys = TransferHistoryOper().list_by_date(target_date.strftime('%Y-%m-%d'))
+ if not transferhistorys:
+ logger.error(f"{self._days}天内没有媒体库入库记录")
+ return
+
+ logger.info(f"开始刷新媒体库元数据,最近{self._days}天内入库媒体:{len(transferhistorys)}个")
+ # 刷新媒体库
+ for transferinfo in transferhistorys:
+ self.__refresh_emby(transferinfo)
+ logger.info(f"刷新媒体库元数据完成")
+
+ @eventmanager.register(EventType.PluginAction)
+ def remote_sync(self, event: Event):
+ """
+ 远程刷新媒体库
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "emby_meta_refresh":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始刷新Emby元数据 ...",
+ userid=event.event_data.get("user"))
+ self.refresh()
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="刷新Emby元数据完成!", userid=event.event_data.get("user"))
+
+ def __refresh_emby(self, transferinfo):
+ """
+ 刷新emby
+ """
+ if transferinfo.type == "电影":
+ movies = Emby().get_movies(title=transferinfo.title, year=transferinfo.year)
+ if not movies:
+ logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})")
+ return
+ for movie in movies:
+ self.__refresh_emby_library_by_id(item_id=movie.item_id)
+ logger.info(f"已通知刷新Emby电影:{movie.title} ({movie.year}) item_id:{movie.item_id}")
+ else:
+ item_id = self.__get_emby_series_id_by_name(name=transferinfo.title, year=transferinfo.year)
+ if not item_id or item_id is None:
+ logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})")
+ return
+
+ # 验证tmdbid是否相同
+ item_info = Emby().get_iteminfo(item_id)
+ if item_info:
+ if transferinfo.tmdbid and item_info.tmdbid:
+ if str(transferinfo.tmdbid) != str(item_info.tmdbid):
+ logger.error(f"Emby中{transferinfo.title} ({transferinfo.year})的tmdbId与入库记录不一致")
+ return
+
+ # 查询集的item_id
+ season = int(transferinfo.seasons.replace("S", ""))
+ episode = int(transferinfo.episodes.replace("E", ""))
+ episode_item_id = self.__get_emby_episode_item_id(item_id=item_id, season=season, episode=episode)
+ if not episode_item_id or episode_item_id is None:
+ logger.error(
+ f"Emby中没有找到{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes}")
+ return
+
+ self.__refresh_emby_library_by_id(item_id=episode_item_id)
+ logger.info(
+ f"已通知刷新Emby电视剧:{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes} item_id:{episode_item_id}")
+
+ def __get_emby_episode_item_id(self, item_id: str, season: int, episode: int) -> Optional[str]:
+ """
+ 根据剧集信息查询Emby中集的item_id
+ """
+ if not self._EMBY_HOST or not self._EMBY_APIKEY:
+ return None
+ req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
+ self._EMBY_HOST, item_id, season, self._EMBY_APIKEY)
+ try:
+ with RequestUtils().get_res(req_url) as res_json:
+ if res_json:
+ tv_item = res_json.json()
+ res_items = tv_item.get("Items")
+ for res_item in res_items:
+ season_index = res_item.get("ParentIndexNumber")
+ if not season_index:
+ continue
+ if season and season != season_index:
+ continue
+ episode_index = res_item.get("IndexNumber")
+ if not episode_index:
+ continue
+ if episode and episode != episode_index:
+ continue
+ episode_item_id = res_item.get("Id")
+ return episode_item_id
+ except Exception as e:
+ logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
+ return None
+ return None
+
+ def __refresh_emby_library_by_id(self, item_id: str) -> bool:
+ """
+ 通知Emby刷新一个项目的媒体库
+ """
+ if not self._EMBY_HOST or not self._EMBY_APIKEY:
+ return False
+ req_url = "%semby/Items/%s/Refresh?MetadataRefreshMode=FullRefresh" \
+ "&ImageRefreshMode=FullRefresh&ReplaceAllMetadata=true&ReplaceAllImages=true&api_key=%s" % (
+ self._EMBY_HOST, item_id, self._EMBY_APIKEY)
+ try:
+ with RequestUtils().post_res(req_url) as res:
+ if res:
+ return True
+ else:
+ logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
+ except Exception as e:
+ logger.error(f"连接Items/Id/Refresh出错:" + str(e))
+ return False
+ return False
+
+ def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]:
+ """
+ 根据名称查询Emby中剧集的SeriesId
+ :param name: 标题
+ :param year: 年份
+ :return: None 表示连不通,""表示未找到,找到返回ID
+ """
+ if not self._EMBY_HOST or not self._EMBY_APIKEY:
+ return None
+ req_url = ("%semby/Items?"
+ "IncludeItemTypes=Series"
+ "&Fields=ProductionYear"
+ "&StartIndex=0"
+ "&Recursive=true"
+ "&SearchTerm=%s"
+ "&Limit=10"
+ "&IncludeSearchTypes=false"
+ "&api_key=%s") % (
+ self._EMBY_HOST, name, self._EMBY_APIKEY)
+ try:
+ with RequestUtils().get_res(req_url) as res:
+ if res:
+ res_items = res.json().get("Items")
+ if res_items:
+ for res_item in res_items:
+ if res_item.get('Name') == name and (
+ not year or str(res_item.get('ProductionYear')) == str(year)):
+ return res_item.get('Id')
+ except Exception as e:
+ logger.error(f"连接Items出错:" + str(e))
+ return None
+ return ""
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ return [{
+ "cmd": "/emby_meta_refresh",
+ "event": EventType.PluginAction,
+ "desc": "Emby媒体库刷新",
+ "category": "",
+ "data": {
+ "action": "emby_meta_refresh"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ "component": "VForm",
+ "content": [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ "component": "VRow",
+ "content": [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'days',
+ 'label': '最新入库天数'
+ }
+ }
+ ]
+ }
+ ],
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '查询入库记录,周期请求媒体服务器元数据刷新接口。注:只支持Emby。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ "days": 5
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/embymetatag/__init__.py b/plugins/embymetatag/__init__.py
new file mode 100644
index 0000000..ff5d36e
--- /dev/null
+++ b/plugins/embymetatag/__init__.py
@@ -0,0 +1,462 @@
+import json
+from datetime import datetime, timedelta
+from typing import Optional, Any, List, Dict, Tuple
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+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.modules.emby import Emby
+from app.schemas.types import EventType
+from app.utils.http import RequestUtils
+
+
+class EmbyMetaTag(_PluginBase):
+ # 插件名称
+ plugin_name = "Emby媒体标签"
+ # 插件描述
+ plugin_desc = "自动给媒体库媒体添加标签。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/tag.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "embymetatag_"
+ # 加载顺序
+ plugin_order = 16
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _onlyonce = False
+ _cron = None
+ _tag_confs = None
+ _name_tag_confs = None
+ _EMBY_HOST = settings.EMBY_HOST
+ _EMBY_APIKEY = settings.EMBY_API_KEY
+ _EMBY_USER = Emby().get_user()
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ _tags = {}
+ _media_tags = {}
+ _media_type = {}
+
+ def init_plugin(self, config: dict = None):
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._tag_confs = config.get("tag_confs")
+ self._name_tag_confs = config.get("name_tag_confs")
+
+ if self._EMBY_HOST:
+ if not self._EMBY_HOST.endswith("/"):
+ self._EMBY_HOST += "/"
+ if not self._EMBY_HOST.startswith("http"):
+ self._EMBY_HOST = "http://" + self._EMBY_HOST
+
+ _tags = {}
+ if self._tag_confs:
+ tag_confs = self._tag_confs.split("\n")
+ for tag_conf in tag_confs:
+ if tag_conf:
+ tag_conf = tag_conf.split("#")
+ if len(tag_conf) == 2:
+ librarys = tag_conf[0].split(',')
+ for library in librarys:
+ library_tags = self._tags.get(library) or []
+ self._tags[library] = library_tags + tag_conf[1].split(',')
+
+ _media_tags = {}
+ _media_type = {}
+ if self._name_tag_confs:
+ name_tag_confs = self._name_tag_confs.split("\n")
+ for name_tag_conf in name_tag_confs:
+ if name_tag_conf:
+ name_tag_conf = name_tag_conf.split("#")
+ if len(name_tag_conf) == 3:
+ media_names = name_tag_conf[0].split(',')
+ for media_name in media_names:
+ self._media_type[media_name] = name_tag_conf[1].split(',')
+ media_tags = self._media_tags.get(media_name) or []
+ self._media_tags[media_name] = media_tags + name_tag_conf[2].split(',')
+
+ # 加载模块
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"Emby媒体标签服务启动,立即运行一次")
+ self._scheduler.add_job(self.auto_tag, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="Emby媒体标签")
+
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.auto_tag,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="Emby媒体标签")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{str(err)}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ def __update_config(self):
+ self.update_config(
+ {
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "enabled": self._enabled,
+ "tag_confs": self._tag_confs,
+ "name_tag_confs": self._name_tag_confs,
+ }
+ )
+
+ def auto_tag(self):
+ """
+ 给设定媒体库打标签
+ """
+ if "emby" not in settings.MEDIASERVER:
+ logger.error("未配置Emby媒体服务器")
+ return
+
+ if (not self._tags or len(self._tags.keys()) == 0) and (
+ not self._media_tags or len(self._media_tags.keys()) == 0):
+ logger.error("未配置Emby媒体标签")
+ return
+
+ # 媒体库标签
+ if self._tags and len(self._tags.keys()) > 0:
+ # 获取emby 媒体库
+ librarys = Emby().get_librarys()
+ if not librarys:
+ logger.error("获取媒体库失败")
+ return
+
+ # 遍历媒体库,获取媒体库媒体
+ for library in librarys:
+ # 获取媒体库标签
+ library_tags = self._tags.get(library.name)
+ if not library_tags:
+ continue
+
+ # 获取媒体库媒体
+ library_items = Emby().get_items(library.id)
+ if not library_items:
+ continue
+
+ for library_item in library_items:
+ if not library_item:
+ continue
+ # 获取item的tag
+ item_tags = self.__get_item_tags(library_item.item_id) or []
+
+ # 获取缺少的tag
+ add_tags = []
+ for library_tag in library_tags:
+ if not item_tags or library_tag not in item_tags:
+ add_tags.append(library_tag)
+
+ # 添加标签
+ if add_tags:
+ tags = [{"Name": str(add_tag)} for add_tag in add_tags]
+ tags = {"Tags": tags}
+ add_flag = self.__add_tag(library_item.item_id, tags)
+ logger.info(f"{library.name} 添加标签成功:{library_item.title} {tags} {add_flag}")
+
+ # 特殊媒体名标签
+ if self._media_tags and len(self._media_tags.keys()) > 0:
+ for media_name, media_tags in self._media_tags.items():
+
+ match_medias = []
+ # 根据Series/Movie搜索媒体
+ for media_type in self._media_type.get(media_name):
+ match_medias += self.__get_medias_by_name(media_name, media_type)
+
+ # 遍历媒体 补充缺失tag
+ for media in match_medias:
+ if not media:
+ continue
+
+ # 获取item的tag
+ item_tags = self.__get_item_tags(media.get("Id")) or []
+
+ # 获取缺少的tag
+ add_tags = []
+ for media_tag in media_tags:
+ if not item_tags or media_tag not in item_tags:
+ add_tags.append(media_tag)
+
+ # 添加标签
+ if add_tags:
+ tags = [{"Name": str(add_tag)} for add_tag in add_tags]
+ tags = {"Tags": tags}
+ add_flag = self.__add_tag(media.get("Id"), tags)
+ logger.info(f"特殊媒体添加标签成功:{media.get('Name')} {tags} {add_flag}")
+
+ logger.info("Emby媒体标签任务完成")
+
+ @eventmanager.register(EventType.PluginAction)
+ def remote_sync(self, event: Event):
+ """
+ 远程添加媒体标签
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "emby_meta_tag":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始添加媒体标签 ...",
+ userid=event.event_data.get("user"))
+ self.auto_tag()
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="添加媒体标签完成!", userid=event.event_data.get("user"))
+
+ def __add_tag(self, itemid: str, tags: dict):
+ req_url = "%semby/Items/%s/Tags/Add?api_key=%s" % (self._EMBY_HOST, itemid, self._EMBY_APIKEY)
+ try:
+ with RequestUtils(content_type="application/json").post_res(url=req_url, json=tags) as res:
+ if res and res.status_code == 204:
+ return True
+ except Exception as e:
+ logger.error(f"连接Items/Id/Tags/Add出错:" + str(e))
+ return False
+
+ def __get_item_tags(self, itemid: str):
+ """
+ 获取单个项目详情
+ """
+ if not itemid:
+ return None
+ if not self._EMBY_HOST or not self._EMBY_APIKEY:
+ return None
+ req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._EMBY_HOST, self._EMBY_USER, itemid, self._EMBY_APIKEY)
+ try:
+ with RequestUtils().get_res(req_url) as res:
+ if res and res.status_code == 200:
+ item = res.json()
+ return [tag.get('Name') for tag in item.get("TagItems")]
+ except Exception as e:
+ logger.error(f"连接Items/Id出错:" + str(e))
+ return []
+
+ def __get_medias_by_name(self, media_name: str, media_type: str):
+ """
+ 搜索媒体名
+ """
+ if not media_name:
+ return None
+ if not self._EMBY_HOST or not self._EMBY_APIKEY:
+ return None
+ req_url = ("%semby/Users/%s/Items?IncludeItemTypes=%s&Recursive=true&SearchTerm=%s&api_key=%s") % (
+ self._EMBY_HOST, self._EMBY_USER, media_type, media_name, self._EMBY_APIKEY)
+ try:
+ with RequestUtils().get_res(req_url) as res:
+ if res and res.status_code == 200:
+ item = res.json()
+ return item.get("Items")
+ except Exception as e:
+ logger.error(f"连接Items/Id出错:" + str(e))
+ return []
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ return [{
+ "cmd": "/emby_meta_tag",
+ "event": EventType.PluginAction,
+ "desc": "Emby媒体标签",
+ "category": "",
+ "data": {
+ "action": "emby_meta_tag"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ "component": "VForm",
+ "content": [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ "component": "VRow",
+ "content": [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ }
+ ],
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'tag_confs',
+ 'label': '媒体库标签配置',
+ 'rows': 3,
+ 'placeholder': '媒体库名,媒体库名#标签名,标签名'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'name_tag_confs',
+ 'label': '媒体名标签配置',
+ 'rows': 3,
+ 'placeholder': '媒体名称,媒体名称#Series,Movie#标签名,标签名'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '定时刷新Emby媒体库媒体,添加媒体库、媒体名(模糊匹配)自定义标签。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ "tag_confs": "",
+ "name_tag_confs": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/embyreporter/__init__.py b/plugins/embyreporter/__init__.py
new file mode 100644
index 0000000..1a7dec0
--- /dev/null
+++ b/plugins/embyreporter/__init__.py
@@ -0,0 +1,785 @@
+import os
+
+from app.core.config import settings
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.schemas import NotificationType
+from pathlib import Path
+
+import random
+from io import BytesIO
+from PIL import Image
+from PIL import ImageFont
+from PIL import ImageDraw
+import pytz
+from cacheout import Cache
+from datetime import datetime, timedelta
+
+from app.utils.http import RequestUtils
+from app.utils.string import StringUtils
+
+cache = Cache()
+
+
+class EmbyReporter(_PluginBase):
+ # 插件名称
+ plugin_name = "Emby观影报告"
+ # 插件描述
+ plugin_desc = "推送Emby观影报告,需Emby安装Playback Report 插件。"
+ # 插件图标
+ plugin_icon = "Pydiocells_A.png"
+ # 插件版本
+ plugin_version = "1.5"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "embyreporter_"
+ # 加载顺序
+ plugin_order = 30
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _res_dir = None
+ _cron = None
+ _days = None
+ _type = None
+ _cnt = None
+ _mp_host = None
+ _emby_host = None
+ _emby_api_key = None
+ _text_url = None
+ show_time = True
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ PLAYBACK_REPORTING_TYPE_MOVIE = "ItemName"
+ PLAYBACK_REPORTING_TYPE_TVSHOWS = "substr(ItemName,0, instr(ItemName, ' - '))"
+ host = None
+ api_key = None
+
+ def init_plugin(self, config: dict = None):
+ self.host = f"http://{settings.EMBY_HOST}" if not str(settings.EMBY_HOST).startswith(
+ "http") else settings.EMBY_HOST
+ self.api_key = settings.EMBY_API_KEY
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._res_dir = config.get("res_dir")
+ self._days = config.get("days") or 7
+ self._cnt = config.get("cnt") or 10
+ self._type = config.get("type") or "tg"
+ self._mp_host = config.get("mp_host")
+ self.show_time = config.get("show_time")
+ self._text_url = config.get("text_url")
+ self._emby_host = config.get("emby_host")
+ self._emby_api_key = config.get("emby_api_key")
+ if self._emby_host and self._emby_api_key:
+ self.host = f"http://{self._emby_host}" if not str(self._emby_host).startswith(
+ "http") else self._emby_host
+ self.api_key = self._emby_api_key
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"Emby观影报告服务启动,立即运行一次")
+ self._scheduler.add_job(self.__report, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="Emby观影报告")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.__report,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="Emby观影报告")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __report(self):
+ """
+ 发送Emby观影报告
+ """
+ # 本地路径转为url
+ if not self._mp_host:
+ return
+
+ if not self._type:
+ return
+
+ # 获取数据
+ success, movies = self.get_report(types=self.PLAYBACK_REPORTING_TYPE_MOVIE, days=int(self._days),
+ limit=int(self._cnt))
+ if not success:
+ exit(movies)
+ logger.info(f"获取到电影 {movies}")
+ success, tvshows = self.get_report(types=self.PLAYBACK_REPORTING_TYPE_TVSHOWS, days=int(self._days),
+ limit=int(self._cnt))
+ if not success:
+ exit(tvshows)
+ logger.info(f"获取到电视剧 {tvshows}")
+
+ # 绘制海报
+ report_path = self.draw(res_path=self._res_dir,
+ movies=movies,
+ tvshows=tvshows,
+ show_time=self.show_time)
+
+ if not report_path:
+ logger.error("生成海报失败")
+ return
+
+ # 发送海报
+ report_title = f"🌟*过去{self._days}日观影排行*"
+
+ report_url = self._mp_host + report_path.replace("/public", "")
+ mtype = NotificationType.MediaServer
+ if self._type:
+ mtype = NotificationType.__getitem__(str(self._type)) or NotificationType.MediaServer
+
+ # 每日一言
+ report_text = None
+ if self._text_url:
+ try:
+ resp = RequestUtils().get_res(url=self._text_url)
+ if resp.status_code == 200:
+ report_text = resp.text
+
+ if report_text:
+ report_text = str(report_text).replace("
", "").replace("
", "")
+ except Exception as e:
+ print(e)
+ self.post_message(title=report_title,
+ mtype=mtype,
+ text=report_text,
+ image=report_url)
+ logger.info(f"Emby观影记录推送成功 {report_url}")
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "days": self._days,
+ "cnt": self._cnt,
+ "type": self._type,
+ "mp_host": self._mp_host,
+ "text_url": self._text_url,
+ "show_time": self.show_time,
+ "emby_host": self._emby_host,
+ "emby_api_key": self._emby_api_key,
+ "res_dir": self._res_dir
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ # 编历 NotificationType 枚举,生成消息类型选项
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'res_dir',
+ 'label': '素材路径',
+ 'placeholder': '本地素材路径'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'days',
+ 'label': '报告天数',
+ 'placeholder': '向前获取数据的天数'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cnt',
+ 'label': '观影记录数量',
+ 'placeholder': '获取观影数据数量,默认10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'mp_host',
+ 'label': 'MoviePilot域名',
+ 'placeholder': '必填,末尾不带/'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'type',
+ 'label': '推送方式',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'show_time',
+ 'label': '是否显示观看时长',
+ 'items': [
+ {'title': '是', 'value': True},
+ {'title': '否', 'value': False}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'text_url',
+ 'label': '每日一言api',
+ 'placeholder': '空则不发送'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'emby_host',
+ 'label': '自定义emby host',
+ 'placeholder': 'IP:PORT'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'emby_api_key',
+ 'label': '自定义emby apiKey'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '如生成观影报告有空白记录,可酌情调大观影记录数量。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '如未设置自定义emby配置,则读取环境变量emby配置。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ "res_dir": "",
+ "days": 7,
+ "cnt": 10,
+ "emby_host": "",
+ "emby_api_key": "",
+ "mp_host": "",
+ "show_time": True,
+ "text_url": "",
+ "type": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
+
+ def draw(self, res_path, movies, tvshows, show_time=True):
+ # 默认路径 默认图
+ if not res_path:
+ res_path = os.path.join(Path(__file__).parent, "res")
+ # 绘图文件路径初始化
+ bg_path = os.path.join(res_path, "bg")
+ mask_path = os.path.join(res_path, "cover-ranks-mask-2.png")
+ font_path = os.path.join(res_path, "PingFang Bold.ttf")
+ # 随机调取背景, 路径: res/ranks/bg/...
+ bg_list = os.listdir(bg_path)
+ bg_path = os.path.join(bg_path, bg_list[random.randint(0, len(bg_list) - 1)])
+ # 初始绘图对象
+ bg = Image.open(bg_path)
+ mask = Image.open(mask_path)
+ bg.paste(mask, (0, 0), mask)
+ font = ImageFont.truetype(font_path, 18)
+ font_small = ImageFont.truetype(font_path, 14)
+ font_count = ImageFont.truetype(font_path, 8)
+
+ exists_movies = []
+ for i in movies:
+ try:
+ # 榜单项数据
+ user_id, item_id, item_type, name, count, duration = tuple(i)
+ print(item_type, item_id, name, count, StringUtils.str_secends(int(duration)))
+ # 封面图像获取
+ success, data = self.primary(item_id)
+ if not success:
+ continue
+ exists_movies.append(i)
+ except Exception:
+ continue
+
+ logger.info(f"过滤后未删除电影 {len(exists_movies)} 部")
+ # 合并绘制
+ if len(exists_movies) < 5:
+ for i in range(5 - len(exists_movies) + 1):
+ exists_movies.append({"item_id": i})
+ if len(exists_movies) > 5:
+ exists_movies = exists_movies[:5]
+
+ exists_tvs = []
+ for i in tvshows:
+ try:
+ # 榜单项数据
+ user_id, item_id, item_type, name, count, duration = tuple(i)
+ print(item_type, item_id, name, count, StringUtils.str_secends(int(duration)))
+ # 图片获取,剧集主封面获取
+ # 获取剧ID
+ success, data = self.items(user_id, item_id)
+ if not success:
+ continue
+ item_id = data["SeriesId"]
+ # 封面图像获取
+ success, data = self.primary(item_id)
+ if not success:
+ continue
+ exists_tvs.append(i)
+ except Exception as e:
+ print(str(e))
+ continue
+ logger.info(f"过滤后未删除电视剧 {len(exists_tvs)} 部")
+ if len(exists_tvs) > 5:
+ exists_tvs = exists_tvs[:5]
+
+ all_ranks = exists_movies + exists_tvs
+ index, offset_y = (-1, 0)
+ for i in all_ranks:
+ index += 1
+ try:
+ # 榜单项数据
+ user_id, item_id, item_type, name, count, duration = tuple(i)
+ # 图片获取,剧集主封面获取
+ if item_type != "Movie":
+ # 获取剧ID
+ success, data = self.items(user_id, item_id)
+ if not success:
+ index -= 1
+ continue
+ item_id = data["SeriesId"]
+ # 封面图像获取
+ success, data = self.primary(item_id)
+ if not success:
+ if item_type != "Movie":
+ index -= 1
+ continue
+ # 剧集Y偏移
+ if index >= 5:
+ index = 0
+ offset_y = 331
+ # 名称显示偏移
+ font_offset_y = 0
+ temp_font = font
+ # 名称超出长度缩小省略
+ if font.getlength(name) > 110:
+ temp_font = font_small
+ font_offset_y = 4
+ for i in range(len(name)):
+ name = name[:len(name) - 1]
+ if font.getlength(name) <= 110:
+ break
+ name += ".."
+ # 绘制封面
+ cover = Image.open(BytesIO(data))
+ cover = cover.resize((108, 159))
+ bg.paste(cover, (73 + 145 * index, 379 + offset_y))
+ # 绘制 播放次数、影片名称
+ text = ImageDraw.Draw(bg)
+ if show_time:
+ self.draw_text_psd_style(text,
+ (177 + 145 * index - font_count.getlength(
+ StringUtils.str_secends(int(duration))),
+ 355 + offset_y),
+ StringUtils.str_secends(int(duration)), font_count, 126)
+ self.draw_text_psd_style(text, (74 + 145 * index, 542 + font_offset_y + offset_y), name, temp_font, 126)
+ except Exception:
+ continue
+
+ if index > 0:
+ save_path = "/public/report.jpg"
+ if Path(save_path).exists():
+ Path.unlink(Path(save_path))
+ bg.save(save_path)
+ return save_path
+ return None
+
+ @staticmethod
+ def draw_text_psd_style(draw, xy, text, font, tracking=0, leading=None, **kwargs):
+ """
+ usage: draw_text_psd_style(draw, (0, 0), "Test",
+ tracking=-0.1, leading=32, fill="Blue")
+
+ Leading is measured from the baseline of one line of text to the
+ baseline of the line above it. Baseline is the invisible line on which most
+ letters—that is, those without descenders—sit. The default auto-leading
+ option sets the leading at 120% of the type size (for example, 12‑point
+ leading for 10‑point type).
+
+ Tracking is measured in 1/1000 em, a unit of measure that is relative to
+ the current type size. In a 6 point font, 1 em equals 6 points;
+ in a 10 point font, 1 em equals 10 points. Tracking
+ is strictly proportional to the current type size.
+ """
+
+ def stutter_chunk(lst, size, overlap=0, default=None):
+ for i in range(0, len(lst), size - overlap):
+ r = list(lst[i:i + size])
+ while len(r) < size:
+ r.append(default)
+ yield r
+
+ x, y = xy
+ font_size = font.size
+ lines = text.splitlines()
+ if leading is None:
+ leading = font.size * 1.2
+ for line in lines:
+ for a, b in stutter_chunk(line, 2, 1, ' '):
+ w = font.getlength(a + b) - font.getlength(b)
+ draw.text((x, y), a, font=font, **kwargs)
+ x += w + (tracking / 1000) * font_size
+ y += leading
+ x = xy[0]
+
+ @cache.memoize(ttl=600)
+ def primary(self, item_id, width=720, height=1440, quality=90, ret_url=False):
+ try:
+ url = self.host + f"/emby/Items/{item_id}/Images/Primary?maxHeight={height}&maxWidth={width}&quality={quality}"
+ if ret_url:
+ return url
+ resp = RequestUtils().get_res(url=url)
+
+ if resp.status_code != 204 and resp.status_code != 200:
+ return False, "🤕Emby 服务器连接失败!"
+ return True, resp.content
+ except Exception:
+ return False, "🤕Emby 服务器连接失败!"
+
+ @cache.memoize(ttl=600)
+ def backdrop(self, item_id, width=1920, quality=70, ret_url=False):
+ try:
+ url = self.host + f"/emby/Items/{item_id}/Images/Backdrop/0?&maxWidth={width}&quality={quality}"
+ if ret_url:
+ return url
+ resp = RequestUtils().get_res(url=url)
+
+ if resp.status_code != 204 and resp.status_code != 200:
+ return False, "🤕Emby 服务器连接失败!"
+ return True, resp.content
+ except Exception:
+ return False, "🤕Emby 服务器连接失败!"
+
+ @cache.memoize(ttl=600)
+ def logo(self, item_id, quality=70, ret_url=False):
+ url = self.host + f"/emby/Items/{item_id}/Images/Logo?quality={quality}"
+ if ret_url:
+ return url
+ resp = RequestUtils().get_res(url=url)
+
+ if resp.status_code != 204 and resp.status_code != 200:
+ return False, "🤕Emby 服务器连接失败!"
+ return True, resp.content
+
+ @cache.memoize(ttl=300)
+ def items(self, user_id, item_id):
+ try:
+ url = f"{self.host}/emby/Users/{user_id}/Items/{item_id}?api_key={self.api_key}"
+ resp = RequestUtils().get_res(url=url)
+
+ if resp.status_code != 204 and resp.status_code != 200:
+ return False, "🤕Emby 服务器连接失败!"
+ return True, resp.json()
+ except Exception:
+ return False, "🤕Emby 服务器连接失败!"
+
+ def get_report(self, days, types=None, user_id=None, end_date=datetime.now(pytz.timezone("Asia/Shanghai")),
+ limit=10):
+ if not types:
+ types = self.PLAYBACK_REPORTING_TYPE_MOVIE
+ sub_date = end_date - timedelta(days=int(days))
+ start_time = sub_date.strftime("%Y-%m-%d 00:00:00")
+ end_time = end_date.strftime("%Y-%m-%d 23:59:59")
+ sql = "SELECT UserId, ItemId, ItemType, "
+ sql += types + " AS name, "
+ sql += "COUNT(1) AS play_count, "
+ sql += "SUM(PlayDuration - PauseDuration) AS total_duration "
+ sql += "FROM PlaybackActivity "
+ sql += f"WHERE ItemType = '{'Movie' if types == self.PLAYBACK_REPORTING_TYPE_MOVIE else 'Episode'}' "
+ sql += f"AND DateCreated >= '{start_time}' AND DateCreated <= '{end_time}' "
+ sql += "AND UserId not IN (select UserId from UserList) "
+ if user_id:
+ sql += f"AND UserId = '{user_id}' "
+ sql += "GROUP BY name "
+ sql += "ORDER BY total_duration DESC "
+ sql += "LIMIT " + str(limit)
+
+ url = f"{self.host}/emby/user_usage_stats/submit_custom_query?api_key={self.api_key}"
+
+ data = {
+ "CustomQueryString": sql,
+ "ReplaceUserId": False
+ }
+ resp = RequestUtils().post_res(url=url, data=data)
+ if resp.status_code != 204 and resp.status_code != 200:
+ return False, "🤕Emby 服务器连接失败!"
+ ret = resp.json()
+ if len(ret["colums"]) == 0:
+ return False, ret["message"]
+ return True, ret["results"]
diff --git a/plugins/filesoftlink/__init__.py b/plugins/filesoftlink/__init__.py
new file mode 100644
index 0000000..e75a942
--- /dev/null
+++ b/plugins/filesoftlink/__init__.py
@@ -0,0 +1,647 @@
+import datetime
+import os
+import re
+import shutil
+import threading
+import traceback
+from pathlib import Path
+from typing import List, Tuple, Dict, Any, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+
+from app import schemas
+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, SystemConfigKey
+from app.utils.system import SystemUtils
+
+lock = threading.Lock()
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, monpath: str, sync: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = monpath
+ self.sync = sync
+
+ def on_created(self, event):
+ self.sync.event_handler(event=event, text="创建",
+ mon_path=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.sync.event_handler(event=event, text="移动",
+ mon_path=self._watch_path, event_path=event.dest_path)
+
+
+class FileSoftLink(_PluginBase):
+ # 插件名称
+ plugin_name = "实时软连接"
+ # 插件描述
+ plugin_desc = "监控目录文件变化,媒体文件软连接,其他文件可选复制。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png"
+ # 插件版本
+ plugin_version = "1.8"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "filesoftlink_"
+ # 加载顺序
+ plugin_order = 10
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _scheduler = None
+ _observer = []
+ _enabled = False
+ _onlyonce = False
+ _copy_files = False
+ _cron = None
+ _size = 0
+ # 模式 compatibility/fast
+ _mode = "compatibility"
+ _monitor_dirs = ""
+ _exclude_keywords = ""
+ # 存储源目录与目的目录关系
+ _dirconf: Dict[str, Optional[Path]] = {}
+ _medias = {}
+
+ _rmt_mediaext = ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+
+ # 退出事件
+ _event = threading.Event()
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._copy_files = config.get("copy_files")
+ self._mode = config.get("mode")
+ self._monitor_dirs = config.get("monitor_dirs") or ""
+ self._exclude_keywords = config.get("exclude_keywords") or ""
+ self._cron = config.get("cron")
+ self._size = config.get("size") or 0
+ self._rmt_mediaext = config.get(
+ "rmt_mediaext") or ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务管理器
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ monitor_dirs = self._monitor_dirs.split("\n")
+ if not monitor_dirs:
+ return
+ for mon_path in monitor_dirs:
+ # 格式源目录:目的目录
+ if not mon_path:
+ continue
+
+ # 存储目的目录
+ if SystemUtils.is_windows():
+ if mon_path.count(":") > 1:
+ paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1],
+ mon_path.split(":")[2] + ":" + mon_path.split(":")[3]]
+ else:
+ paths = [mon_path]
+ else:
+ paths = mon_path.split(":")
+
+ # 目的目录
+ target_path = None
+ if len(paths) > 1:
+ mon_path = paths[0]
+ target_path = Path(paths[1])
+ self._dirconf[mon_path] = target_path
+ else:
+ self._dirconf[mon_path] = None
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_path and target_path.is_relative_to(Path(mon_path)):
+ logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ # 异步开启云盘监控
+ logger.info(f"异步开启实时硬链接 {mon_path} {self._mode},延迟5s启动")
+ self._scheduler.add_job(func=self.start_monitor, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=5),
+ name=f"实时硬链接 {mon_path}",
+ kwargs={
+ "source_dir": mon_path
+ })
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("实时软连接服务启动,立即运行一次")
+ self._scheduler.add_job(name="实时软连接", func=self.sync_all, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
+ )
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动定时服务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def start_monitor(self, source_dir: str):
+ """
+ 异步开启实时软链接
+ """
+ try:
+ if str(self._mode) == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{source_dir} 的实时软链接服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}")
+ self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}")
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "copy_files": self._copy_files,
+ "mode": self._mode,
+ "monitor_dirs": self._monitor_dirs,
+ "exclude_keywords": self._exclude_keywords,
+ "cron": self._cron,
+ "size": self._size,
+ "rmt_mediaext": self._rmt_mediaext
+ })
+
+ @eventmanager.register(EventType.PluginAction)
+ def remote_sync(self, event: Event):
+ """
+ 远程全量同步
+ """
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "softlink_sync":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始同步监控目录 ...",
+ userid=event.event_data.get("user"))
+ self.sync_all()
+ if event:
+ self.post_message(channel=event.event_data.get("channel"),
+ title="监控目录同步完成!", userid=event.event_data.get("user"))
+
+ def sync_all(self):
+ """
+ 立即运行一次,全量同步目录中所有文件
+ """
+ logger.info("开始全量同步监控目录 ...")
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ # 遍历目录下所有文件
+ for root, dirs, files in os.walk(mon_path):
+ for name in dirs + files:
+ path = os.path.join(root, name)
+ if Path(path).is_file():
+ self.__handle_file(event_path=str(path), mon_path=mon_path)
+ logger.info("全量同步监控目录完成!")
+
+ def event_handler(self, event, mon_path: str, text: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param mon_path: 监控目录
+ :param text: 事件描述
+ :param event_path: 事件文件路径
+ """
+ if not event.is_directory:
+ # 文件发生变化
+ logger.debug("文件%s:%s" % (text, event_path))
+ self.__handle_file(event_path=event_path, mon_path=mon_path)
+
+ def __handle_file(self, event_path: str, mon_path: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param mon_path: 监控目录
+ """
+ file_path = Path(event_path)
+ try:
+ if not file_path.exists():
+ return
+ # 全程加锁
+ with lock:
+ # 回收站及隐藏的文件不处理
+ if event_path.find('/@Recycle/') != -1 \
+ or event_path.find('/#recycle/') != -1 \
+ or event_path.find('/.') != -1 \
+ or event_path.find('/@eaDir') != -1:
+ logger.debug(f"{event_path} 是回收站或隐藏的文件")
+ return
+
+ # 命中过滤关键字不处理
+ if self._exclude_keywords:
+ for keyword in self._exclude_keywords.split("\n"):
+ if keyword and re.findall(keyword, event_path):
+ logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
+ return
+
+ # 整理屏蔽词不处理
+ transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
+ if transfer_exclude_words:
+ for keyword in transfer_exclude_words:
+ if not keyword:
+ continue
+ if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
+ logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
+ return
+
+ # 判断是不是蓝光目录
+ if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
+ # 截取BDMV前面的路径
+ blurray_dir = event_path[:event_path.find("BDMV")]
+ file_path = Path(blurray_dir)
+ logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
+
+ # 判断文件大小
+ if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3:
+ logger.info(f"{file_path} 文件大小小于监控文件大小,不处理")
+ return
+
+ # 查询转移目的目录
+ target: Path = self._dirconf.get(mon_path)
+ target_file = str(file_path).replace(str(mon_path), str(target))
+
+ # 如果是文件夹
+ if Path(target_file).is_dir():
+ if not Path(target_file).exists():
+ logger.info(f"创建目标文件夹 {target_file}")
+ os.makedirs(target_file)
+ return
+ else:
+ # 文件
+ if Path(target_file).exists():
+ logger.info(f"目标文件 {target_file} 已存在")
+ return
+
+ if not Path(target_file).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(target_file).parent}")
+ os.makedirs(Path(target_file).parent)
+
+ # 媒体文件软连接
+ if Path(target_file).suffix.lower() in [ext.strip() for ext in
+ self._rmt_mediaext.split(",")]:
+ retcode, retmsg = SystemUtils.softlink(file_path, Path(target_file))
+ logger.info(f"创建媒体文件软连接 {str(file_path)} 到 {target_file} {retcode} {retmsg}")
+ else:
+ if self._copy_files:
+ # 其他nfo、jpg等复制文件
+ shutil.copy2(str(file_path), target_file)
+ logger.info(f"复制其他文件 {str(file_path)} 到 {target_file}")
+ except Exception as e:
+ logger.error("软连接发生错误:%s - %s" % (str(e), traceback.format_exc()))
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/softlink_sync",
+ "event": EventType.PluginAction,
+ "desc": "文件软连接同步",
+ "category": "",
+ "data": {
+ "action": "softlink_sync"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [{
+ "path": "/softlink_sync",
+ "endpoint": self.sync,
+ "methods": ["GET"],
+ "summary": "实时软连接同步",
+ "description": "实时软连接同步",
+ }]
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ if self._enabled and self._cron:
+ return [{
+ "id": "FileSoftLink",
+ "name": "实时软连接全量同步服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.sync_all,
+ "kwargs": {}
+ }]
+ return []
+
+ def sync(self) -> schemas.Response:
+ """
+ API调用目录同步
+ """
+ self.sync_all()
+ return schemas.Response(success=True)
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'copy_files',
+ 'label': '复制非媒体文件',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'mode',
+ 'label': '监控模式',
+ 'items': [
+ {'title': '兼容模式', 'value': 'compatibility'},
+ {'title': '性能模式', 'value': 'fast'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '定时全量同步周期',
+ 'placeholder': '5位cron表达式,留空关闭'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '监控文件大小(GB)',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_dirs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '监控目录:转移目的目录'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'exclude_keywords',
+ 'label': '排除关键词',
+ 'rows': 2,
+ 'placeholder': '每一行一个关键词'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'rmt_mediaext',
+ 'label': '视频格式',
+ 'rows': 2,
+ 'placeholder': ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "copy_files": True,
+ "mode": "compatibility",
+ "monitor_dirs": "",
+ "exclude_keywords": "",
+ "cron": "",
+ "size": 0,
+ "rmt_mediaext": ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v"
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._event.set()
+ self._scheduler.shutdown()
+ self._event.clear()
+ self._scheduler = None
diff --git a/plugins/homepage/__init__.py b/plugins/homepage/__init__.py
new file mode 100644
index 0000000..8343abe
--- /dev/null
+++ b/plugins/homepage/__init__.py
@@ -0,0 +1,648 @@
+from pathlib import Path
+
+from app.chain.dashboard import DashboardChain
+from app.core.config import settings
+from app.db.subscribe_oper import SubscribeOper
+from app.helper.directory import DirectoryHelper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.schemas import NotificationType
+from app import schemas
+from app.utils.string import StringUtils
+from app.utils.system import SystemUtils
+
+
+class HomePage(_PluginBase):
+ # 插件名称
+ plugin_name = "HomePage"
+ # 插件描述
+ plugin_desc = "HomePage自定义API。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/homepage.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "homepage_"
+ # 加载顺序
+ plugin_order = 30
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 任务执行间隔
+ _enabled = False
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._enabled = config.get("enabled")
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ def statistic(self, apikey: str) -> Any:
+ """
+ 订阅、剩余空间等信息
+ """
+ if apikey != settings.API_TOKEN:
+ return schemas.Response(success=False, message="API密钥错误")
+
+ # 媒体统计
+ movie_count = 0
+ tv_count = 0
+ episode_count = 0
+ user_count = 0
+ media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic()
+ if media_statistics:
+ # 汇总各媒体库统计信息
+ for media_statistic in media_statistics:
+ movie_count += media_statistic.movie_count
+ tv_count += media_statistic.tv_count
+ episode_count += media_statistic.episode_count
+ user_count += media_statistic.user_count
+
+ # 磁盘统计
+ library_dirs = DirectoryHelper().get_library_dirs()
+ total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
+
+ # 订阅统计
+ movie_subscribes = 0
+ tv_subscribes = 0
+ subscribes = SubscribeOper().list()
+ for subscribe in subscribes:
+ if str(subscribe.type) == '电影':
+ movie_subscribes += 1
+ else:
+ tv_subscribes += 1
+ return {
+ 'movie_count': movie_count,
+ 'tv_count': tv_count,
+ 'episode_count': episode_count,
+ 'user_count': user_count,
+ 'total_storage': StringUtils.str_filesize(total_storage),
+ 'free_storage': StringUtils.str_filesize(free_storage),
+ 'used_storage': StringUtils.str_filesize(total_storage - free_storage),
+ 'movie_subscribes': movie_subscribes,
+ 'tv_subscribes': tv_subscribes,
+ }
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ """
+ 获取插件API
+ [{
+ "path": "/xx",
+ "endpoint": self.xxx,
+ "methods": ["GET", "POST"],
+ "summary": "API说明"
+ }]
+ """
+ return [{
+ "path": "/statistic",
+ "endpoint": self.statistic,
+ "methods": ["GET"],
+ "summary": "数据统计",
+ "description": "订阅数量等统计数量",
+ }]
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'success',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '配置教程请参考:'
+ },
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/HomePage.md',
+ 'target': '_blank'
+ },
+ 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/HomePage.md'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '如安装完启用插件后,HomePage提示404,重启MoviePilot即可。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ }
+
+ def get_page(self) -> List[dict]:
+ dict = self.statistic(settings.API_TOKEN)
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '电影订阅'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('movie_subscribes')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '电视剧订阅'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('tv_subscribes')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '总空间'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('total_storage')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '剩余空间'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('free_storage')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '电影数量'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('movie_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '电视剧数量'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('tv_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '电影剧集数量'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('episode_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '用户数量'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': dict.get('user_count')
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/linktosrc/__init__.py b/plugins/linktosrc/__init__.py
new file mode 100644
index 0000000..d3f96e6
--- /dev/null
+++ b/plugins/linktosrc/__init__.py
@@ -0,0 +1,204 @@
+import sqlite3
+from pathlib import Path
+from typing import List, Tuple, Dict, Any
+
+from app.core.config import Settings
+from app.log import logger
+from app.plugins import _PluginBase
+
+
+class LinkToSrc(_PluginBase):
+ # 插件名称
+ plugin_name = "源文件恢复"
+ # 插件描述
+ plugin_desc = "根据MoviePilot的转移记录中的硬链文件恢复源文件"
+ # 插件图标
+ plugin_icon = "Time_machine_A.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "linktosrc_"
+ # 加载顺序
+ plugin_order = 32
+ # 可使用的用户级别
+ auth_level = 1
+
+ _onlyonce: bool = False
+ _link_dirs: str = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._onlyonce = config.get("onlyonce")
+ self._link_dirs = config.get("link_dirs")
+
+ if self._onlyonce:
+ # 执行替换
+ self._task()
+ self._onlyonce = False
+ self.__update_config()
+
+ def _task(self):
+ db_path = Settings().CONFIG_PATH / 'user.db'
+ try:
+ gradedb = sqlite3.connect(db_path)
+ except Exception as e:
+ logger.error(f"无法打开数据库文件 {db_path},请检查路径是否正确:{str(e)}")
+ return
+
+ transfer_history = []
+ # 创建游标cursor来执行executeSQL语句
+ cursor = gradedb.cursor()
+ if self._link_dirs:
+ link_dirs = self._link_dirs.split("\n")
+ for link_dir in link_dirs:
+ sql = f'''
+ SELECT
+ src,
+ dest
+ FROM
+ transferhistory
+ WHERE
+ src IS NOT NULL and dest IS NOT NULL and dest like '{link_dir}%';
+ '''
+ cursor.execute(sql)
+ transfer_history += cursor.fetchall()
+ else:
+ sql = '''
+ SELECT
+ src,
+ dest
+ FROM
+ transferhistory
+ WHERE
+ src IS NOT NULL and dest IS NOT NULL;
+ '''
+ cursor.execute(sql)
+ transfer_history = cursor.fetchall()
+ logger.info(f"查询到历史记录{len(transfer_history)}条")
+ cursor.close()
+
+ if not transfer_history:
+ logger.error("未获取到历史记录,停止处理")
+ return
+
+ for history in transfer_history:
+ src = history[0]
+ dest = history[1]
+ # 判断源文件是否存在
+ if Path(src).exists():
+ logger.warn(f"源文件{src}已存在,跳过处理")
+ continue
+ # 源文件不存在,目标文件也不存在,跳过
+ if not Path(dest).exists():
+ logger.warn(f"源文件{src}不存在且硬链文件{dest}不存在,跳过处理")
+ continue
+ # 创建源文件目录,防止目录不存在无法执行
+ Path(src).parent.mkdir(parents=True, exist_ok=True)
+ # 目标文件硬链回源文件
+ Path(src).hardlink_to(dest)
+ logger.info(f"硬链文件{dest}重新链接回源文件{src}")
+
+ logger.info("全部处理完成")
+
+ def __update_config(self):
+ self.update_config({
+ "onlyonce": self._onlyonce,
+ "link_dirs": self._link_dirs
+ })
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'link_dirs',
+ 'label': '需要恢复的硬链接目录',
+ 'rows': 5,
+ 'placeholder': '硬链接目录 (一行一个)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '根据转移记录中的硬链接恢复源文件',
+ 'style': 'white-space: pre-line;'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "onlyonce": False,
+ "link_dirs": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def get_state(self) -> bool:
+ return self._onlyonce
+
+ def stop_service(self):
+ pass
diff --git a/plugins/pluginautoupdate/__init__.py b/plugins/pluginautoupdate/__init__.py
new file mode 100644
index 0000000..403b198
--- /dev/null
+++ b/plugins/pluginautoupdate/__init__.py
@@ -0,0 +1,574 @@
+from datetime import datetime, timedelta
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from fastapi import APIRouter
+
+from app.core.config import settings
+from app.core.plugin import PluginManager
+from app.db.systemconfig_oper import SystemConfigOper
+from app.helper.plugin import PluginHelper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from app.schemas.types import SystemConfigKey
+from app.schemas import NotificationType
+from app.scheduler import Scheduler
+from app.schemas.types import EventType
+from app.core.event import eventmanager, Event
+from app.utils.string import StringUtils
+
+router = APIRouter()
+
+
+class PluginAutoUpdate(_PluginBase):
+ # 插件名称
+ plugin_name = "插件更新管理"
+ # 插件描述
+ plugin_desc = "监测已安装插件,推送更新提醒,可配置自动更新。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/pluginupdate.png"
+ # 插件版本
+ plugin_version = "1.9"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "pluginautoupdate_"
+ # 加载顺序
+ plugin_order = 97
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ # 任务执行间隔
+ _cron = None
+ _onlyonce = False
+ _update = False
+ _notify = False
+ _msgtype = None
+ _update_ids = []
+ _exclude_ids = []
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+ _plugin_version = {}
+
+ def init_plugin(self, config: dict = None):
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._cron = config.get("cron")
+ self._onlyonce = config.get("onlyonce")
+ self._update = config.get("update")
+ self._notify = config.get("notify")
+ self._msgtype = config.get("msgtype")
+ self._update_ids = config.get("update_ids")
+ self._exclude_ids = config.get("exclude_ids")
+
+ if self._enabled:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.plugin_update,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="插件自动更新")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{str(err)}")
+
+ if self._onlyonce:
+ logger.info(f"插件自动更新服务启动,立即运行一次")
+ # 关闭一次性开关
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "enabled": self._enabled,
+ "update": self._update,
+ "notify": self._notify,
+ "msgtype": self._msgtype,
+ "update_ids": self._update_ids,
+ "exclude_ids": self._exclude_ids,
+ })
+
+ self._scheduler.add_job(func=self.plugin_update, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=1),
+ name="插件自动更新")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ @eventmanager.register(EventType.PluginAction)
+ def plugin_update(self, event: Event = None):
+ """
+ 插件自动更新
+ """
+ if not self._enabled:
+ logger.error("插件未开启")
+ return
+
+ update_forced: bool = False
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "plugin_update":
+ return
+ logger.info("收到命令,开始插件更新 ...")
+ update_forced = True
+ self.post_message(channel=event.event_data.get("channel"),
+ title="开始插件更新 ...",
+ userid=event.event_data.get("user"))
+
+ logger.info("插件更新任务开始")
+ # 已安装插件
+ install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
+
+ # 在线插件
+ online_plugins = PluginManager().get_online_plugins()
+ if not online_plugins:
+ logger.error("未获取到在线插件,停止运行")
+ return
+
+ # 使用字典来存储每个插件的最大版本号
+ max_versions = {}
+ for plugin in online_plugins:
+ if plugin.id not in max_versions or plugin.plugin_version > max_versions[plugin.id]:
+ max_versions[plugin.id] = plugin.plugin_version
+ # 根据最大版本号来筛选数据
+ online_plugins = [plugin for plugin in online_plugins if
+ plugin.plugin_version == max_versions[plugin.id]]
+
+ # 已安装插件版本
+ self.__get_install_plugin_version()
+
+ # 系统运行的服务
+ schedulers = Scheduler().list()
+ running_scheduler = []
+ for scheduler in schedulers:
+ if scheduler.status == "正在运行":
+ running_scheduler.append(scheduler.id)
+
+ title = None
+ # 支持更新的插件自动更新
+ for plugin in online_plugins:
+ # 只处理已安装的插件
+ if str(plugin.id) in install_plugins:
+ # 有更新 或者 本地未安装的
+ if plugin.has_update or not plugin.installed:
+ # 已安装插件版本
+ install_plugin_version = self._plugin_version.get(str(plugin.id))
+ version_text = f"更新版本:v{install_plugin_version} -> v{plugin.plugin_version}"
+
+ # 自动更新
+ if self._update or update_forced:
+ # 判断是否是排除插件
+ if self._exclude_ids and str(plugin.id) in self._exclude_ids:
+ logger.info(f"插件 {plugin.plugin_name} 已被排除自动更新,跳过")
+ continue
+ # 判断是否是已选择插件
+ if self._update_ids and str(plugin.id) not in self._update_ids:
+ logger.info(f"插件 {plugin.plugin_name} 不在自动更新列表中,跳过")
+ continue
+ # 判断当前要升级的插件是否正在运行,正则运行则暂不更新
+ if plugin.id in running_scheduler:
+ msg = f"插件 {plugin.plugin_name} 正在运行,跳过自动升级,最新版本 v{plugin.plugin_version}"
+ logger.info(msg)
+ title = msg
+ continue
+ else:
+ # 下载安装
+ state, msg = PluginHelper().install(pid=plugin.id,
+ repo_url=plugin.repo_url)
+ # 安装失败
+ if not state:
+ title = f"插件 {plugin.plugin_name} 更新失败"
+ logger.error(f"{title} {version_text}")
+ else:
+ title = f"插件 {plugin.plugin_name} 更新成功"
+ logger.info(f"{title} {version_text}")
+
+ # 加载插件到内存
+ PluginManager().reload_plugin(plugin.id)
+ # 注册插件服务
+ Scheduler().update_plugin_job(plugin.id)
+ # 注册插件API
+ self.register_plugin_api(plugin.id)
+ else:
+ title = f"插件 {plugin.plugin_name} 有更新啦"
+ logger.info(f"{title} {version_text}")
+
+ # 发送通知
+ if self._notify and self._msgtype:
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+
+ plugin_icon = plugin.plugin_icon
+ if not str(plugin_icon).startswith("http"):
+ plugin_icon = f"https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/icons/{plugin_icon}"
+ if plugin.history:
+ for verison in plugin.history.keys():
+ if str(verison).replace("v", "") == str(plugin.plugin_version).replace("v", ""):
+ version_text += f"\n更新记录:{plugin.history[verison]}"
+ self.post_message(title=title,
+ mtype=mtype,
+ text=version_text,
+ image=plugin_icon)
+
+ # 重载插件管理器
+ if not title:
+ logger.info("所有插件已是最新版本")
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "plugin_update":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title="所有插件已是最新版本",
+ userid=event.event_data.get("user"))
+
+ else:
+ if '正在运行,跳过自动升级' in title:
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "plugin_update":
+ return
+ self.post_message(channel=event.event_data.get("channel"),
+ title=title,
+ userid=event.event_data.get("user"))
+
+ def __get_install_plugin_version(self):
+ """
+ 获取已安装插件版本
+ """
+ # 本地插件
+ local_plugins = PluginManager().get_local_plugins()
+ for plugin in local_plugins:
+ self._plugin_version[plugin.id] = plugin.plugin_version
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ return [{
+ "cmd": "/plugin_update",
+ "event": EventType.PluginAction,
+ "desc": "插件更新",
+ "category": "",
+ "data": {
+ "action": "plugin_update"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ @staticmethod
+ def register_plugin_api(plugin_id: str = None):
+ """
+ 注册插件API(先删除后新增)
+ """
+ apis: List[Dict[str, Any]] = []
+ for api in PluginManager().get_plugin_apis():
+ if plugin_id in api.get("path"):
+ apis.append(api)
+
+ for api in apis:
+ for r in router.routes:
+ if r.path == api.get("path"):
+ router.routes.remove(r)
+ break
+ router.add_api_route(**api)
+
+ @staticmethod
+ def get_local_plugins():
+ """
+ 获取本地插件
+ """
+ # 已安装插件
+ install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
+
+ local_plugins = {}
+ # 线上插件列表
+ markets = settings.PLUGIN_MARKET.split(",")
+ for market in markets:
+ online_plugins = PluginHelper().get_plugins(market) or {}
+ for pid, plugin in online_plugins.items():
+ if pid in install_plugins:
+ local_plugin = local_plugins.get(pid)
+ if local_plugin:
+ if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+ else:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+
+ return local_plugins
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+
+ # 已安装插件
+ local_plugins = self.get_local_plugins()
+ # 编历 local_plugins,生成插件类型选项
+ pluginOptions = []
+
+ for plugin_id in list(local_plugins.keys()):
+ local_plugin = local_plugins.get(plugin_id)
+ pluginOptions.append({
+ "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}",
+ "value": local_plugin.get("id")
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'update',
+ 'label': '自动更新',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '监测周期',
+ 'placeholder': '5位cron表达式'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'update_ids',
+ 'label': '更新插件',
+ 'items': pluginOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'exclude_ids',
+ 'label': '排除插件',
+ 'items': pluginOptions
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '已安装的插件自动更新最新版本。'
+ '如未开启自动更新则发送更新通知。'
+ '如更新插件正在运行,则本次跳过更新。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '所有已安装插件均会检查更新,发送通知。'
+ '更新插件/排除插件仅针对于自动更新场景。'
+ '如未选择更新插件,则默认为自动更新所有。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "update": False,
+ "notify": False,
+ "cron": "",
+ "msgtype": "",
+ "update_ids": [],
+ "exclude_ids": [],
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ pass
+ # logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/pluginreinstall/__init__.py b/plugins/pluginreinstall/__init__.py
new file mode 100644
index 0000000..a6103d8
--- /dev/null
+++ b/plugins/pluginreinstall/__init__.py
@@ -0,0 +1,330 @@
+import re
+
+from fastapi import APIRouter
+
+from app.core.config import settings
+from app.core.plugin import PluginManager
+from app.db.systemconfig_oper import SystemConfigOper
+from app.helper.plugin import PluginHelper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.schemas.types import SystemConfigKey
+from app.utils.string import StringUtils
+from app.scheduler import Scheduler
+
+router = APIRouter()
+
+
+class PluginReInstall(_PluginBase):
+ # 插件名称
+ plugin_name = "插件强制重装"
+ # 插件描述
+ plugin_desc = "卸载当前插件,强制重装。"
+ # 插件图标
+ plugin_icon = "refresh.png"
+ # 插件版本
+ plugin_version = "1.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "pluginreinstall_"
+ # 加载顺序
+ plugin_order = 98
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _reload = False
+ _plugin_ids = []
+ _plugin_url = []
+ _base_url = "https://raw.githubusercontent.com/%s/%s/main/"
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._reload = config.get("reload")
+ self._plugin_ids = config.get("plugin_ids") or []
+ if not self._plugin_ids:
+ return
+ self._plugin_url = config.get("plugin_url")
+
+ # 仅重载插件
+ if self._reload:
+ for plugin_id in self._plugin_ids:
+ self.__reload_plugin(plugin_id)
+ logger.info(f"插件 {plugin_id} 热重载成功")
+ self.__update_conifg()
+ else:
+ # 校验插件仓库格式
+ plugin_url = None
+ if self._plugin_url:
+ pattern = "https://github.com/(.*?)/(.*?)/"
+ matches = re.findall(pattern, str(self._plugin_url))
+ if not matches:
+ logger.warn(f"指定插件仓库地址 {self._plugin_url} 错误,将使用插件默认地址重装")
+ self._plugin_url = ""
+
+ user, repo = PluginHelper().get_repo_info(self._plugin_url)
+ plugin_url = self._base_url % (user, repo)
+
+ self.__update_conifg()
+
+ # 本地插件
+ local_plugins = self.get_local_plugins()
+
+ # 开始重载插件
+ for plugin_id in list(local_plugins.keys()):
+ local_plugin = local_plugins.get(plugin_id)
+ if plugin_id in self._plugin_ids:
+ logger.info(
+ f"开始重载插件 {local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}")
+
+ # 开始安装线上插件
+ state, msg = PluginHelper().install(pid=plugin_id,
+ repo_url=plugin_url or local_plugin.get("repo_url"))
+ # 安装失败
+ if not state:
+ logger.error(
+ f"插件 {local_plugin.get('plugin_name')} 重装失败,当前版本 v{local_plugin.get('plugin_version')}")
+ continue
+
+ logger.info(
+ f"插件 {local_plugin.get('plugin_name')} 重装成功,当前版本 v{local_plugin.get('plugin_version')}")
+
+ self.__reload_plugin(plugin_id)
+
+ def __update_conifg(self):
+ self.update_config({
+ "reload": self._reload,
+ "plugin_url": self._plugin_url,
+ })
+
+ def __reload_plugin(self, plugin_id):
+ """
+ 重载插件
+ """
+ # 加载插件到内存
+ PluginManager().reload_plugin(plugin_id)
+ # 注册插件服务
+ Scheduler().update_plugin_job(plugin_id)
+ # 注册插件API
+ self.register_plugin_api(plugin_id)
+
+ @staticmethod
+ def register_plugin_api(plugin_id: str = None):
+ """
+ 注册插件API(先删除后新增)
+ """
+ for api in PluginManager().get_plugin_apis(plugin_id):
+ for r in router.routes:
+ if r.path == api.get("path"):
+ router.routes.remove(r)
+ break
+ router.add_api_route(**api)
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 已安装插件
+ local_plugins = self.get_local_plugins()
+ # 编历 local_plugins,生成插件类型选项
+ pluginOptions = []
+
+ for plugin_id in list(local_plugins.keys()):
+ local_plugin = local_plugins.get(plugin_id)
+ pluginOptions.append({
+ "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}",
+ "value": local_plugin.get("id")
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'reload',
+ 'label': '仅重载',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'plugin_ids',
+ 'label': '重装插件',
+ 'items': pluginOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 8
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'plugin_url',
+ 'label': '仓库地址',
+ 'placeholder': 'https://github.com/%s/%s/'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '选择已安装的本地插件,强制安装插件市场最新版本。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '支持指定插件仓库地址(https://github.com/%s/%s/)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '仅重载:不会获取最新代码,而是基于本地代码重新加载插件。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ }
+ ], {
+ "reload": False,
+ "plugin_ids": [],
+ "plugin_url": "",
+ }
+
+ @staticmethod
+ def get_local_plugins():
+ """
+ 获取本地插件
+ """
+ # 已安装插件
+ install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
+
+ local_plugins = {}
+ # 线上插件列表
+ markets = settings.PLUGIN_MARKET.split(",")
+ for market in markets:
+ online_plugins = PluginHelper().get_plugins(market) or {}
+ for pid, plugin in online_plugins.items():
+ if pid in install_plugins:
+ local_plugin = local_plugins.get(pid)
+ if local_plugin:
+ if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+ else:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+
+ return local_plugins
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/pluginuninstall/__init__.py b/plugins/pluginuninstall/__init__.py
new file mode 100644
index 0000000..689b2ea
--- /dev/null
+++ b/plugins/pluginuninstall/__init__.py
@@ -0,0 +1,184 @@
+import shutil
+from pathlib import Path
+
+from app.core.config import settings
+from app.db.systemconfig_oper import SystemConfigOper
+from app.helper.plugin import PluginHelper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.schemas.types import SystemConfigKey
+from app.utils.string import StringUtils
+
+
+class PluginUnInstall(_PluginBase):
+ # 插件名称
+ plugin_name = "插件彻底卸载"
+ # 插件描述
+ plugin_desc = "删除数据库中已安装插件记录、清理插件文件。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "pluginuninstall_"
+ # 加载顺序
+ plugin_order = 98
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _plugin_ids = []
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._plugin_ids = config.get("plugin_ids") or []
+ if not self._plugin_ids:
+ return
+
+ # 已安装插件
+ install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
+
+ new_install_plugins = []
+ for install_plugin in install_plugins:
+ if install_plugin in self._plugin_ids:
+ # 删除插件文件
+ plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / install_plugin.lower()
+ if plugin_dir.exists():
+ shutil.rmtree(plugin_dir, ignore_errors=True)
+ logger.info(f"插件 {install_plugin} 已卸载")
+ else:
+ new_install_plugins.append(install_plugin)
+
+ # 保存已安装插件
+ SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, new_install_plugins)
+
+ self.update_config({
+ "plugin_ids": ""
+ })
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 已安装插件
+ local_plugins = self.get_local_plugins()
+ # 编历 local_plugins,生成插件类型选项
+ pluginOptions = []
+
+ for plugin_id in list(local_plugins.keys()):
+ local_plugin = local_plugins.get(plugin_id)
+ pluginOptions.append({
+ "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}",
+ "value": local_plugin.get("id")
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'plugin_ids',
+ 'label': '卸载插件',
+ 'items': pluginOptions
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '删除数据库中已安装插件记录、清理插件文件。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ }
+ ], {
+ "plugin_ids": []
+ }
+
+ @staticmethod
+ def get_local_plugins():
+ """
+ 获取本地插件
+ """
+ # 已安装插件
+ install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
+
+ local_plugins = {}
+ # 线上插件列表
+ markets = settings.PLUGIN_MARKET.split(",")
+ for market in markets:
+ online_plugins = PluginHelper().get_plugins(market) or {}
+ for pid, plugin in online_plugins.items():
+ if pid in install_plugins:
+ local_plugin = local_plugins.get(pid)
+ if local_plugin:
+ if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+ else:
+ local_plugins[pid] = {
+ "id": pid,
+ "plugin_name": plugin.get("name"),
+ "repo_url": market,
+ "plugin_version": plugin.get("version")
+ }
+
+ return local_plugins
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/popularsubscribe/__init__.py b/plugins/popularsubscribe/__init__.py
new file mode 100644
index 0000000..58ba1b9
--- /dev/null
+++ b/plugins/popularsubscribe/__init__.py
@@ -0,0 +1,953 @@
+from datetime import datetime, timedelta
+
+import pytz
+import cn2an
+
+from app import schemas
+from app.chain.download import DownloadChain
+from app.chain.subscribe import SubscribeChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+from app.core.metainfo import MetaInfo
+from app.helper.subscribe import SubscribeHelper
+from app.schemas import MediaType
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from app.modules.themoviedb.tmdbapi import TmdbApi
+
+
+class PopularSubscribe(_PluginBase):
+ # 插件名称
+ plugin_name = "热门媒体订阅"
+ # 插件描述
+ plugin_desc = "自定添加热门电影、电视剧、动漫到订阅。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/popular.png"
+ # 插件版本
+ plugin_version = "1.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "popularsubscribe_"
+ # 加载顺序
+ plugin_order = 25
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _movie_enabled: bool = False
+ _tv_enabled: bool = False
+ _anime_enabled: bool = False
+ # 一页多少条数据
+ _movie_page_cnt: int = 30
+ _tv_page_cnt: int = 30
+ _anime_page_cnt: int = 30
+ # 流行度最低多少
+ _movie_popular_cnt: int = 0
+ _tv_popular_cnt: int = 0
+ _anime_popular_cnt: int = 0
+ _movie_cron: str = ""
+ _tv_cron: str = ""
+ _anime_cron: str = ""
+ _onlyonce: bool = False
+ _clear = False
+ _clear_already_handle = False
+ _username = None
+
+ downloadchain = None
+ subscribechain = None
+ tmdb = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ self.downloadchain = DownloadChain()
+ self.subscribechain = SubscribeChain()
+ self.tmdb = TmdbApi()
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._movie_enabled = config.get("movie_enabled")
+ self._tv_enabled = config.get("tv_enabled")
+ self._anime_enabled = config.get("anime_enabled")
+ self._movie_cron = config.get("movie_cron")
+ self._tv_cron = config.get("tv_cron")
+ self._anime_cron = config.get("anime_cron")
+ self._movie_page_cnt = config.get("movie_page_cnt")
+ self._tv_page_cnt = config.get("tv_page_cnt")
+ self._anime_page_cnt = config.get("anime_page_cnt")
+ self._movie_popular_cnt = config.get("movie_popular_cnt")
+ self._tv_popular_cnt = config.get("tv_popular_cnt")
+ self._anime_popular_cnt = config.get("anime_popular_cnt")
+ self._clear = config.get("clear")
+ self._clear_already_handle = config.get("clear_already_handle")
+ self._username = config.get("username") or '热门订阅'
+ _onlyonce2 = config.get("onlyonce")
+
+ # 清理插件订阅历史
+ if self._clear:
+ self.del_data(key="history")
+
+ self._clear = False
+ self.__update_config()
+ logger.info("订阅历史清理完成")
+
+ # 清理已处理历史
+ if self._clear_already_handle:
+ self.del_data(key="already_handle")
+
+ self._clear_already_handle = False
+ self.__update_config()
+ logger.info("已处理历史清理完成")
+
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ if self._movie_enabled and (self._movie_cron or _onlyonce2):
+ if self._movie_cron:
+ try:
+ self._scheduler.add_job(func=self.__popular_subscribe,
+ trigger=CronTrigger.from_crontab(self._movie_cron),
+ name="电影热门订阅",
+ args=['电影', self._movie_page_cnt, self._movie_popular_cnt])
+ except Exception as err:
+ logger.error(f"电影热门订阅定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"电影热门订阅执行周期配置错误:{err}")
+
+ if _onlyonce2:
+ logger.info(f"电影热门订阅服务启动,立即运行一次")
+ self._scheduler.add_job(self.__popular_subscribe, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="电影热门订阅",
+ args=['电影', self._movie_page_cnt, self._movie_popular_cnt])
+ self._onlyonce = False
+ self.__update_config()
+
+ if self._tv_enabled and (self._tv_cron or _onlyonce2):
+ if self._tv_cron:
+ try:
+ self._scheduler.add_job(func=self.__popular_subscribe,
+ trigger=CronTrigger.from_crontab(self._tv_cron),
+ name="电视剧热门订阅",
+ args=['电视剧', self._tv_page_cnt, self._tv_popular_cnt])
+ except Exception as err:
+ logger.error(f"电视剧热门订阅定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"电视剧热门订阅执行周期配置错误:{err}")
+
+ if _onlyonce2:
+ logger.info(f"电视剧热门订阅服务启动,立即运行一次")
+ self._scheduler.add_job(self.__popular_subscribe, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="电视剧热门订阅",
+ args=['电视剧', self._tv_page_cnt, self._tv_popular_cnt])
+ self._onlyonce = False
+ self.__update_config()
+
+ if self._anime_enabled and (self._anime_cron or _onlyonce2):
+ if self._anime_cron:
+ try:
+ self._scheduler.add_job(func=self.__popular_subscribe,
+ trigger=CronTrigger.from_crontab(self._anime_cron),
+ name="动漫热门订阅",
+ args=['动漫', self._anime_page_cnt, self._anime_popular_cnt])
+ except Exception as err:
+ logger.error(f"动漫热门订阅定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"动漫热门订阅执行周期配置错误:{err}")
+
+ if _onlyonce2:
+ logger.info(f"动漫热门订阅服务启动,立即运行一次")
+ self._scheduler.add_job(self.__popular_subscribe, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="动漫热门订阅",
+ args=['动漫', self._anime_page_cnt, self._anime_popular_cnt])
+ self._onlyonce = False
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ self.update_config({
+ "movie_enabled": self._movie_enabled,
+ "tv_enabled": self._tv_enabled,
+ "anime_enabled": self._anime_enabled,
+ "movie_cron": self._movie_cron,
+ "tv_cron": self._tv_cron,
+ "anime_cron": self._anime_cron,
+ "movie_page_cnt": self._movie_page_cnt,
+ "tv_page_cnt": self._tv_page_cnt,
+ "anime_page_cnt": self._anime_page_cnt,
+ "movie_popular_cnt": self._movie_popular_cnt,
+ "tv_popular_cnt": self._tv_popular_cnt,
+ "anime_popular_cnt": self._anime_popular_cnt,
+ "clear": self._clear,
+ "clear_already_handle": self._clear_already_handle,
+ "onlyonce": self._onlyonce,
+ "username": self._username
+ })
+
+ def __popular_subscribe(self, stype, page_cnt, popular_cnt):
+ """
+ 热门订阅
+ """
+ true_type = stype
+ true_cnt = page_cnt
+ if str(stype) == '动漫':
+ stype = "电视剧"
+ # 动漫|电视剧 公用一组数据,取所需数据的20倍应该ok吧
+ page_cnt = int(page_cnt) * 20
+
+ subscribes = SubscribeHelper().get_statistic(stype=stype, page=1, count=page_cnt)
+ if not subscribes:
+ logger.error(f"没有获取到{true_type}热门订阅")
+ return
+
+ history: List[dict] = self.get_data('history') or []
+ already_handle: List[dict] = self.get_data('already_handle') or []
+
+ # 遍历热门订阅检查流行度是否达到要求
+ tv_anime_cnt = 0
+ for sub in subscribes:
+ if popular_cnt and sub.get("count") and int(popular_cnt) > int(sub.get("count")):
+ logger.info(
+ f"{sub.get('name')} 订阅人数:{sub.get('count')} 小于 设定人数:{popular_cnt},跳过")
+ continue
+
+ media = MediaInfo()
+ media.tmdb_id = sub.get("tmdbid")
+ media.type = MediaType(sub.get("type"))
+ media.title = sub.get("name")
+ media.year = sub.get("year")
+ media.douban_id = sub.get("doubanid")
+ media.bangumi_id = sub.get("bangumiid")
+ media.tvdb_id = sub.get("tvdbid")
+ media.imdb_id = sub.get("imdbid")
+ media.season = sub.get("season")
+ media.poster_path = sub.get("poster")
+
+ # 元数据
+ meta = MetaInfo(media.title)
+
+ # 电视剧特殊处理:动漫|电视剧
+ if str(stype) == "电视剧":
+ # 动漫|电视剧所需请求数量以达到
+ if int(tv_anime_cnt) >= int(true_cnt):
+ break
+
+ # 根据tmdbid获取媒体信息
+ tmdb_info = self.tmdb.get_info(mtype=media.type, tmdbid=media.tmdb_id)
+ if not tmdb_info:
+ logger.warn(f'未识别到媒体信息,标题:{media.title},tmdbid:{media.tmdb_id}')
+ continue
+
+ # 获取媒体类型
+ genre_ids = tmdb_info.get("genre_ids") or []
+ if genre_ids:
+ # 如果当前是动漫订阅,则判断是否在动漫分类中,如果不在则跳过
+ if str(true_type) == '动漫' and not set(genre_ids).intersection(set(settings.ANIME_GENREIDS)):
+ logger.debug(f'{media.title_year} 不在动漫分类中,跳过')
+ continue
+ # 如果当前是电视剧订阅,则判断是否在动漫分类中,如果在则跳过
+ if str(true_type) == '电视剧' and set(genre_ids).intersection(set(settings.ANIME_GENREIDS)):
+ logger.debug(f'{media.title_year} 在动漫分类中,跳过')
+ continue
+
+ # 电视剧|动漫分类都通过,则计数
+ tv_anime_cnt += 1
+
+ if media.title_year in already_handle:
+ logger.info(f"{media.type.value} {media.title_year} 已被处理,跳过")
+ continue
+ already_handle.append(media.title_year)
+
+ title = media.title_year
+ season_str = None
+ if media.season and int(media.season) > 1:
+ # 小写数据转大写
+ season_str = f"第{cn2an.an2cn(media.season, 'low')}季"
+ title = f"{media.title_year} {season_str}"
+ logger.info(f"{title} 订阅人数:{sub.get('count')} 满足 设定人数:{popular_cnt}")
+
+ # 查询缺失的媒体信息
+ exist_flag, _ = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=media)
+ if exist_flag:
+ logger.info(f'{media.title_year} 媒体库中已存在')
+ continue
+
+ # 判断用户是否已经添加订阅
+ if self.subscribechain.exists(mediainfo=media):
+ logger.info(f'{media.title_year} 订阅已存在')
+ continue
+
+ # 添加订阅
+ self.subscribechain.add(title=media.title,
+ year=media.year,
+ mtype=media.type,
+ tmdbid=media.tmdb_id,
+ season=media.season,
+ doubanid=media.douban_id,
+ exist_ok=True,
+ username=self._username)
+ logger.info(f'{media.title_year} 订阅人数:{sub.get("count")} 添加订阅')
+
+ # 存储历史记录
+ history.append({
+ "title": media.title,
+ "type": media.type.value,
+ "year": media.year,
+ "season": season_str,
+ "poster": media.get_poster_image(),
+ "overview": media.overview,
+ "tmdbid": media.tmdb_id,
+ "doubanid": media.douban_id,
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "unique": f"{media.title}:{media.tmdb_id}:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')})"
+ })
+
+ # 保存历史记录
+ self.save_data('history', history)
+ self.save_data('already_handle', already_handle)
+ logger.info(f"{true_type}热门订阅检查完成")
+
+ def delete_history(self, key: str, apikey: str):
+ """
+ 删除同步历史记录
+ """
+ if apikey != settings.API_TOKEN:
+ return schemas.Response(success=False, message="API密钥错误")
+ # 历史记录
+ historys = self.get_data('history')
+ if not historys:
+ return schemas.Response(success=False, message="未找到历史记录")
+ # 删除指定记录
+ historys = [h for h in historys if h.get("unique") != key]
+ self.save_data('history', historys)
+ return schemas.Response(success=True, message="删除成功")
+
+ def get_state(self) -> bool:
+ return self._movie_enabled or self._tv_enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "path": "/delete_history",
+ "endpoint": self.delete_history,
+ "methods": ["GET"],
+ "summary": "删除订阅历史记录"
+ }
+ ]
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'movie_enabled',
+ 'label': '电影热门订阅',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'movie_cron',
+ 'label': '电影订阅周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'movie_page_cnt',
+ 'label': '电影获取条数',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'movie_popular_cnt',
+ 'label': '电影订阅人次',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'tv_enabled',
+ 'label': '电视剧热门订阅',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'tv_cron',
+ 'label': '电视剧订阅周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'tv_page_cnt',
+ 'label': '电视剧获取条数',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'tv_popular_cnt',
+ 'label': '电视剧订阅人次',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'anime_enabled',
+ 'label': '动漫热门订阅',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'anime_cron',
+ 'label': '动漫订阅周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'anime_page_cnt',
+ 'label': '动漫获取条数',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'anime_popular_cnt',
+ 'label': '动漫订阅人次',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '获取指定条数的热门媒体,自定义最低订阅人数要求进行订阅。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'error',
+ 'variant': 'tonal',
+ 'text': '立即运行一次:立即运行一次已开启的电影/电视剧/动漫订阅。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear',
+ 'label': '清理订阅记录',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear_already_handle',
+ 'label': '清理已处理记录',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'username',
+ 'label': '订阅用户',
+ 'placeholder': '默认为`热门订阅`'
+ }
+ }
+ ]
+ },
+ ]
+ }
+ ]
+ }
+ ], {
+ "movie_enabled": False,
+ "tv_enabled": False,
+ "anime_enabled": False,
+ "movie_cron": "5 1 * * *",
+ "tv_cron": "5 1 * * *",
+ "anime_cron": "5 1 * * *",
+ "movie_page_cnt": "",
+ "tv_page_cnt": "",
+ "anime_page_cnt": "",
+ "movie_popular_cnt": "",
+ "tv_popular_cnt": "",
+ "anime_popular_cnt": "",
+ "onlyonce": False,
+ "clear": False,
+ "clear_already_handle": False,
+ "username": "热门订阅"
+ }
+
+ def get_page(self) -> List[dict]:
+ """
+ 拼装插件详情页面,需要返回页面配置,同时附带数据
+ """
+ # 查询历史记录
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+ # 数据按时间降序排序
+ historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
+ # 拼装页面
+ contents = []
+ for history in historys:
+ title = history.get("title")
+ year = history.get("year")
+ season = history.get("season")
+ poster = history.get("poster")
+ mtype = history.get("type")
+ time_str = history.get("time")
+ tmdbid = history.get("tmdbid")
+ doubanid = history.get("doubanid")
+ unique = history.get("unique")
+
+ if season:
+ contents.append(
+ {
+ 'component': 'VCard',
+ 'content': [
+ {
+ "component": "VDialogCloseBtn",
+ "props": {
+ 'innerClass': 'absolute top-0 right-0',
+ },
+ 'events': {
+ 'click': {
+ 'api': 'plugin/PopularSubscribe/delete_history',
+ 'method': 'get',
+ 'params': {
+ 'key': unique,
+ 'apikey': settings.API_TOKEN
+ }
+ }
+ },
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex justify-space-start flex-nowrap flex-row',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': poster,
+ 'height': 120,
+ 'width': 80,
+ 'aspect-ratio': '2/3',
+ 'class': 'object-cover shadow ring-gray-500',
+ 'cover': True
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VCardSubtitle',
+ 'props': {
+ 'class': 'pa-2 font-bold break-words whitespace-break-spaces'
+ },
+ 'content': [
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': f"https://movie.douban.com/subject/{doubanid}",
+ 'target': '_blank'
+ },
+ 'text': title
+ }
+ ]
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'类型:{mtype}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'年份:{year}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'季度:{season}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'时间:{time_str}'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ )
+ else:
+ contents.append(
+ {
+ 'component': 'VCard',
+ 'content': [
+ {
+ "component": "VDialogCloseBtn",
+ "props": {
+ 'innerClass': 'absolute top-0 right-0',
+ },
+ 'events': {
+ 'click': {
+ 'api': 'plugin/PopularSubscribe/delete_history',
+ 'method': 'get',
+ 'params': {
+ 'key': f"popularsubscribe: {title} (DB:{tmdbid})",
+ 'apikey': settings.API_TOKEN
+ }
+ }
+ },
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex justify-space-start flex-nowrap flex-row',
+ },
+ 'content': [
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': poster,
+ 'height': 120,
+ 'width': 80,
+ 'aspect-ratio': '2/3',
+ 'class': 'object-cover shadow ring-gray-500',
+ 'cover': True
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'VCardSubtitle',
+ 'props': {
+ 'class': 'pa-2 font-bold break-words whitespace-break-spaces'
+ },
+ 'content': [
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': f"https://movie.douban.com/subject/{doubanid}",
+ 'target': '_blank'
+ },
+ 'text': title
+ }
+ ]
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'类型:{mtype}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'年份:{year}'
+ },
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'pa-0 px-2'
+ },
+ 'text': f'时间:{time_str}'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ )
+ return [
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'grid gap-3 grid-info-card',
+ },
+ 'content': contents
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/removetorrent/__init__.py b/plugins/removetorrent/__init__.py
new file mode 100644
index 0000000..7829507
--- /dev/null
+++ b/plugins/removetorrent/__init__.py
@@ -0,0 +1,425 @@
+from app.modules.qbittorrent import Qbittorrent
+from app.modules.transmission import Transmission
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+
+
+class RemoveTorrent(_PluginBase):
+ # 插件名称
+ plugin_name = "删除站点种子"
+ # 插件描述
+ plugin_desc = "删除下载器中某站点种子。"
+ # 插件图标
+ plugin_icon = "delete.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "removetorrent_"
+ # 加载顺序
+ plugin_order = 30
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _downloader = None
+ _onlyonce = None
+ _delete_type = False
+ _delete_torrent = False
+ _delete_file = False
+ _trackers = None
+ qb = None
+ tr = None
+
+ def init_plugin(self, config: dict = None):
+ self.qb = Qbittorrent()
+ self.tr = Transmission()
+
+ if config:
+ self._downloader = config.get("downloader")
+ self._onlyonce = config.get("onlyonce")
+ self._delete_type = config.get("delete_type")
+ self._delete_torrent = config.get("delete_torrent")
+ self._delete_file = config.get("delete_file")
+ self._trackers = config.get("trackers")
+
+ if self._trackers and self._onlyonce:
+ self.update_config({
+ "downloader": self._downloader,
+ "delete_type": self._delete_type,
+ "delete_torrent": self._delete_torrent,
+ "delete_file": self._delete_file,
+ "trackers": self._trackers,
+ "onlyonce": False
+ })
+
+ for tracker in str(self._trackers).split("\n"):
+ logger.info(f"下载器 {self._downloader} 开始处理站点tracker {tracker}")
+ self.__check_feed(tracker)
+ logger.info(f"下载器 {self._downloader} 处理站点tracker {tracker} 完成")
+
+ def __check_feed(self, tracker: str):
+ """
+ 检查tracker辅种情况
+ """
+ downloader_obj = self.__get_downloader(self._downloader)
+ # 获取下载器中已完成的种子
+ torrents = downloader_obj.get_completed_torrents()
+ if not torrents:
+ logger.info(f"下载器 {self._downloader} 未获取到已完成种子")
+ return
+ logger.info(f"下载器 {self._downloader} 获取到已完成种子 {len(torrents)} 个")
+
+ all_torrents = []
+ tracker_torrents = []
+ key_torrents = {}
+ # 遍历种子,以种子名称和种子大小为key,查询辅种数量
+ for torrent in torrents:
+ torrent_size = self.__get_torrent_size(torrent, self._downloader)
+ torrent_name = self.__get_torrent_name(torrent, self._downloader)
+ torrent_key = "%s-%s" % (torrent_name, torrent_size)
+ all_torrents.append(torrent_key)
+
+ torrent_trackers = self.__get_torrent_trackers(torrent, self._downloader)
+ if str(self._downloader) == "qb":
+ # 命中tracker的种子
+ if str(tracker) in torrent_trackers:
+ tracker_torrents.append(torrent_key)
+ key_torrents[torrent_key] = torrent
+ else:
+ for torrent_tracker in torrent_trackers:
+ # 命中tracker的种子
+ if str(tracker) in torrent_tracker.get('announce'):
+ tracker_torrents.append(torrent_key)
+ key_torrents[torrent_key] = torrent
+
+ if not tracker_torrents:
+ logger.error(f"下载器 {self._downloader} 未获取到命中tracker {tracker} 的种子")
+ return
+
+ logger.info(f"下载器 {self._downloader} 获取到命中tracker {tracker} 已完成种子 {len(tracker_torrents)} 个")
+
+ # 查询tracker种子是否有其他辅种
+ for tracker_torrent in tracker_torrents:
+ torrent = key_torrents.get(tracker_torrent)
+ torrent_name = self.__get_torrent_name(torrent, self._downloader)
+ torrent_hash = self.__get_torrent_hash(torrent, self._downloader)
+
+ if self._delete_type:
+ # 有辅种
+ if all_torrents.count(tracker_torrent) > 1:
+ # 删除逻辑
+ if self._delete_torrent:
+ downloader_obj.delete_torrents(delete_file=self._delete_file,
+ ids=torrent_hash)
+ logger.info(f"种子 {torrent_name} {torrent_hash} 有其他辅种,已删除")
+ else:
+ logger.info(f"种子 {torrent_name} {torrent_hash} 有其他辅种,可删除")
+ else:
+ # 无辅种
+ logger.warn(f"种子 {torrent_name} {torrent_hash} 在其他站无辅种,如需删除请手动处理")
+ else:
+ # 无辅种
+ if all_torrents.count(tracker_torrent) == 1:
+ # 删除逻辑
+ if self._delete_torrent:
+ downloader_obj.delete_torrents(delete_file=self._delete_file,
+ ids=torrent_hash)
+ logger.info(f"种子 {torrent_name} {torrent_hash} 无其他辅种,已删除")
+ else:
+ logger.info(f"种子 {torrent_name} {torrent_hash} 无其他辅种,可删除")
+ else:
+ logger.warn(f"种子 {torrent_name} {torrent_hash} 在其他站有辅种,如需删除请手动处理")
+
+ def __get_downloader(self, dtype: str):
+ """
+ 根据类型返回下载器实例
+ """
+ if dtype == "qb":
+ return self.qb
+ elif dtype == "tr":
+ return self.tr
+ else:
+ return None
+
+ @staticmethod
+ def __get_torrent_trackers(torrent: Any, dl_type: str):
+ """
+ 获取种子trackers
+ """
+ try:
+ return torrent.get("tracker") if dl_type == "qb" else torrent.trackers
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ @staticmethod
+ def __get_torrent_name(torrent: Any, dl_type: str):
+ """
+ 获取种子name
+ """
+ try:
+ return torrent.get("name") if dl_type == "qb" else torrent.name
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ @staticmethod
+ def __get_torrent_size(torrent: Any, dl_type: str):
+ """
+ 获取种子大小
+ """
+ try:
+ return torrent.get("size") if dl_type == "qb" else torrent.total_size
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ @staticmethod
+ def __get_torrent_hash(torrent: Any, dl_type: str):
+ """
+ 获取种子hash
+ """
+ try:
+ return torrent.get("hash") if dl_type == "qb" else torrent.hashString
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'downloader',
+ 'label': '下载器',
+ 'items': [
+ {'title': 'qb', 'value': 'qb'},
+ {'title': 'tr', 'value': 'tr'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'delete_type',
+ 'label': '是否有辅种',
+ 'items': [
+ {'title': '是', 'value': True},
+ {'title': '否', 'value': False}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'delete_torrent',
+ 'label': '删除种子',
+ 'items': [
+ {'title': '是', 'value': True},
+ {'title': '否', 'value': False}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'delete_file',
+ 'label': '删除文件',
+ 'items': [
+ {'title': '是', 'value': True},
+ {'title': '否', 'value': False}
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'trackers',
+ 'rows': '3',
+ 'label': '站点tracker域名',
+ 'placeholder': '站点tracker域名,一行一个'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '输入要删除辅种的站点tracker域名。'
+ '保留站点没有辅种的种子,其余在其他站有辅种的种子均删除。'
+ '(适用于某个站点不想保种了,但是可能有孤种没法直接全部删除的情况)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '场景一:某个站不想保种了,但是有些种子没有辅种,需要保留。'
+ '是否有辅种=是,删除种子=是,删除文件=否。'
+ '(保留站点没有辅种的种子,其余在其他站有辅种的种子均删除(保留文件)。)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '场景二:想删除某个站没有辅种的种子。'
+ '是否有辅种=否,删除种子=是,删除文件=是。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "downloader": "qb",
+ "delete_type": True,
+ "delete_torrent": False,
+ "delete_file": False,
+ "onlyonce": False,
+ "trackers": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/schedulereminder/__init__.py b/plugins/schedulereminder/__init__.py
new file mode 100644
index 0000000..04a0640
--- /dev/null
+++ b/plugins/schedulereminder/__init__.py
@@ -0,0 +1,186 @@
+from app.core.config import settings
+from app.db.site_oper import SiteOper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.schemas import NotificationType
+
+
+class ScheduleReminder(_PluginBase):
+ # 插件名称
+ plugin_name = "日程提醒"
+ # 插件描述
+ plugin_desc = "自定义提醒事项、提醒时间。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/reminder.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "schedulereminder_"
+ # 加载顺序
+ plugin_order = 32
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _confs = None
+ siteoper = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ self.siteoper = SiteOper()
+
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._confs = config.get("confs")
+
+ if self._enabled and self._confs:
+ # 周期运行
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 读取目录配置
+ confs = self._confs.split("\n")
+ if not confs:
+ return
+ for conf in confs:
+ if str(conf).count(":") != 1:
+ logger.warn(f"{conf} 格式错误,跳过处理")
+ continue
+ try:
+ self._scheduler.add_job(func=self.__send_notify,
+ trigger=CronTrigger.from_crontab(str(conf).split(":")[1]),
+ name=f"{str(conf).split(':')[0]}提醒",
+ kwargs={"theme": str(conf).split(":")[0]})
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __send_notify(self, theme: str):
+ """
+ 同步站点cookie到cookiecloud
+ """
+ self.post_message(mtype=NotificationType.Manual,
+ title="日程提醒",
+ text=theme)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'confs',
+ 'label': '提醒事项',
+ 'rows': 5,
+ 'placeholder': '提醒内容:cron'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '提醒事项格式为:提醒内容:提醒时间cron表达式(一行一条)。'
+ '需开启(手动处理通知)通知类型'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "confs": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/shortplaymonitor/__init__.py b/plugins/shortplaymonitor/__init__.py
new file mode 100644
index 0000000..078aba1
--- /dev/null
+++ b/plugins/shortplaymonitor/__init__.py
@@ -0,0 +1,1058 @@
+import os
+import threading
+import datetime
+from pathlib import Path
+
+from typing import Any, List, Dict, Tuple, Optional
+from xml.dom import minidom
+from threading import Lock
+from app.chain.tmdb import TmdbChain
+from app.core.metainfo import MetaInfoPath
+from app.schemas import MediaInfo, TransferInfo
+from app.utils.dom import DomUtils
+from PIL import Image
+import pytz
+from app.db.site_oper import SiteOper
+from apscheduler.schedulers.background import BackgroundScheduler
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+from app.utils.common import retry
+from requests import RequestException
+from app.core.meta.words import WordsMatcher
+from app.log import logger
+from app.plugins import _PluginBase
+from app.core.config import settings
+from app.utils.system import SystemUtils
+from app.schemas.types import NotificationType
+import re
+
+import chardet
+from lxml import etree
+
+from app.modules.indexer import TorrentSpider
+from app.helper.sites import SitesHelper
+
+from app.utils.http import RequestUtils
+
+ffmpeg_lock = threading.Lock()
+lock = Lock()
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, watching_path: str, file_change: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = watching_path
+ self.file_change = file_change
+
+ def on_created(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path)
+
+
+class ShortPlayMonitor(_PluginBase):
+ # 插件名称
+ plugin_name = "短剧刮削"
+ # 插件描述
+ plugin_desc = "监控视频短剧创建,刮削。"
+ # 插件图标
+ plugin_icon = "Amule_B.png"
+ # 插件版本
+ plugin_version = "3.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "shortplaymonitor_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _monitor_confs = None
+ _onlyonce = False
+ _image = False
+ _exclude_keywords = ""
+ _transfer_type = "link"
+ _observer = []
+ _timeline = "00:00:10"
+ _dirconf = {}
+ _renameconf = {}
+ _coverconf = {}
+ tmdbchain = None
+ _interval = 10
+ _notify = False
+ _medias = {}
+
+ # 定时器
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ # 清空配置
+ self._dirconf = {}
+ self._renameconf = {}
+ self._coverconf = {}
+ self.tmdbchain = TmdbChain()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._image = config.get("image")
+ self._interval = config.get("interval")
+ self._notify = config.get("notify")
+ self._monitor_confs = config.get("monitor_confs")
+ self._exclude_keywords = config.get("exclude_keywords") or ""
+ self._transfer_type = config.get("transfer_type") or "link"
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ if self._notify:
+ # 追加入库消息统一发送服务
+ self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15)
+
+ # 读取目录配置
+ monitor_confs = self._monitor_confs.split("\n")
+ if not monitor_confs:
+ return
+ for monitor_conf in monitor_confs:
+ # 格式 监控方式#监控目录#目的目录#是否重命名#封面比例
+ if not monitor_conf:
+ continue
+ if str(monitor_conf).count("#") != 4:
+ logger.error(f"{monitor_conf} 格式错误")
+ continue
+ mode = str(monitor_conf).split("#")[0]
+ source_dir = str(monitor_conf).split("#")[1]
+ target_dir = str(monitor_conf).split("#")[2]
+ rename_conf = str(monitor_conf).split("#")[3]
+ cover_conf = str(monitor_conf).split("#")[4]
+
+ # 存储目录监控配置
+ self._dirconf[source_dir] = target_dir
+ self._renameconf[source_dir] = rename_conf
+ self._coverconf[source_dir] = cover_conf
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_dir and Path(target_dir).is_relative_to(Path(source_dir)):
+ logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ try:
+ if mode == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{source_dir} 的目录监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{source_dir} 启动目录监控失败:{err_msg}")
+ self.systemmessage.put(f"{source_dir} 启动目录监控失败:{err_msg}")
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("短剧监控服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.sync_all, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3),
+ name="短剧监控全量执行")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ if self._image:
+ self._image = False
+ self.__update_config()
+ self.__handle_image()
+
+ def sync_all(self):
+ """
+ 立即运行一次,全量同步目录中所有文件
+ """
+ logger.info("开始全量同步短剧监控目录 ...")
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ # 遍历目录下所有文件
+ for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT):
+ self.__handle_file(is_directory=Path(file_path).is_dir(),
+ event_path=str(file_path),
+ source_dir=mon_path)
+ logger.info("全量同步短剧监控目录完成!")
+
+ def __handle_image(self):
+ """
+ 立即运行一次,裁剪封面
+ """
+ if not self._dirconf or not self._dirconf.keys():
+ logger.error("未正确配置,停止裁剪 ...")
+ return
+
+ logger.info("开始全量裁剪封面 ...")
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ cover_conf = self._coverconf.get(mon_path)
+ target_path = self._dirconf.get(mon_path)
+ # 遍历目录下所有文件
+ for file_path in SystemUtils.list_files(Path(target_path), ["poster.jpg"]):
+ try:
+ if Path(file_path).name != "poster.jpg":
+ continue
+ image = Image.open(file_path)
+ if image.width / image.height != int(str(cover_conf).split(":")[0]) / int(
+ str(cover_conf).split(":")[1]):
+ self.__save_poster(input_path=file_path,
+ poster_path=file_path,
+ cover_conf=cover_conf)
+ logger.info(f"封面 {file_path} 已裁剪 比例为 {cover_conf}")
+ except Exception:
+ continue
+ logger.info("全量裁剪封面完成!")
+
+ def event_handler(self, event, source_dir: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param source_dir: 监控目录
+ :param event_path: 事件文件路径
+ """
+ # 回收站及隐藏的文件不处理
+ if (event_path.find("/@Recycle") != -1
+ or event_path.find("/#recycle") != -1
+ or event_path.find("/.") != -1
+ or event_path.find("/@eaDir") != -1):
+ logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理")
+ return
+
+ # 命中过滤关键字不处理
+ if self._exclude_keywords:
+ for keyword in self._exclude_keywords.split("\n"):
+ if keyword and re.findall(keyword, event_path):
+ logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
+ return
+
+ # 不是媒体文件不处理
+ if Path(event_path).suffix not in settings.RMT_MEDIAEXT:
+ logger.debug(f"{event_path} 不是媒体文件")
+ return
+
+ # 文件发生变化
+ logger.debug(f"变动类型 {event.event_type} 变动路径 {event_path}")
+ self.__handle_file(is_directory=event.is_directory,
+ event_path=event_path,
+ source_dir=source_dir)
+
+ def __handle_file(self, is_directory: bool, event_path: str, source_dir: str):
+ """
+ 同步一个文件
+ :event.is_directory
+ :param event_path: 事件文件路径
+ :param source_dir: 监控目录
+ """
+ try:
+ # 转移路径
+ dest_dir = self._dirconf.get(source_dir)
+ # 是否重命名
+ rename_conf = self._renameconf.get(source_dir)
+ # 封面比例
+ cover_conf = self._coverconf.get(source_dir)
+ # 元数据
+ file_meta = MetaInfoPath(Path(event_path))
+ if not file_meta.name:
+ logger.error(f"{Path(event_path).name} 无法识别有效信息")
+ return
+ # 识别媒体信息
+ mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta)
+
+ transfer_flag = False
+ title = None
+ # 走tmdb刮削
+ if mediainfo:
+ try:
+ # 更新媒体图片
+ self.chain.obtain_images(mediainfo=mediainfo)
+ episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
+ season=file_meta.begin_season or 1)
+ mediainfo.category = ""
+ # 转移
+ transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
+ path=Path(event_path),
+ transfer_type=self._transfer_type,
+ target=Path(dest_dir),
+ meta=file_meta,
+ episodes_info=episodes_info)
+ if not transferinfo:
+ logger.error("文件转移模块运行失败")
+ transfer_flag = False
+ else:
+ self.chain.scrape_metadata(path=transferinfo.target_path,
+ mediainfo=mediainfo,
+ transfer_type=self._transfer_type)
+ transfer_flag = True
+ except Exception as e:
+ print(str(e))
+ transfer_flag = False
+ logger.error(f"{event_path} tmdb刮削失败")
+ # 广播事件
+ # self.eventmanager.send_event(EventType.TransferComplete, {
+ # 'meta': file_meta,
+ # 'mediainfo': mediainfo,
+ # 'transferinfo': transferinfo
+ # })
+ if not transfer_flag:
+ target_path = event_path.replace(source_dir, dest_dir)
+
+ # 目录重命名
+ if str(rename_conf) == "true" or str(rename_conf) == "false":
+ rename_conf = bool(rename_conf)
+ target = target_path.replace(dest_dir, "")
+ parent = Path(Path(target).parents[0])
+ last = target.replace(str(parent), "")
+ if rename_conf:
+ # 自定义识别次
+ title, _ = WordsMatcher().prepare(parent)
+ target_path = Path(dest_dir).joinpath(title + last)
+ else:
+ title = parent
+ else:
+ if str(rename_conf) == "smart":
+ target = target_path.replace(dest_dir, "")
+ parent = Path(Path(target).parents[0])
+ last = target.replace(str(parent), "")
+ # 取.第一个
+ title = Path(parent).name.split(".")[0]
+ target_path = Path(dest_dir).joinpath(title + last)
+ else:
+ logger.error(f"{target_path} 智能重命名失败")
+ return
+
+ # 文件夹同步创建
+ if is_directory:
+ # 目标文件夹不存在则创建
+ if not Path(target_path).exists():
+ logger.info(f"创建目标文件夹 {target_path}")
+ os.makedirs(target_path)
+ else:
+ # 媒体重命名
+ try:
+ pattern = r'S\d+E\d+'
+ matches = re.search(pattern, Path(target_path).name)
+ if matches:
+ target_path = Path(
+ target_path).parent / f"{matches.group()}{Path(Path(target_path).name).suffix}"
+ else:
+ print("未找到匹配的季数和集数")
+ except Exception as e:
+ print(e)
+
+ # 目标文件夹不存在则创建
+ if not Path(target_path).parent.exists():
+ logger.info(f"创建目标文件夹 {Path(target_path).parent}")
+ os.makedirs(Path(target_path).parent)
+
+ # 文件:nfo、图片、视频文件
+ if Path(target_path).exists():
+ logger.debug(f"目标文件 {target_path} 已存在")
+ return
+
+ # 硬链接
+ retcode = self.__transfer_command(file_item=Path(event_path),
+ target_file=target_path,
+ transfer_type=self._transfer_type)
+ if retcode == 0:
+ logger.info(f"文件 {event_path} 硬链接完成")
+ # 生成 tvshow.nfo
+ if not (target_path.parent / "tvshow.nfo").exists():
+ self.__gen_tv_nfo_file(dir_path=target_path.parent,
+ title=title)
+
+ # 生成缩略图
+ if not (target_path.parent / "poster.jpg").exists():
+ thumb_path = self.gen_file_thumb(title=title,
+ rename_conf=rename_conf,
+ file_path=target_path)
+ if thumb_path and Path(thumb_path).exists():
+ self.__save_poster(input_path=thumb_path,
+ poster_path=target_path.parent / "poster.jpg",
+ cover_conf=cover_conf)
+ if (target_path.parent / "poster.jpg").exists():
+ logger.info(f"{target_path.parent / 'poster.jpg'} 缩略图已生成")
+ thumb_path.unlink()
+ else:
+ # 检查是否有缩略图
+ thumb_files = SystemUtils.list_files(directory=target_path.parent,
+ extensions=[".jpg"])
+ if thumb_files:
+ # 生成poster
+ for thumb in thumb_files:
+ self.__save_poster(input_path=thumb,
+ poster_path=target_path.parent / "poster.jpg",
+ cover_conf=cover_conf)
+ break
+ # 删除多余jpg
+ for thumb in thumb_files:
+ Path(thumb).unlink()
+ else:
+ logger.error(f"文件 {event_path} 硬链接失败,错误码:{retcode}")
+ if self._notify:
+ # 发送消息汇总
+ media_list = self._medias.get(mediainfo.title_year if mediainfo else title) or {}
+ if media_list:
+ media_files = media_list.get("files") or []
+ if media_files:
+ if str(event_path) not in media_files:
+ media_files.append(str(event_path))
+ else:
+ media_files = [str(event_path)]
+ media_list = {
+ "files": media_files,
+ "time": datetime.datetime.now()
+ }
+ else:
+ media_list = {
+ "files": [str(event_path)],
+ "time": datetime.datetime.now()
+ }
+ self._medias[mediainfo.title_year if mediainfo else title] = media_list
+ except Exception as e:
+ logger.error(f"event_handler_created error: {e}")
+ print(str(e))
+
+ def send_msg(self):
+ """
+ 定时检查是否有媒体处理完,发送统一消息
+ """
+ if self._notify:
+ if not self._medias or not self._medias.keys():
+ return
+
+ # 遍历检查是否已刮削完,发送消息
+ for medis_title_year in list(self._medias.keys()):
+ media_list = self._medias.get(medis_title_year)
+ logger.info(f"开始处理媒体 {medis_title_year} 消息")
+
+ if not media_list:
+ continue
+
+ # 获取最后更新时间
+ last_update_time = media_list.get("time")
+ media_files = media_list.get("files")
+ if not last_update_time or not media_files:
+ continue
+
+ # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息
+ if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval):
+ # 发送消息
+ self.post_message(mtype=NotificationType.Organize,
+ title=f"{medis_title_year} 共{len(media_files)}集已入库",
+ text="类别:短剧")
+ # 发送完消息,移出key
+ del self._medias[medis_title_year]
+ continue
+
+ @staticmethod
+ def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
+ """
+ 使用系统命令处理单个文件
+ :param file_item: 文件路径
+ :param target_file: 目标文件路径
+ :param transfer_type: RmtMode转移方式
+ """
+ with lock:
+
+ # 转移
+ if transfer_type == 'link':
+ # 硬链接
+ retcode, retmsg = SystemUtils.link(file_item, target_file)
+ elif transfer_type == 'filesoftlink':
+ # 软链接
+ retcode, retmsg = SystemUtils.softlink(file_item, target_file)
+ elif transfer_type == 'move':
+ # 移动
+ retcode, retmsg = SystemUtils.move(file_item, target_file)
+ elif transfer_type == 'rclone_move':
+ # Rclone 移动
+ retcode, retmsg = SystemUtils.rclone_move(file_item, target_file)
+ elif transfer_type == 'rclone_copy':
+ # Rclone 复制
+ retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file)
+ else:
+ # 复制
+ retcode, retmsg = SystemUtils.copy(file_item, target_file)
+
+ if retcode != 0:
+ logger.error(retmsg)
+
+ return retcode
+
+ def __save_poster(self, input_path, poster_path, cover_conf):
+ """
+ 截取图片做封面
+ """
+ try:
+ image = Image.open(input_path)
+
+ # 需要截取的长宽比(比如 16:9)
+ if not cover_conf:
+ target_ratio = 2 / 3
+ else:
+ covers = cover_conf.split(":")
+ target_ratio = int(covers[0]) / int(covers[1])
+
+ # 获取原始图片的长宽比
+ original_ratio = image.width / image.height
+
+ # 计算截取后的大小
+ if original_ratio > target_ratio:
+ new_height = image.height
+ new_width = int(new_height * target_ratio)
+ else:
+ new_width = image.width
+ new_height = int(new_width / target_ratio)
+
+ # 计算截取的位置
+ left = (image.width - new_width) // 2
+ top = (image.height - new_height) // 2
+ right = left + new_width
+ bottom = top + new_height
+
+ # 截取图片
+ cropped_image = image.crop((left, top, right, bottom))
+
+ # 保存截取后的图片
+ cropped_image.save(poster_path)
+ except Exception as e:
+ print(str(e))
+
+ def __gen_tv_nfo_file(self, dir_path: Path, title: str):
+ """
+ 生成电视剧的NFO描述文件
+ :param dir_path: 电视剧根目录
+ """
+ # 开始生成XML
+ logger.info(f"正在生成电视剧NFO文件:{dir_path.name}")
+ doc = minidom.Document()
+ root = DomUtils.add_node(doc, doc, "tvshow")
+
+ # 标题
+ DomUtils.add_node(doc, root, "title", title)
+ DomUtils.add_node(doc, root, "originaltitle", title)
+ DomUtils.add_node(doc, root, "season", "-1")
+ DomUtils.add_node(doc, root, "episode", "-1")
+ # 保存
+ self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo"))
+
+ def __save_nfo(self, doc, file_path: Path):
+ """
+ 保存NFO
+ """
+ xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
+ file_path.write_bytes(xml_str)
+ logger.info(f"NFO文件已保存:{file_path}")
+
+ def gen_file_thumb_from_site(self, title: str, file_path: Path):
+ """
+ 从agsv或者萝莉站查询封面
+ """
+ try:
+ image = None
+ # 查询索引
+ domain = "agsvpt.com"
+ site = SiteOper().get_by_domain(domain)
+ index = SitesHelper().get_indexer(domain)
+ if site:
+ req_url = f"https://www.agsvpt.com/torrents.php?search_mode=0&search_area=0&page=0¬newword=1&cat=419&search={title}"
+ image_xpath = "//*[@id='kdescr']/img[1]/@src"
+ # 查询站点资源
+ logger.info(f"开始检索 {site.name} {title}")
+ image = self.__get_site_torrents(url=req_url, site=site, image_xpath=image_xpath, index=index)
+ if not image:
+ domain = "ilolicon.com"
+ site = SiteOper().get_by_domain(domain)
+ index = SitesHelper().get_indexer(domain)
+ if site:
+ req_url = f"https://share.ilolicon.com/torrents.php?search_mode=0&search_area=0&page=0¬newword=1&cat=402&search={title}"
+
+ image_xpath = "//*[@id='kdescr']/img[1]/@src"
+ # 查询站点资源
+ logger.info(f"开始检索 {site.name} {title}")
+ image = self.__get_site_torrents(url=req_url, site=site, image_xpath=image_xpath, index=index)
+
+ if not image:
+ logger.error(f"检索站点 {title} 封面失败")
+ return None
+
+ # 下载图片保存
+ if self.__save_image(url=image, file_path=file_path):
+ return file_path
+ return None
+ except Exception as e:
+ logger.error(f"检索站点 {title} 封面失败 {str(e)}")
+ return None
+
+ @retry(RequestException, logger=logger)
+ def __save_image(self, url: str, file_path: Path):
+ """
+ 下载图片并保存
+ """
+ try:
+ logger.info(f"正在下载{file_path.stem}图片:{url} ...")
+ r = RequestUtils().get_res(url=url, raise_exception=True)
+ if r:
+ file_path.write_bytes(r.content)
+ logger.info(f"图片已保存:{file_path}")
+ return True
+ else:
+ logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
+ return False
+ except RequestException as err:
+ raise err
+ except Exception as err:
+ logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
+ return False
+
+ def __get_site_torrents(self, url: str, site, image_xpath, index):
+ """
+ 查询站点资源
+ """
+ page_source = self.__get_page_source(url=url, site=site)
+ if not page_source:
+ logger.error(f"请求站点 {site.name} 失败")
+ return None
+ _spider = TorrentSpider(indexer=index,
+ page=1)
+ torrents = _spider.parse(page_source)
+ if not torrents:
+ logger.error(f"未检索到站点 {site.name} 资源")
+ return None
+
+ # 获取种子详情页
+ torrent_detail_source = self.__get_page_source(url=torrents[0].get("page_url"), site=site)
+ if not torrent_detail_source:
+ logger.error(f"请求种子详情页失败 {torrents[0].get('page_url')}")
+ return None
+
+ html = etree.HTML(torrent_detail_source)
+ if not html:
+ logger.error(f"请求种子详情页失败 {torrents[0].get('page_url')}")
+ return None
+
+ image = html.xpath(image_xpath)[0]
+ if not image:
+ logger.error(f"未获取到种子封面图 {torrents[0].get('page_url')}")
+ return None
+
+ return str(image)
+
+ def __get_page_source(self, url: str, site):
+ """
+ 获取页面资源
+ """
+ ret = RequestUtils(
+ cookies=site.cookie,
+ timeout=30,
+ ).get_res(url, allow_redirects=True)
+ if ret is not None:
+ # 使用chardet检测字符编码
+ raw_data = ret.content
+ if raw_data:
+ try:
+ result = chardet.detect(raw_data)
+ encoding = result['encoding']
+ # 解码为字符串
+ page_source = raw_data.decode(encoding)
+ except Exception as e:
+ # 探测utf-8解码
+ if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE):
+ ret.encoding = "utf-8"
+ else:
+ ret.encoding = ret.apparent_encoding
+ page_source = ret.text
+ else:
+ page_source = ret.text
+ else:
+ page_source = ""
+
+ return page_source
+
+ def gen_file_thumb(self, title: str, file_path: Path, rename_conf: str):
+ """
+ 处理一个文件
+ """
+ # 智能重命名时从站点检索
+ if str(rename_conf) == "smart":
+ thumb_path = file_path.with_name(file_path.stem + "-site.jpg")
+ if thumb_path.exists():
+ logger.info(f"缩略图已存在:{thumb_path}")
+ return
+ self.gen_file_thumb_from_site(title=title, file_path=thumb_path)
+ if Path(thumb_path).exists():
+ logger.info(f"{file_path} 缩略图已生成:{thumb_path}")
+ return thumb_path
+ # 单线程处理
+ with ffmpeg_lock:
+ try:
+ thumb_path = file_path.with_name(file_path.stem + "-thumb.jpg")
+ if thumb_path.exists():
+ logger.info(f"缩略图已存在:{thumb_path}")
+ return
+ self.get_thumb(video_path=str(file_path),
+ image_path=str(thumb_path),
+ frames=self._timeline)
+ if Path(thumb_path).exists():
+ logger.info(f"{file_path} 缩略图已生成:{thumb_path}")
+ return thumb_path
+ except Exception as err:
+ logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}")
+ return None
+
+ @staticmethod
+ def get_thumb(video_path: str, image_path: str, frames: str = None):
+ """
+ 使用ffmpeg从视频文件中截取缩略图
+ """
+ if not frames:
+ frames = "00:00:10"
+ if not video_path or not image_path:
+ return False
+ cmd = 'ffmpeg -y -i "{video_path}" -ss {frames} -frames 1 "{image_path}"'.format(
+ video_path=video_path,
+ frames=frames,
+ image_path=image_path)
+ result = SystemUtils.execute(cmd)
+ if result:
+ return True
+ return False
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "exclude_keywords": self._exclude_keywords,
+ "transfer_type": self._transfer_type,
+ "onlyonce": self._onlyonce,
+ "interval": self._interval,
+ "notify": self._notify,
+ "image": self._image,
+ "monitor_confs": self._monitor_confs
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'image',
+ 'label': '封面裁剪',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'transfer_type',
+ 'label': '转移方式',
+ 'items': [
+ {'title': '移动', 'value': 'move'},
+ {'title': '复制', 'value': 'copy'},
+ {'title': '硬链接', 'value': 'link'},
+ {'title': '软链接', 'value': 'filesoftlink'},
+ {'title': 'Rclone复制', 'value': 'rclone_copy'},
+ {'title': 'Rclone移动', 'value': 'rclone_move'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'interval',
+ 'label': '入库消息延迟',
+ 'placeholder': '10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_confs',
+ 'label': '监控目录',
+ 'rows': 5,
+ 'placeholder': '监控方式#监控目录#目的目录#是否重命名#封面比例'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'exclude_keywords',
+ 'label': '排除关键词',
+ 'rows': 2,
+ 'placeholder': '每一行一个关键词'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '配置说明:'
+ 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/ShortPlayMonitor.md'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '默认从tmdb刮削,刮削失败则从pt站刮削。当重命名方式为smart时,如站点管理已配置AGSV、ilolicon,则优先从站点获取短剧封面。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '开启封面裁剪后,会把封面裁剪成配置的比例。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "image": False,
+ "notify": False,
+ "interval": 10,
+ "monitor_confs": "",
+ "exclude_keywords": "",
+ "transfer_type": "link"
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
+
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
diff --git a/plugins/siteunreadmsg/__init__.py b/plugins/siteunreadmsg/__init__.py
new file mode 100644
index 0000000..603fc12
--- /dev/null
+++ b/plugins/siteunreadmsg/__init__.py
@@ -0,0 +1,708 @@
+import re
+import time
+import warnings
+from datetime import datetime, timedelta
+from multiprocessing.dummy import Pool as ThreadPool
+from threading import Lock
+from typing import Optional, Any, List, Dict, Tuple
+
+import pytz
+import requests
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from ruamel.yaml import CommentedMap
+
+from app.core.config import settings
+from app.core.event import eventmanager
+from app.db.site_oper import SiteOper
+from app.helper.browser import PlaywrightHelper
+from app.helper.module import ModuleHelper
+from app.helper.sites import SitesHelper
+from app.log import logger
+from app.plugins import _PluginBase
+from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo
+from app.schemas.types import EventType, NotificationType
+from app.utils.http import RequestUtils
+
+warnings.filterwarnings("ignore", category=FutureWarning)
+
+lock = Lock()
+
+
+class SiteUnreadMsg(_PluginBase):
+ # 插件名称
+ plugin_name = "站点未读消息"
+ # 插件描述
+ plugin_desc = "发送站点未读消息。"
+ # 插件图标
+ plugin_icon = "Synomail_A.png"
+ # 插件版本
+ plugin_version = "1.9"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "siteunreadmsg_"
+ # 加载顺序
+ plugin_order = 1
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ sites = None
+ siteoper = None
+ _scheduler: Optional[BackgroundScheduler] = None
+ _history = []
+ _exits_key = []
+ _site_schema: List[ISiteUserInfo] = None
+
+ # 配置属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _cron: str = ""
+ _notify: bool = False
+ _queue_cnt: int = 5
+ _history_days: int = 30
+ _unread_sites: list = []
+
+ def init_plugin(self, config: dict = None):
+ self.sites = SitesHelper()
+ self.siteoper = SiteOper()
+ # 停止现有任务
+ self.stop_service()
+
+ # 配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._notify = config.get("notify")
+ self._queue_cnt = config.get("queue_cnt")
+ self._history_days = config.get("history_days") or 30
+ self._unread_sites = config.get("unread_sites") or []
+
+ # 过滤掉已删除的站点
+ all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
+ self._unread_sites = [site.get("id") for site in all_sites if
+ not site.get("public") and site.get("id") in self._unread_sites]
+ self.__update_config()
+
+ if self._enabled or self._onlyonce:
+ # 加载模块
+ self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo',
+ filter_func=lambda _, obj: hasattr(obj, 'schema'))
+
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ self._site_schema.sort(key=lambda x: x.order)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"站点未读消息服务启动,立即运行一次")
+ self._scheduler.add_job(self.refresh_all_site_unread_msg, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="站点未读消息")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.refresh_all_site_unread_msg,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="站点未读消息")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ """
+ 获取插件API
+ [{
+ "path": "/xx",
+ "endpoint": self.xxx,
+ "methods": ["GET", "POST"],
+ "summary": "API说明"
+ }]
+ """
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 站点的可选项(内置站点 + 自定义站点)
+ customSites = self.__custom_sites()
+
+ site_options = ([{"title": site.name, "value": site.id}
+ for site in self.siteoper.list_order_by_pri()]
+ + [{"title": site.get("name"), "value": site.get("id")}
+ for site in customSites])
+
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'queue_cnt',
+ 'label': '队列数量'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'history_days',
+ 'label': '保留历史天数'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'chips': True,
+ 'multiple': True,
+ 'model': 'unread_sites',
+ 'label': '未读消息站点',
+ 'items': site_options
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '依赖于[站点数据统计]插件,解析邮件失败请去[站点数据统计]插件仓库提交issue。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "notify": True,
+ "cron": "5 1 * * *",
+ "queue_cnt": 5,
+ "history_days": 30,
+ "unread_sites": []
+ }
+
+ def get_page(self) -> List[dict]:
+ """
+ 拼装插件详情页面,需要返回页面配置,同时附带数据
+ """
+ unread_data = self.get_data("history")
+ if not unread_data:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ # 数据按时间降序排序
+ unread_data = sorted(unread_data,
+ key=lambda item: item.get('time') or 0,
+ reverse=True)
+
+ # 站点数据明细
+ unread_msgs = [
+ {
+ 'component': 'tr',
+ 'props': {
+ 'class': 'text-sm'
+ },
+ 'content': [
+ {
+ 'component': 'td',
+ 'props': {
+ 'class': 'whitespace-nowrap break-keep text-high-emphasis'
+ },
+ 'text': data.get("site")
+ },
+ {
+ 'component': 'td',
+ 'text': data.get("head")
+ },
+ {
+ 'component': 'td',
+ 'text': data.get("content")
+ },
+ {
+ 'component': 'td',
+ 'text': data.get("time")
+ }
+ ]
+ } for data in unread_data
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTable',
+ 'props': {
+ 'hover': True
+ },
+ 'content': [
+ {
+ 'component': 'thead',
+ 'content': [
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '站点'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '标题'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '内容'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '时间'
+ },
+ ]
+ },
+ {
+ 'component': 'tbody',
+ 'content': unread_msgs
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
+
+ def __build_class(self, html_text: str) -> Any:
+ for site_schema in self._site_schema:
+ try:
+ if site_schema.match(html_text):
+ return site_schema
+ except Exception as e:
+ logger.error(f"站点匹配失败 {e}")
+ return None
+
+ def build(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]:
+ """
+ 构建站点信息
+ """
+ site_cookie = site_info.get("cookie")
+ if not site_cookie:
+ return None
+ site_name = site_info.get("name")
+ apikey = site_info.get("apikey")
+ token = site_info.get("token")
+ url = site_info.get("url")
+ proxy = site_info.get("proxy")
+ ua = site_info.get("ua")
+ # 会话管理
+ with requests.Session() as session:
+ proxies = settings.PROXY if proxy else None
+ proxy_server = settings.PROXY_SERVER if proxy else None
+ render = site_info.get("render")
+
+ logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}")
+ if render:
+ # 演染模式
+ html_text = PlaywrightHelper().get_page_source(url=url,
+ cookies=site_cookie,
+ ua=ua,
+ proxies=proxy_server)
+ else:
+ # 普通模式
+ res = RequestUtils(cookies=site_cookie,
+ session=session,
+ ua=ua,
+ proxies=proxies
+ ).get_res(url=url)
+ if res and res.status_code == 200:
+ if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
+ res.encoding = "utf-8"
+ else:
+ res.encoding = res.apparent_encoding
+ html_text = res.text
+ # 第一次登录反爬
+ if html_text.find("title") == -1:
+ i = html_text.find("window.location")
+ if i == -1:
+ return None
+ tmp_url = url + html_text[i:html_text.find(";")] \
+ .replace("\"", "") \
+ .replace("+", "") \
+ .replace(" ", "") \
+ .replace("window.location=", "")
+ res = RequestUtils(cookies=site_cookie,
+ session=session,
+ ua=ua,
+ proxies=proxies
+ ).get_res(url=tmp_url)
+ if res and res.status_code == 200:
+ if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
+ res.encoding = "UTF-8"
+ else:
+ res.encoding = res.apparent_encoding
+ html_text = res.text
+ if not html_text:
+ return None
+ elif res is not None:
+ logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code))
+ return None
+ else:
+ logger.error("站点 %s 无法访问:%s" % (site_name, url))
+ return None
+
+ # 兼容假首页情况,假首页通常没有 0:
+ logger.debug(f"开始解析站点 {site_name} 未读消息 {site_user_info.message_unread_contents}")
+ for head, date, content in site_user_info.message_unread_contents:
+ msg_title = f"【站点 {site_user_info.site_name} 消息】"
+ msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
+ # 防止同一消息重复发送
+ key = site_user_info.site_name + "_" + date + "_" + head + "_" + content
+ if key not in self._exits_key:
+ self._exits_key.append(key)
+ self.post_message(mtype=NotificationType.SiteMessage, title=msg_title, text=msg_text)
+ self._history.append({
+ "site": site_name,
+ "head": head,
+ "content": content,
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())),
+ "date": date,
+ })
+ else:
+ self.post_message(mtype=NotificationType.SiteMessage,
+ title=f"站点 {site_user_info.site_name} 收到 "
+ f"{site_user_info.message_unread} 条新消息,请登陆查看")
+
+ def refresh_all_site_unread_msg(self):
+ """
+ 多线程刷新站点未读消息
+ """
+ if not self.sites.get_indexers():
+ return
+
+ logger.info("开始刷新站点未读消息 ...")
+
+ with lock:
+ all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
+ # 没有指定站点,默认使用全部站点
+ if not self._unread_sites:
+ refresh_sites = all_sites
+ else:
+ refresh_sites = [site for site in all_sites if
+ site.get("id") in self._unread_sites]
+ if not refresh_sites:
+ return
+
+ self._history = self.get_data("history") or []
+ # 并发刷新
+ with ThreadPool(min(len(refresh_sites), int(self._queue_cnt or 5))) as p:
+ p.map(self.__refresh_site_data, refresh_sites)
+
+ if self._history:
+ thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
+ self._history = [record for record in self._history if
+ datetime.strptime(record["time"], '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
+
+ # 保存数据
+ self.save_data("history", self._history)
+
+ logger.info("站点未读消息刷新完成")
+
+ def __custom_sites(self) -> List[Any]:
+ custom_sites = []
+ custom_sites_config = self.get_config("CustomSites")
+ if custom_sites_config and custom_sites_config.get("enabled"):
+ custom_sites = custom_sites_config.get("sites")
+ return custom_sites
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "notify": self._notify,
+ "queue_cnt": self._queue_cnt,
+ "history_days": self._history_days,
+ "unread_sites": self._unread_sites,
+ })
+
+ @eventmanager.register(EventType.SiteDeleted)
+ def site_deleted(self, event):
+ """
+ 删除对应站点选中
+ """
+ site_id = event.event_data.get("site_id")
+ config = self.get_config()
+ if config:
+ unread_sites = config.get("unread_sites")
+ if unread_sites:
+ if isinstance(unread_sites, str):
+ unread_sites = [unread_sites]
+
+ # 删除对应站点
+ if site_id:
+ unread_sites = [site for site in unread_sites if int(site) != int(site_id)]
+ else:
+ # 清空
+ unread_sites = []
+
+ # 若无站点,则停止
+ if len(unread_sites) == 0:
+ self._enabled = False
+
+ self._unread_sites = unread_sites
+ # 保存配置
+ self.__update_config()
diff --git a/plugins/softlinkredirect/__init__.py b/plugins/softlinkredirect/__init__.py
new file mode 100644
index 0000000..f1e982b
--- /dev/null
+++ b/plugins/softlinkredirect/__init__.py
@@ -0,0 +1,212 @@
+import os
+from typing import List, Tuple, Dict, Any
+from app.log import logger
+from app.plugins import _PluginBase
+
+
+class SoftLinkRedirect(_PluginBase):
+ # 插件名称
+ plugin_name = "软连接重定向"
+ # 插件描述
+ plugin_desc = "重定向软连接指向。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlinkredirect.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "softlinkredirect_"
+ # 加载顺序
+ plugin_order = 9
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _onlyonce = False
+ _soft_path = None
+ _origin_path = None
+ _redirect_path = None
+
+ def init_plugin(self, config: dict = None):
+ # 读取配置
+ if config:
+ self._onlyonce = config.get("onlyonce")
+ self._soft_path = config.get("soft_path")
+ self._origin_path = config.get("origin_path")
+ self._redirect_path = config.get("redirect_path")
+
+ if self._onlyonce and self._soft_path and self._origin_path and self._redirect_path:
+ logger.info(f"{self._soft_path} 软连接重定向开始 {self._origin_path} - {self._redirect_path}")
+ self.update_symlink(self._origin_path, self._redirect_path, self._soft_path)
+ logger.info(f"{self._soft_path} 软连接重定向完成")
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": self._onlyonce,
+ "soft_path": self._soft_path,
+ "origin_path": self._origin_path,
+ "redirect_path": self._redirect_path
+ })
+
+ @staticmethod
+ def update_symlink(target_from, target_to, directory):
+ for root, dirs, files in os.walk(directory):
+ for name in dirs + files:
+ file_path = os.path.join(root, name)
+ if os.path.islink(file_path):
+ current_target = os.readlink(file_path)
+ if str(current_target).startswith(target_from):
+ new_target = current_target.replace(target_from, target_to)
+ os.remove(file_path)
+ os.symlink(new_target, file_path)
+ print(f"Updated symlink: {file_path} -> {new_target}")
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_service(self) -> List[Dict[str, Any]]:
+ """
+ 注册插件公共服务
+ [{
+ "id": "服务ID",
+ "name": "服务名称",
+ "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
+ "func": self.xxx,
+ "kwargs": {} # 定时器参数
+ }]
+ """
+ return []
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'soft_path',
+ 'label': '软连接路径',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'origin_path',
+ 'label': '原来源文件路径',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'redirect_path',
+ 'label': '重定向源文件路径',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '软连接指向由A路径改为B路径'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "onlyonce": False,
+ "soft_path": "",
+ "origin_path": "",
+ "redirect_path": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def get_state(self):
+ return False
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/sqlexecute/__init__.py b/plugins/sqlexecute/__init__.py
new file mode 100644
index 0000000..d544aa5
--- /dev/null
+++ b/plugins/sqlexecute/__init__.py
@@ -0,0 +1,279 @@
+import sqlite3
+
+from app.core.event import eventmanager, Event
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.schemas.types import EventType, MessageChannel
+
+
+class SqlExecute(_PluginBase):
+ # 插件名称
+ plugin_name = "Sql执行器"
+ # 插件描述
+ plugin_desc = "自定义MoviePilot数据库Sql执行。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/sqlite.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "sqlexecute_"
+ # 加载顺序
+ plugin_order = 99
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _onlyonce = None
+ _sql = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._onlyonce = config.get("onlyonce")
+ self._sql = config.get("sql")
+
+ if self._onlyonce and self._sql:
+ # 读取sqlite数据
+ try:
+ gradedb = sqlite3.connect("/config/user.db")
+ except Exception as e:
+ logger.error(f"数据库链接失败 {str(e)}")
+ return
+
+ # 创建游标cursor来执行executeSQL语句
+ cursor = gradedb.cursor()
+
+ # 执行SQL语句
+ try:
+ for sql in self._sql.split("\n"):
+ logger.info(f"开始执行SQL语句 {sql}")
+ # 执行SQL语句
+ cursor.execute(sql)
+
+ rows = cursor.fetchall()
+ if 'select' in sql.lower():
+ # 获取列名
+ columns = [desc[0] for desc in cursor.description]
+ # 将查询结果转换为key-value对的列表
+ results = []
+ for row in rows:
+ result = dict(zip(columns, row))
+ results.append(result)
+ result = "\n".join([str(i) for i in results])
+ else:
+ result = "\n".join([str(i) for i in rows])
+
+ result = str(result).replace("'", "\"")
+ logger.info(result)
+ except Exception as e:
+ logger.error(f"SQL语句执行失败 {str(e)}")
+ return
+ finally:
+ # 关闭游标
+ cursor.close()
+
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": self._onlyonce,
+ "sql": self._sql
+ })
+
+ @eventmanager.register(EventType.PluginAction)
+ def execute(self, event: Event = None):
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "sql_execute":
+ return
+ args = event_data.get("args")
+ if not args:
+ return
+
+ logger.info(f"收到命令,开始执行SQL ...{args}")
+
+ # 读取sqlite数据
+ try:
+ gradedb = sqlite3.connect("/config/user.db")
+ except Exception as e:
+ logger.error(f"数据库链接失败 {str(e)}")
+ return
+
+ # 创建游标cursor来执行executeSQL语句
+ cursor = gradedb.cursor()
+
+ # 执行SQL语句
+ try:
+ # 执行SQL语句
+ cursor.execute(args)
+ rows = cursor.fetchall()
+ if 'select' in args.lower():
+ # 获取列名
+ columns = [desc[0] for desc in cursor.description]
+ # 将查询结果转换为key-value对的列表
+ results = []
+ for row in rows:
+ result = dict(zip(columns, row))
+ results.append(result)
+ result = "\n".join([str(i) for i in results])
+ else:
+ result = "\n".join([str(i) for i in rows])
+
+ result = str(result).replace("'", "\"")
+ logger.info(result)
+
+ if event.event_data.get("channel") == MessageChannel.Telegram:
+ result = f"```plaintext\n{result}\n```"
+ self.post_message(channel=event.event_data.get("channel"),
+ title="SQL执行结果",
+ text=result,
+ userid=event.event_data.get("user"))
+ except Exception as e:
+ logger.error(f"SQL语句执行失败 {str(e)}")
+ return
+ finally:
+ # 关闭游标
+ cursor.close()
+
+ def get_state(self) -> bool:
+ return True
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [{
+ "cmd": "/sql",
+ "event": EventType.PluginAction,
+ "desc": "自定义sql执行",
+ "category": "",
+ "data": {
+ "action": "sql_execute"
+ }
+ }]
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '执行sql'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'sql',
+ 'rows': '2',
+ 'label': 'sql语句',
+ 'placeholder': '一行一条'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '执行日志将会输出到控制台,请谨慎操作。'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '可使用交互命令/sql select *****'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "onlyonce": False,
+ "sql": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/strmconvert/__init__.py b/plugins/strmconvert/__init__.py
new file mode 100644
index 0000000..216b4bf
--- /dev/null
+++ b/plugins/strmconvert/__init__.py
@@ -0,0 +1,320 @@
+import re
+import urllib.parse
+from pathlib import Path
+
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+
+
+class StrmConvert(_PluginBase):
+ # 插件名称
+ plugin_name = "Strm文件模式转换"
+ # 插件描述
+ plugin_desc = "Strm文件内容转为本地路径或者cd2/alist API路径。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/convert.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "strmconvert_"
+ # 加载顺序
+ plugin_order = 27
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _to_local = False
+ _to_api = False
+ _convert_confs = None
+ _library_path = None
+ _api_url = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._to_local = config.get("to_local")
+ self._to_api = config.get("to_api")
+ self._convert_confs = config.get("convert_confs")
+
+ if self._to_local and self._to_api:
+ logger.error(f"本地模式和API模式同时只能开启一个")
+ return
+
+ convert_confs = self._convert_confs.split("\n")
+ if not convert_confs:
+ return
+
+ self.update_config({
+ "to_local": False,
+ "to_api": False,
+ "convert_confs": self._convert_confs
+ })
+
+ if self._to_local:
+ self.__convert_to_local(convert_confs)
+
+ if self._to_api:
+ self.__convert_to_api(convert_confs)
+
+ def __convert_to_local(self, convert_confs: list):
+ """
+ 转为本地模式
+ """
+ for convert_conf in convert_confs:
+ if str(convert_conf).count("#") != 1:
+ logger.error(f"转换配置 {convert_conf} 格式错误,已跳过处理")
+ continue
+ source_path = str(convert_conf).split("#")[0]
+ library_path = str(convert_conf).split("#")[1]
+ logger.info(f"{source_path} 开始转为本地模式")
+ self.__to_local(source_path, library_path)
+ logger.info(f"{source_path} 转换本地模式已结束")
+
+ def __to_local(self, source_path: str, library_path: str):
+ files = self.__list_files(Path(source_path), ['.strm'])
+ for f in files:
+ logger.debug(f"开始处理文件 {f}")
+ try:
+ with open(f, 'r') as file:
+ content = file.read()
+ # 获取扩展名
+ ext = str(content).split(".")[-1]
+ library_file = str(f).replace(source_path, library_path)
+ library_file = Path(library_file).parent.joinpath(Path(library_file).stem + "." + ext)
+ with open(f, 'w') as file2:
+ logger.debug(f"开始写入 媒体库路径 {library_file}")
+ file2.write(str(library_file))
+ except Exception as e:
+ print(e)
+
+ def __convert_to_api(self, convert_confs: list):
+ """
+ 转为api模式
+ """
+ for convert_conf in convert_confs:
+ if str(convert_conf).count("#") != 3:
+ logger.error(f"转换配置 {convert_conf} 格式错误,已跳过处理")
+ continue
+ source_path = str(convert_conf).split("#")[0]
+ library_path = str(convert_conf).split("#")[1]
+ cloud_type = str(convert_conf).split("#")[2]
+ cloud_url = str(convert_conf).split("#")[3]
+ logger.info(f"{source_path} 开始转为API模式")
+ self.__to_api(source_path, library_path, cloud_type, cloud_url)
+ logger.info(f"{source_path} 转换本地模式已结束")
+
+ def __to_api(self, source_path: str, library_path: str, cloud_type: str, cloud_url: str):
+ files = self.__list_files(Path(source_path), ['.strm'])
+ for f in files:
+ logger.debug(f"开始处理文件 {f}")
+ try:
+ library_file = str(f).replace(source_path, library_path)
+ # 对盘符之后的所有内容进行url转码
+ library_file = urllib.parse.quote(library_file, safe='')
+
+ if str(cloud_type) == "cd2":
+ # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/"
+ # http://192.168.31.103:19798/static/http/192.168.31.103:19798/False/%2F115%2Femby%2Fanime%2F%20%E4%B8%83%E9%BE%99%E7%8F%A0%20%281986%29%2FSeason%201.%E5%9B%BD%E8%AF%AD%2F%E4%B8%83%E9%BE%99%E7%8F%A0%20-%20S01E002%20-%201080p%20AAC%20h264.mp4
+ api_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{library_file}"
+ else:
+ api_file = f"http://{cloud_url}/d/{library_file}"
+ with open(f, 'w') as file2:
+ logger.debug(f"开始写入 api路径 {api_file}")
+ file2.write(str(api_file))
+ except Exception as e:
+ print(e)
+
+ @staticmethod
+ def __list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]:
+ """
+ 获取目录下所有指定扩展名的文件(包括子目录)
+ """
+ if not min_filesize:
+ min_filesize = 0
+
+ if not directory.exists():
+ return []
+
+ if directory.is_file():
+ return [directory]
+
+ if not min_filesize:
+ min_filesize = 0
+
+ files = []
+ pattern = r".*(" + "|".join(extensions) + ")$"
+
+ # 遍历目录及子目录
+ for path in directory.rglob('**/*'):
+ if path.is_file() \
+ and re.match(pattern, path.name, re.IGNORECASE) \
+ and path.stat().st_size >= min_filesize * 1024 * 1024:
+ files.append(path)
+
+ return files
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'to_local',
+ 'label': '转为本地模式',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'to_api',
+ 'label': '转为API模式',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'convert_confs',
+ 'label': '转换配置',
+ 'rows': 3,
+ 'placeholder': 'strm文件根路径#转换路径'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '转换配置(转为本地模式):'
+ 'strm文件根路径#转换路径。'
+ '转换路径为源文件挂载进媒体服务器的路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '转换配置(转为API模式):'
+ 'strm文件根路径#转换路径#cd2/alist#cd2/alist服务地址(ip:port)。'
+ '转换路径为云盘根路径。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '配置说明:'
+ 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/StrmConvert.md'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "to_local": False,
+ "to_api": False,
+ "convert_confs": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/subscribeclear/__init__.py b/plugins/subscribeclear/__init__.py
new file mode 100644
index 0000000..89d834c
--- /dev/null
+++ b/plugins/subscribeclear/__init__.py
@@ -0,0 +1,122 @@
+from app.plugins import _PluginBase
+from app.db.subscribe_oper import SubscribeOper
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+
+
+class SubscribeClear(_PluginBase):
+ # 插件名称
+ plugin_name = "清理订阅缓存"
+ # 插件描述
+ plugin_desc = "清理订阅已下载集数。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/broom.png"
+ # 插件版本
+ plugin_version = "1.0"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "subscribeclear_"
+ # 加载顺序
+ plugin_order = 28
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 任务执行间隔
+ _subscribe_ids = None
+ subscribe = None
+
+ def init_plugin(self, config: dict = None):
+ self.subscribe = SubscribeOper()
+ if config:
+ self._subscribe_ids = config.get("subscribe_ids")
+ if self._subscribe_ids:
+ # 遍历 清理订阅下载缓存
+ for subscribe_id in self._subscribe_ids:
+ self.subscribe.update(subscribe_id, {'note': ""})
+ logger.info(f"订阅 {subscribe_id} 下载缓存已清理")
+
+ self.update_config(
+ {
+ "subscribe_ids": []
+ }
+ )
+
+ def get_state(self) -> bool:
+ return False
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ subscribe_options = [{"title": subscribe.name, "value": subscribe.id} for subscribe in
+ self.subscribe.list('R') if subscribe.type == '电视剧']
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'chips': True,
+ 'multiple': True,
+ 'model': 'subscribe_ids',
+ 'label': '电视剧订阅',
+ 'items': subscribe_options
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '请选择需要清理缓存的订阅,用于清理该订阅已下载集数。'
+ '注意!!!未入库的会被重新下载。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "subscribe_ids": []
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/subscribegroup/__init__.py b/plugins/subscribegroup/__init__.py
new file mode 100644
index 0000000..d9d4632
--- /dev/null
+++ b/plugins/subscribegroup/__init__.py
@@ -0,0 +1,755 @@
+import json
+import re
+import time
+
+from app.db.downloadhistory_oper import DownloadHistoryOper
+from app.db.subscribe_oper import SubscribeOper
+from app.db.site_oper import SiteOper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.core.event import eventmanager, Event
+from app.schemas.types import EventType, SystemConfigKey
+
+
+class SubscribeGroup(_PluginBase):
+ # 插件名称
+ plugin_name = "订阅规则自动填充"
+ # 插件描述
+ plugin_desc = "电视剧下载后自动添加官组等信息到订阅;添加订阅后根据二级分类名称自定义订阅规则。"
+ # 插件图标
+ plugin_icon = "teamwork.png"
+ # 插件版本
+ plugin_version = "2.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "subscribegroup_"
+ # 加载顺序
+ plugin_order = 26
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _enabled: bool = False
+ _category: bool = False
+ _clear = False
+ _clear_handle = False
+ _update_details = []
+ _update_confs = None
+ _subscribe_confs = {}
+ _subscribeoper = None
+ _downloadhistoryoper = None
+ _siteoper = None
+
+ def init_plugin(self, config: dict = None):
+ self._downloadhistoryoper = DownloadHistoryOper()
+ self._subscribeoper = SubscribeOper()
+ self._siteoper = SiteOper()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._category = config.get("category")
+ self._clear = config.get("clear")
+ self._clear_handle = config.get("clear_handle")
+ self._update_details = config.get("update_details") or []
+ self._update_confs = config.get("update_confs")
+
+ if self._update_confs:
+ active_sites = self._siteoper.list_active()
+ for confs in str(self._update_confs).split("\n"):
+ category = None
+ resolution = None
+ quality = None
+ effect = None
+ include = None
+ exclude = None
+ savepath = None
+ sites = []
+ for conf in str(confs).split("#"):
+ if ":" in conf:
+ k = conf.split(":")[0]
+ v = ":".join(conf.split(":")[1:])
+ if k == "category":
+ category = v
+ if k == "resolution":
+ resolution = v
+ if k == "quality":
+ quality = v
+ if k == "effect":
+ effect = v
+ if k == "include":
+ include = v
+ if k == "exclude":
+ exclude = v
+ if k == "savepath":
+ savepath = v
+ if k == "sites":
+ for site_name in str(v).split(","):
+ for active_site in active_sites:
+ if str(site_name) == str(active_site.name):
+ sites.append(active_site.id)
+ break
+ if category:
+ for c in str(category).split(","):
+ self._subscribe_confs[c] = {
+ 'resolution': resolution,
+ 'quality': quality,
+ 'effect': effect,
+ 'include': include,
+ 'exclude': exclude,
+ 'savepath': savepath,
+ 'sites': sites
+ }
+ logger.info(f"获取到二级分类自定义配置 {len(self._subscribe_confs.keys())} 个")
+ else:
+ self._subscribe_confs = {}
+
+ # 清理已处理历史
+ if self._clear_handle:
+ self.del_data(key="history_handle")
+
+ self._clear_handle = False
+ self.__update_config()
+ logger.info("已处理历史清理完成")
+
+ # 清理历史记录
+ if self._clear:
+ self.del_data(key="history")
+
+ self._clear = False
+ self.__update_config()
+ logger.info("历史记录清理完成")
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "category": self._category,
+ "clear": self._clear,
+ "clear_handle": self._clear_handle,
+ "update_details": self._update_details,
+ "update_confs": self._update_confs,
+ })
+
+ @eventmanager.register(EventType.SubscribeAdded)
+ def subscribe_notice(self, event: Event = None):
+ """
+ 添加订阅根据二级分类填充订阅
+ """
+ if not event:
+ logger.error("订阅事件数据为空")
+ return
+
+ if not self._category:
+ logger.error("二级分类自定义填充未开启")
+ return
+
+ if len(self._subscribe_confs.keys()) == 0:
+ logger.error("插件未开启二级分类自定义填充")
+ return
+
+ if event:
+ event_data = event.event_data
+ if not event_data or not event_data.get("subscribe_id") or not event_data.get("mediainfo"):
+ logger.error(f"订阅事件数据不完整 {event_data}")
+ return
+
+ sid = event_data.get("subscribe_id")
+ category = event_data.get("mediainfo").get("category")
+ if not category:
+ logger.error(f"订阅ID:{sid} 未获取到二级分类")
+ return
+
+ if category not in self._subscribe_confs.keys():
+ logger.error(f"订阅ID:{sid} 二级分类:{category} 未配置自定义规则")
+ return
+
+ # 查询订阅
+ subscribe = self._subscribeoper.get(sid)
+
+ # 二级分类自定义配置
+ category_conf = self._subscribe_confs.get(category)
+
+ update_dict = {}
+ if category_conf.get('include'):
+ update_dict['include'] = category_conf.get('include')
+ if category_conf.get('exclude'):
+ update_dict['exclude'] = category_conf.get('exclude')
+ if category_conf.get('sites'):
+ update_dict['sites'] = json.dumps(category_conf.get('sites'))
+ if category_conf.get('resolution'):
+ update_dict['resolution'] = self.__parse_pix(category_conf.get('resolution'))
+ if category_conf.get('quality'):
+ update_dict['quality'] = self.__parse_type(category_conf.get('quality'))
+ if category_conf.get('effect'):
+ update_dict['effect'] = self.__parse_effect(category_conf.get('effect'))
+ if category_conf.get('savepath'):
+ # 判断是否有变量{name}
+ if '{name}' in category_conf.get('savepath'):
+ savepath = category_conf.get('savepath').replace('{name}', f"{subscribe.name} ({subscribe.year})")
+ update_dict['save_path'] = savepath
+ else:
+ update_dict['save_path'] = category_conf.get('savepath')
+
+ # 更新订阅自定义配置
+ self._subscribeoper.update(sid, update_dict)
+ logger.info(f"订阅记录:{subscribe.name} 填充成功\n{update_dict}")
+
+ # 读取历史记录
+ history = self.get_data('history') or []
+
+ history.append({
+ 'name': subscribe.name,
+ 'type': f'二级分类自定义配置 {category}',
+ 'content': json.dumps(update_dict),
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+ # 保存历史
+ self.save_data(key="history", value=history)
+
+ @eventmanager.register(EventType.DownloadAdded)
+ def download_notice(self, event: Event = None):
+ """
+ 添加下载填充订阅制作组等信息
+ """
+ if not event:
+ logger.error("下载事件数据为空")
+ return
+
+ if not self._enabled:
+ logger.error("种子下载自定义填充未开启")
+ return
+
+ if len(self._update_details) == 0:
+ logger.error("插件未开启更新填充内容")
+ return
+
+ if event:
+ event_data = event.event_data
+ if not event_data or not event_data.get("hash") or not event_data.get("context"):
+ logger.error(f"下载事件数据不完整 {event_data}")
+ return
+ download_hash = event_data.get("hash")
+ # 根据hash查询下载记录
+ download_history = self._downloadhistoryoper.get_by_hash(download_hash)
+ if not download_history:
+ logger.warning(f"种子hash:{download_hash} 对应下载记录不存在")
+ return
+
+ history_handle: List[str] = self.get_data('history_handle') or []
+
+ if f"{download_history.type}:{download_history.tmdbid}" in history_handle:
+ logger.warning(f"下载历史:{download_history.title} 已处理过,不再重复处理")
+ return
+
+ if download_history.type != '电视剧':
+ logger.warning(f"下载历史:{download_history.title} 不是电视剧,不进行官组填充")
+ return
+
+ # 根据下载历史查询订阅记录
+ subscribes = self._subscribeoper.list_by_tmdbid(tmdbid=download_history.tmdbid,
+ season=int(download_history.seasons.replace('S', ''))
+ if download_history.seasons and
+ download_history.seasons.count('-') == 0 else None)
+ if not subscribes or len(subscribes) == 0:
+ logger.warning(f"下载历史:{download_history.title} tmdbid:{download_history.tmdbid} 对应订阅记录不存在")
+ return
+
+ logger.info(
+ f"获取到tmdbid {download_history.tmdbid} season {int(download_history.seasons.replace('S', '')) if download_history.seasons and download_history.seasons.count('-') == 0 else None} 订阅记录:{len(subscribes)} 个")
+
+ for subscribe in subscribes:
+ if subscribe.type != '电视剧':
+ logger.warning(f"订阅记录:{subscribe.name} 不是电视剧,不进行官组填充")
+ continue
+
+ # 开始填充官组和站点
+ context = event_data.get("context")
+ _torrent = context.torrent_info
+ _meta = context.meta_info
+
+ # 填充数据
+ update_dict = {}
+ # 分辨率
+ if "分辨率" in self._update_details and not subscribe.resolution:
+ resource_pix = _meta.resource_pix if _meta else None
+ if resource_pix:
+ resource_pix = self.__parse_pix(resource_pix)
+ if resource_pix:
+ update_dict['resolution'] = resource_pix
+ else:
+ logger.warning(f"订阅记录:{subscribe.name} 未获取到分辨率信息")
+ # 资源质量
+ if "资源质量" in self._update_details and not subscribe.quality:
+ resource_type = _meta.resource_type if _meta else None
+ if resource_type:
+ resource_type = self.__parse_type(resource_type)
+ if resource_type:
+ update_dict['quality'] = resource_type
+ else:
+ logger.warning(f"订阅记录:{subscribe.name} 未获取到资源质量信息")
+ # 特效
+ if "特效" in self._update_details and not subscribe.effect:
+ resource_effect = _meta.resource_effect if _meta else None
+ if resource_effect:
+ resource_effect = self.__parse_effect(resource_effect)
+ if resource_effect:
+ update_dict['effect'] = resource_effect
+ else:
+ logger.warning(f"订阅记录:{subscribe.name} 未获取到特效信息")
+ # 制作组
+ if "制作组" in self._update_details and not subscribe.include:
+ # 官组
+ resource_team = _meta.resource_team if _meta else None
+ customization = _meta.customization if _meta else None
+ if resource_team and customization:
+ resource_team = f"{customization}.+{resource_team}"
+ if not resource_team and customization:
+ resource_team = customization
+ if resource_team:
+ update_dict['include'] = resource_team
+ # 站点
+ if "站点" in self._update_details and (
+ not subscribe.sites or (subscribe.sites and len(json.loads(subscribe.sites)) == 0)):
+ # 站点 判断是否在订阅站点范围内
+ rss_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
+ if _torrent and _torrent.site and int(_torrent.site) in rss_sites:
+ sites = json.dumps([_torrent.site])
+ update_dict['sites'] = sites
+
+ if len(update_dict.keys()) == 0:
+ logger.info(f"订阅记录:{subscribe.name} 无需填充")
+ continue
+
+ # 更新订阅记录
+ self._subscribeoper.update(subscribe.id, update_dict)
+ logger.info(f"订阅记录:{subscribe.name} 填充成功\n {update_dict}")
+
+ # 读取历史记录
+ history = self.get_data('history') or []
+ history.append({
+ 'name': subscribe.name,
+ 'type': '种子下载自定义配置',
+ 'content': json.dumps(update_dict),
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+ # 保存历史
+ self.save_data(key="history", value=history)
+
+ # 保存已处理历史
+ history_handle.append(f"{download_history.type}:{download_history.tmdbid}")
+ self.save_data('history_handle', history_handle)
+
+ def __parse_pix(self, resource_pix):
+ # 识别1080或者4k或720
+ if re.match(r"1080[pi]|x1080", resource_pix):
+ resource_pix = "1080[pi]|x1080"
+ if re.match(r"4K|2160p|x2160", resource_pix):
+ resource_pix = "4K|2160p|x2160"
+ if re.match(r"720[pi]|x720", resource_pix):
+ resource_pix = "720[pi]|x720"
+ return resource_pix
+
+ def __parse_type(self, resource_type):
+ if re.match(r"Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD", resource_type):
+ resource_type = "Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD"
+ if re.match(r"Remux", resource_type):
+ resource_type = "Remux"
+ if re.match(r"Blu-?Ray", resource_type):
+ resource_type = "Blu-?Ray"
+ if re.match(r"UHD|UltraHD", resource_type):
+ resource_type = "UHD|UltraHD"
+ if re.match(r"WEB-?DL|WEB-?RIP", resource_type):
+ resource_type = "WEB-?DL|WEB-?RIP"
+ if re.match(r"HDTV", resource_type):
+ resource_type = "HDTV"
+ if re.match(r"[Hx].?265|HEVC", resource_type):
+ resource_type = "[Hx].?265|HEVC"
+ if re.match(r"[Hx].?264|AVC", resource_type):
+ resource_type = "[Hx].?264|AVC"
+ return resource_type
+
+ def __parse_effect(self, resource_effect):
+ if re.match(r"Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+", resource_effect):
+ resource_effect = "Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+"
+ if re.match(r"Dolby[\\s.]*\\+?Atmos|Atmos", resource_effect):
+ resource_effect = "Dolby[\\s.]*\\+?Atmos|Atmos"
+ if re.match(r"[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+", resource_effect):
+ resource_effect = "[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+"
+ if re.match(r"[\\s.]+SDR[\\s.]+", resource_effect):
+ resource_effect = "[\\s.]+SDR[\\s.]+"
+ return resource_effect
+
+ def get_state(self) -> bool:
+ return self._enabled or self._category
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '种子下载自定义填充',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'category',
+ 'label': '二级分类自定义填充',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear',
+ 'label': '清理历史记录',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear_handle',
+ 'label': '清理已处理记录',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'update_details',
+ 'label': '种子下载填充内容',
+ 'items': [
+ {
+ "title": "资源质量",
+ "vale": "资源质量"
+ },
+ {
+ "title": "分辨率",
+ "vale": "分辨率"
+ },
+ {
+ "title": "特效",
+ "vale": "特效"
+ },
+ {
+ "title": "制作组",
+ "vale": "制作组"
+ },
+ {
+ "title": "站点",
+ "vale": "站点"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'update_confs',
+ 'label': '二级分类自定义填充',
+ 'rows': 3,
+ 'placeholder': 'category:日番#include:.*(CR.*简繁|简繁英).RLWeb|ADWeb.#sites:观众,红叶PT\n'
+ 'category:港台剧,日韩剧#include:国粤'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'error',
+ 'variant': 'tonal',
+ 'text': '种子下载自定义填充:需要下载种子才会填充订阅属性,且不会覆盖原有属性!'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '电视剧订阅未配置包含关键词、订阅站点等配置时,订阅或搜索下载后,'
+ '将下载种子的制作组、站点等信息填充到订阅信息中,以保证后续订阅资源的统一性。'
+ '(订阅新出的电视剧效果更佳。)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'error',
+ 'variant': 'tonal',
+ 'text': '二级分类自定义填充:添加订阅才会填充订阅属性,会强制覆盖!用于根据二级分类自定义订阅规则,具体属性明细请查看电视剧订阅设置页面。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': 'category:二级分类名称(多个分类名称逗号拼接),resolution:分辨率,quality:质量,effect:特效,include:包含关键词,'
+ 'exclude:排除关键词,sites:站点名称(多个站点用逗号拼接),savepath:保存路径/{name}({name}为当前订阅的名称和年份)。'
+ 'category必填,多组属性用#分割。例如category:动漫#resolution:1080p'
+ '(添加的动漫订阅,指定分辨率为1080p)。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "category": False,
+ "clear": False,
+ "clear_handle": False,
+ "update_details": [],
+ "update_confs": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ if not isinstance(historys, list):
+ historys = [historys]
+
+ # 按照时间倒序
+ historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True)
+
+ contens = [
+ {
+ 'component': 'tr',
+ 'props': {
+ 'class': 'text-sm'
+ },
+ 'content': [
+ {
+ 'component': 'td',
+ 'props': {
+ 'class': 'whitespace-nowrap break-keep text-high-emphasis'
+ },
+ 'text': history.get("time")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("name")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("type")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("content").encode('utf-8').decode('unicode_escape') if history.get(
+ "content") else ''
+ }
+ ]
+ } for history in historys
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTable',
+ 'props': {
+ 'hover': True
+ },
+ 'content': [
+ {
+ 'component': 'thead',
+ 'content': [
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '执行时间'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '订阅名称'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '更新类型'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': '更新内容'
+ },
+ ]
+ },
+ {
+ 'component': 'tbody',
+ 'content': contens
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/subscribereminder/__init__.py b/plugins/subscribereminder/__init__.py
new file mode 100644
index 0000000..79e5a99
--- /dev/null
+++ b/plugins/subscribereminder/__init__.py
@@ -0,0 +1,284 @@
+from datetime import datetime, timedelta
+
+import pytz
+from app.chain.media import MediaChain
+from app.chain.tmdb import TmdbChain
+from app.core.config import settings
+from app.db.subscribe_oper import SubscribeOper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.schemas import NotificationType, MediaType
+
+
+class SubscribeReminder(_PluginBase):
+ # 插件名称
+ plugin_name = "订阅提醒"
+ # 插件描述
+ plugin_desc = "推送当天订阅更新内容。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribe_reminder.png"
+ # 插件版本
+ plugin_version = "1.1"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "subscribereminder_"
+ # 加载顺序
+ plugin_order = 33
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _time = None
+ tmdb = None
+ media = None
+ subscribe_oper = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ self.subscribe_oper = SubscribeOper()
+ self.tmdb = TmdbChain()
+ self.media = MediaChain()
+
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._time = config.get("time")
+
+ if self._enabled or self._onlyonce:
+ # 周期运行
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ if self._time and str(self._time).isdigit():
+ cron = f"0 {int(self._time)} * * *"
+ try:
+ self._scheduler.add_job(func=self.__send_notify,
+ trigger=CronTrigger.from_crontab(cron),
+ name="订阅提醒")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"订阅提醒服务启动,立即运行一次")
+ self._scheduler.add_job(self.__send_notify, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="订阅提醒")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "time": self._time
+ })
+
+ def __send_notify(self):
+ # 查询所有订阅
+ subscribes = self.subscribe_oper.list()
+ if not subscribes:
+ logger.error("当前没有订阅,跳过处理")
+ return
+
+ # 当前日期
+ current_date = datetime.now().date().strftime("%Y-%m-%d")
+
+ current_tv_subscribe = []
+ current_movie_subscribe = []
+ # 遍历订阅,查询tmdb
+ for subscribe in subscribes:
+ # 电视剧
+ if subscribe.type == "电视剧":
+ if not subscribe.tmdbid or not subscribe.season:
+ continue
+
+ # 电视剧某季所有集
+ episodes_info = self.tmdb.tmdb_episodes(tmdbid=subscribe.tmdbid, season=subscribe.season)
+ if not episodes_info:
+ continue
+
+ episodes = []
+ # 遍历集,筛选当前日期发布的剧集
+ for episode in episodes_info:
+ if episode and episode.air_date and str(episode.air_date) == current_date:
+ episodes.append(episode.episode_number)
+
+ if episodes:
+ current_tv_subscribe.append({
+ 'name': f"{subscribe.name} ({subscribe.year})",
+ 'season': f"S{str(subscribe.season).rjust(2, '0')}",
+ 'episode': f"E{str(episodes[0]).rjust(2, '0')}-E{str(episodes[-1]).rjust(2, '0')}" if len(
+ episodes) > 1 else f"E{str(episodes[0]).rjust(2, '0')}"
+ })
+
+ # 电影
+ else:
+ if not subscribe.tmdbid:
+ continue
+ mediainfo = self.media.recognize_media(tmdbid=subscribe.tmdbid, mtype=MediaType.MOVIE)
+ if not mediainfo:
+ continue
+ if str(mediainfo.release_date) == current_date:
+ current_movie_subscribe.append({
+ 'name': f"{subscribe.name} ({subscribe.year})"
+ })
+
+ # 如当前日期匹配到订阅,则发送通知
+ text = ""
+ for sub in current_tv_subscribe:
+ text += sub.get("name") + "\n"
+ text += sub.get("season") + sub.get("episode") + "\n"
+ text += "\n"
+
+ for sub in current_movie_subscribe:
+ text += sub.get("name") + "\n"
+ text += "\n"
+
+ if text:
+ self.post_message(mtype=NotificationType.Subscribe,
+ title=f"{current_date}订阅提醒",
+ text=text)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'time',
+ 'label': '时间',
+ 'placeholder': '默认9点'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '默认每天9点推送,需开启(订阅)通知类型。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "time": 9,
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/subscribestatistic/__init__.py b/plugins/subscribestatistic/__init__.py
new file mode 100644
index 0000000..18594cc
--- /dev/null
+++ b/plugins/subscribestatistic/__init__.py
@@ -0,0 +1,730 @@
+import json
+from datetime import datetime, timedelta
+
+from app.db.downloadhistory_oper import DownloadHistoryOper
+from app.db.site_oper import SiteOper
+from app.plugins import _PluginBase
+from app.db.subscribe_oper import SubscribeOper
+from typing import Any, List, Dict, Tuple, Optional
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.log import logger
+from app.core.config import settings
+from app.schemas import NotificationType
+from app.schemas.types import SystemConfigKey
+
+
+class SubscribeStatistic(_PluginBase):
+ # 插件名称
+ plugin_name = "订阅下载统计"
+ # 插件描述
+ plugin_desc = "统计指定时间内各站点订阅及下载情况。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribestatistic.png"
+ # 插件版本
+ plugin_version = "1.5"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "subscribestatistic_"
+ # 加载顺序
+ plugin_order = 28
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 任务执行间隔
+ _enabled = False
+ _notify = False
+ _onlyonce = False
+ _movie_subscribe_days = None
+ _tv_subscribe_days = None
+ _movie_download_days = None
+ _tv_download_days = None
+ _notify_type = None
+ _msgtype = None
+ subscribe = None
+ downloadhis = None
+ siteoper = None
+ _cron: str = ""
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ self.subscribe = SubscribeOper()
+ self.downloadhis = DownloadHistoryOper()
+ self.siteoper = SiteOper()
+
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._movie_subscribe_days = config.get("movie_subscribe_days")
+ self._tv_subscribe_days = config.get("tv_subscribe_days")
+ self._movie_download_days = config.get("movie_download_days")
+ self._tv_download_days = config.get("tv_download_days")
+ self._notify_type = config.get("notify_type")
+ self._msgtype = config.get("msgtype")
+
+ if self._enabled and (
+ self._cron or self._onlyonce) and self._notify and self._msgtype and self._notify_type:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"订阅下载统计服务启动,立即运行一次")
+ self._scheduler.add_job(self.notify, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="订阅下载统计")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.notify,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="订阅下载统计")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "notify": self._notify,
+ "movie_subscribe_days": self._movie_subscribe_days,
+ "tv_subscribe_days": self._tv_subscribe_days,
+ "movie_download_days": self._movie_download_days,
+ "tv_download_days": self._tv_download_days,
+ "notify_type": self._notify_type,
+ "msgtype": self._msgtype,
+ })
+
+ def notify(self):
+ """
+ 发送统计消息
+ """
+ text = ""
+ if 'movie_subscribes' in self._notify_type:
+ text += f"【电影{self._movie_subscribe_days}天内订阅统计】\n"
+ _, movie_subscribe_sites, movie_subscribe_datas = self.__get_movie_subscribes()
+ movie_subscribe_dict = dict(zip(movie_subscribe_sites, movie_subscribe_datas))
+ movie_subscribe_dict = dict(sorted(movie_subscribe_dict.items(), key=lambda x: x[1], reverse=True))
+ for movie_subscribe_site in movie_subscribe_dict.keys():
+ text += f"{movie_subscribe_site}: {movie_subscribe_dict[movie_subscribe_site]}\n"
+ text += "\n"
+
+ if 'tv_subscribes' in self._notify_type:
+ text += f"【电视剧{self._tv_subscribe_days}天内订阅统计】\n"
+ _, tv_subscribe_sites, tv_subscribe_datas = self.__get_tv_subscribes()
+ tv_subscribe_dict = dict(zip(tv_subscribe_sites, tv_subscribe_datas))
+ tv_subscribe_dict = dict(sorted(tv_subscribe_dict.items(), key=lambda x: x[1], reverse=True))
+ for tv_subscribe_site in tv_subscribe_dict.keys():
+ text += f"{tv_subscribe_site}: {tv_subscribe_dict[tv_subscribe_site]}\n"
+ text += "\n"
+
+ if 'movie_downloads' in self._notify_type:
+ text += f"【电影{self._movie_download_days}天内下载统计】\n"
+ _, movie_download_sites, movie_download_datas = self.__get_movie_downloads()
+ movie_download_dict = dict(zip(movie_download_sites, movie_download_datas))
+ movie_download_dict = dict(sorted(movie_download_dict.items(), key=lambda x: x[1], reverse=True))
+ for movie_download_site in movie_download_dict.keys():
+ text += f"{movie_download_site}: {movie_download_dict[movie_download_site]}\n"
+ text += "\n"
+
+ if 'tv_downloads' in self._notify_type:
+ text += f"【电视剧{self._tv_download_days}天内下载统计】\n"
+ _, tv_download_sites, tv_download_datas = self.__get_tv_downloads()
+ tv_download_dict = dict(zip(tv_download_sites, tv_download_datas))
+ tv_download_dict = dict(sorted(tv_download_dict.items(), key=lambda x: x[1], reverse=True))
+ for tv_download_site in tv_download_dict.keys():
+ text += f"{tv_download_site}: {tv_download_dict[tv_download_site]}\n"
+
+ # 发送通知
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+
+ self.post_message(title="【订阅下载统计】",
+ mtype=mtype,
+ text=text)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def __get_movie_subscribes(self):
+ """
+ 获取电影订阅统计数据
+ """
+ # 电影订阅
+ movie_subscribes = self.subscribe.list_by_type(mtype='电影', days=self._movie_subscribe_days)
+ movie_subscribe_sites = []
+ movie_subscribe_datas = []
+ if movie_subscribes:
+ movie_subscribe_site_ids = []
+ for movie_subscribe in movie_subscribes:
+ if movie_subscribe.sites:
+ movie_subscribe_site_ids += [site for site in json.loads(movie_subscribe.sites)]
+ else:
+ movie_subscribe_site_ids += self.systemconfig.get(SystemConfigKey.RssSites) or []
+
+ for movie_subscribe_site_id in movie_subscribe_site_ids:
+ site = self.siteoper.get(movie_subscribe_site_id)
+ if site:
+ if not movie_subscribe_sites.__contains__(site.name):
+ movie_subscribe_sites.append(site.name)
+ movie_subscribe_datas.append(movie_subscribe_site_ids.count(movie_subscribe_site_id))
+
+ return movie_subscribes, movie_subscribe_sites, movie_subscribe_datas
+
+ def __get_tv_subscribes(self):
+ """
+ 获取电视剧订阅统计数据
+ """
+ tv_subscribes = self.subscribe.list_by_type(mtype='电视剧', days=self._tv_subscribe_days)
+ tv_subscribe_sites = []
+ tv_subscribe_datas = []
+ if tv_subscribes:
+ tv_subscribe_site_ids = []
+ for tv_subscribe in tv_subscribes:
+ if tv_subscribe.sites:
+ tv_subscribe_site_ids += [site for site in json.loads(tv_subscribe.sites)]
+ else:
+ tv_subscribe_site_ids += self.systemconfig.get(SystemConfigKey.RssSites) or []
+
+ for tv_subscribe_site_id in tv_subscribe_site_ids:
+ site = self.siteoper.get(tv_subscribe_site_id)
+ if site:
+ if not tv_subscribe_sites.__contains__(site.name):
+ tv_subscribe_sites.append(site.name)
+ tv_subscribe_datas.append(tv_subscribe_site_ids.count(tv_subscribe_site_id))
+
+ return tv_subscribes, tv_subscribe_sites, tv_subscribe_datas
+
+ def __get_movie_downloads(self):
+ """
+ 获取电影下载统计数据
+ """
+ movie_downloads = self.downloadhis.list_by_type(mtype="电影", days=self._movie_download_days)
+ movie_download_sites = []
+ movie_download_datas = []
+ if movie_downloads:
+ movie_download_sites2 = []
+ for movie_download in movie_downloads:
+ if movie_download.torrent_site:
+ movie_download_sites2.append(movie_download.torrent_site)
+
+ for movie_download_site in movie_download_sites2:
+ if not movie_download_sites.__contains__(movie_download_site):
+ movie_download_sites.append(movie_download_site)
+ if not movie_download_datas.__contains__(movie_download_site):
+ movie_download_datas.append(movie_download_sites2.count(movie_download_site))
+
+ return movie_downloads, movie_download_sites, movie_download_datas
+
+ def __get_tv_downloads(self):
+ """
+ 获取电视剧下载统计数据
+ """
+ tv_downloads = self.downloadhis.list_by_type(mtype="电视剧", days=self._tv_download_days)
+ tv_download_sites = []
+ tv_download_datas = []
+ if tv_downloads:
+ tv_download_sites2 = []
+ for tv_download in tv_downloads:
+ if tv_download.torrent_site:
+ tv_download_sites2.append(tv_download.torrent_site)
+
+ for tv_download_site in tv_download_sites2:
+ if not tv_download_sites.__contains__(tv_download_site):
+ tv_download_sites.append(tv_download_site)
+ if not tv_download_datas.__contains__(tv_download_site):
+ tv_download_datas.append(tv_download_sites2.count(tv_download_site))
+
+ return tv_downloads, tv_download_sites, tv_download_datas
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '发送通知',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'movie_subscribe_days',
+ 'label': '电影订阅天数',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'tv_subscribe_days',
+ 'label': '电视剧订阅天数',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'movie_download_days',
+ 'label': '电影下载天数',
+ 'placeholder': '7'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'tv_download_days',
+ 'label': '电视剧下载天数',
+ 'placeholder': '7'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'model': 'notify_type',
+ 'label': '推送类型',
+ 'items': [
+ {'title': '电影订阅', 'value': 'movie_subscribes'},
+ {'title': '电视剧订阅', 'value': 'tv_subscribes'},
+ {'title': '电影下载', 'value': 'movie_downloads'},
+ {'title': '电视剧下载', 'value': 'tv_downloads'},
+ ]
+ }
+ }
+ ]
+ },
+
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '订阅数量:MoviePilot指定天数内正在订阅的数量。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '下载数量:通过MoviePilot下载的数量,包括订阅下载、手动下载以及其他下载等场景。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ "movie_subscribe_days": 30,
+ "tv_subscribe_days": 30,
+ "movie_download_days": 7,
+ "tv_download_days": 7,
+ "notify_type": "",
+ "msgtype": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ if not self._enabled:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂未开启插件',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ # 电影订阅
+ movie_subscribes, movie_subscribe_sites, movie_subscribe_datas = self.__get_movie_subscribes()
+
+ # 电视剧订阅
+ tv_subscribes, tv_subscribe_sites, tv_subscribe_datas = self.__get_tv_subscribes()
+
+ # 电影下载
+ movie_downloads, movie_download_sites, movie_download_datas = self.__get_movie_downloads()
+
+ # 电视剧下载
+ tv_downloads, tv_download_sites, tv_download_datas = self.__get_tv_downloads()
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ # 电影订阅图表
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VApexChart',
+ 'props': {
+ 'height': 300,
+ 'options': {
+ 'chart': {
+ 'type': 'pie',
+ },
+ 'labels': movie_subscribe_sites,
+ 'title': {
+ 'text': f'电影近 {self._movie_subscribe_days} 天订阅 {len(movie_subscribes)} 部'
+ },
+ 'legend': {
+ 'show': True
+ },
+ 'plotOptions': {
+ 'pie': {
+ 'expandOnClick': False
+ }
+ },
+ 'noData': {
+ 'text': '订阅未选择站点或站点已删除'
+ }
+ },
+ 'series': movie_subscribe_datas
+ }
+ }
+ ]
+ },
+ # 电视剧订阅图表
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VApexChart',
+ 'props': {
+ 'height': 300,
+ 'options': {
+ 'chart': {
+ 'type': 'pie',
+ },
+ 'labels': tv_subscribe_sites,
+ 'title': {
+ 'text': f'电视剧近 {self._tv_subscribe_days} 天订阅 {len(tv_subscribes)} 部'
+ },
+ 'legend': {
+ 'show': True
+ },
+ 'plotOptions': {
+ 'pie': {
+ 'expandOnClick': False
+ }
+ },
+ 'noData': {
+ 'text': '订阅未选择站点或站点已删除'
+ }
+ },
+ 'series': tv_subscribe_datas
+ }
+ }
+ ]
+ },
+ # 电影下载图表
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VApexChart',
+ 'props': {
+ 'height': 300,
+ 'options': {
+ 'chart': {
+ 'type': 'pie',
+ },
+ 'labels': movie_download_sites,
+ 'title': {
+ 'text': f'电影近 {self._movie_download_days} 天下载 {len(movie_downloads)} 个种子'
+ },
+ 'legend': {
+ 'show': True
+ },
+ 'plotOptions': {
+ 'pie': {
+ 'expandOnClick': False
+ }
+ },
+ 'noData': {
+ 'text': '暂无数据'
+ }
+ },
+ 'series': movie_download_datas
+ }
+ }
+ ]
+ },
+ # 电视剧下载图表
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VApexChart',
+ 'props': {
+ 'height': 300,
+ 'options': {
+ 'chart': {
+ 'type': 'pie',
+ },
+ 'labels': tv_download_sites,
+ 'title': {
+ 'text': f'电视剧近 {self._tv_download_days} 天下载 {len(tv_downloads)} 个种子'
+ },
+ 'legend': {
+ 'show': True
+ },
+ 'plotOptions': {
+ 'pie': {
+ 'expandOnClick': False
+ }
+ },
+ 'noData': {
+ 'text': '暂无数据'
+ }
+ },
+ 'series': tv_download_datas
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py
new file mode 100644
index 0000000..768ed9d
--- /dev/null
+++ b/plugins/synccookiecloud/__init__.py
@@ -0,0 +1,276 @@
+import json
+from datetime import datetime, timedelta
+from hashlib import md5
+
+import pytz
+
+from app.core.config import settings
+from app.db.site_oper import SiteOper
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from app.utils.common import encrypt
+
+
+class SyncCookieCloud(_PluginBase):
+ # 插件名称
+ plugin_name = "同步CookieCloud"
+ # 插件描述
+ plugin_desc = "同步MoviePilot站点Cookie到本地CookieCloud。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cookiecloud.png"
+ # 插件版本
+ plugin_version = "1.2"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "synccookiecloud_"
+ # 加载顺序
+ plugin_order = 28
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled: bool = False
+ _onlyonce: bool = False
+ _cron: str = ""
+ siteoper = None
+ _scheduler: Optional[BackgroundScheduler] = None
+
+ def init_plugin(self, config: dict = None):
+ self.siteoper = SiteOper()
+
+ # 停止现有任务
+ self.stop_service()
+
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+
+ if self._enabled or self._onlyonce:
+ # 定时服务
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ # 立即运行一次
+ if self._onlyonce:
+ logger.info(f"同步CookieCloud服务启动,立即运行一次")
+ self._scheduler.add_job(self.__sync_to_cookiecloud, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="同步CookieCloud")
+ # 关闭一次性开关
+ self._onlyonce = False
+
+ # 保存配置
+ self.__update_config()
+
+ # 周期运行
+ if self._cron:
+ try:
+ self._scheduler.add_job(func=self.__sync_to_cookiecloud,
+ trigger=CronTrigger.from_crontab(self._cron),
+ name="同步CookieCloud")
+ except Exception as err:
+ logger.error(f"定时任务配置错误:{err}")
+ # 推送实时消息
+ self.systemmessage.put(f"执行周期配置错误:{err}")
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __sync_to_cookiecloud(self):
+ """
+ 同步站点cookie到cookiecloud
+ """
+ # 获取所有站点
+ sites = self.siteoper.list_order_by_pri()
+ if not sites:
+ return
+
+ if not settings.COOKIECLOUD_ENABLE_LOCAL:
+ logger.error('本地CookieCloud服务器未启用')
+ return
+
+ cookies = {}
+ for site in sites:
+ domain = site.domain
+ cookie = site.cookie
+
+ if not cookie:
+ logger.error(f"站点{domain}无cookie,跳过处理")
+ continue
+
+ # 解析cookie
+ site_cookies = []
+ for ck in cookie.split(";"):
+ site_cookies.append({
+ "domain": domain,
+ "sameSite": "unspecified",
+ "path": "/",
+ "name": ck.split("=")[0],
+ "value": ck.split("=")[1]
+ })
+
+ # 存储cookies
+ cookies[domain] = site_cookies
+
+ # 覆盖到cookiecloud
+ if cookies:
+ crypt_key = self._get_crypt_key()
+ try:
+ cookies = {'cookie_data': cookies}
+ encrypted_data = encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8')
+ except Exception as e:
+ logger.error(f"CookieCloud加密失败,{e}")
+ return
+
+ ck = {'encrypted': encrypted_data}
+ file = open(settings.COOKIE_PATH / f'{settings.COOKIECLOUD_KEY}.json', 'w')
+ file.write(json.dumps(ck))
+ file.close()
+
+ logger.info(cookies)
+ logger.info(f"同步站点cookie到CookieCloud成功")
+
+ def _get_crypt_key(self) -> bytes:
+ """
+ 使用UUID和密码生成CookieCloud的加解密密钥
+ """
+ md5_generator = md5()
+ md5_generator.update((str(settings.COOKIECLOUD_KEY).strip() + '-' + str(settings.COOKIECLOUD_PASSWORD).strip()).encode('utf-8'))
+ return (md5_generator.hexdigest()[:16]).encode('utf-8')
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron
+ })
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '需要MoviePilot设定-站点启用本地CookieCloud服务器。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "5 1 * * *",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ try:
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._scheduler.shutdown()
+ self._scheduler = None
+ except Exception as e:
+ logger.error("退出插件失败:%s" % str(e))
diff --git a/plugins/synologynotify/__init__.py b/plugins/synologynotify/__init__.py
new file mode 100644
index 0000000..6ed1ad7
--- /dev/null
+++ b/plugins/synologynotify/__init__.py
@@ -0,0 +1,215 @@
+from app.plugins import _PluginBase
+from typing import Any, List, Dict, Tuple
+from app.log import logger
+from app.schemas import NotificationType
+from app import schemas
+
+
+class SynologyNotify(_PluginBase):
+ # 插件名称
+ plugin_name = "群辉Webhook通知"
+ # 插件描述
+ plugin_desc = "接收群辉webhook通知并推送。"
+ # 插件图标
+ plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/synology.png"
+ # 插件版本
+ plugin_version = "1.1"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "synologynotify_"
+ # 加载顺序
+ plugin_order = 30
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 任务执行间隔
+ _enabled = False
+ _notify = False
+ _msgtype = None
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._msgtype = config.get("msgtype")
+
+ def send_notify(self, text: str) -> schemas.Response:
+ """
+ 发送通知
+ """
+ logger.info(f"收到webhook消息啦。。。 {text}")
+ if self._enabled and self._notify:
+ mtype = NotificationType.Manual
+ if self._msgtype:
+ mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual
+ self.post_message(title="群辉通知",
+ mtype=mtype,
+ text=text)
+
+ return schemas.Response(
+ success=True,
+ message="发送成功"
+ )
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ """
+ 获取插件API
+ [{
+ "path": "/xx",
+ "endpoint": self.xxx,
+ "methods": ["GET", "POST"],
+ "summary": "API说明"
+ }]
+ """
+ return [{
+ "path": "/webhook",
+ "endpoint": self.send_notify,
+ "methods": ["GET"],
+ "summary": "群辉webhook",
+ "description": "接受群辉webhook通知并推送",
+ }]
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 编历 NotificationType 枚举,生成消息类型选项
+ MsgTypeOptions = []
+ for item in NotificationType:
+ MsgTypeOptions.append({
+ "title": item.value,
+ "value": item.name
+ })
+ return [
+ {
+ 'component': 'VForm',
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enabled',
+ 'label': '启用插件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'notify',
+ 'label': '开启通知',
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': False,
+ 'chips': True,
+ 'model': 'msgtype',
+ 'label': '消息类型',
+ 'items': MsgTypeOptions
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '群辉webhook配置http://ip:3001/api/v1/plugin/SynologyNotify/webhook?text=hello world。'
+ 'text参数类型是消息内容。此插件安装完需要重启生效api。消息类型默认为手动处理通知。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '如安装完插件后,群晖发送webhook提示404,重启MoviePilot即可。'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": False,
+ "msgtype": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
diff --git a/plugins/wechatforward/__init__.py b/plugins/wechatforward/__init__.py
new file mode 100644
index 0000000..5c7e309
--- /dev/null
+++ b/plugins/wechatforward/__init__.py
@@ -0,0 +1,1093 @@
+import json
+import re
+import time
+from datetime import datetime
+
+from app.core.config import settings
+from app.db.models.subscribehistory import SubscribeHistory
+from app.db.subscribe_oper import SubscribeOper
+from app.plugins import _PluginBase
+from app.core.event import eventmanager
+from app.schemas.types import EventType, MessageChannel, MediaType
+from app.utils.http import RequestUtils
+from typing import Any, List, Dict, Tuple, Optional
+from app.log import logger
+
+
+class WeChatForward(_PluginBase):
+ # 插件名称
+ plugin_name = "微信消息转发"
+ # 插件描述
+ plugin_desc = "根据正则转发通知到其他WeChat应用。"
+ # 插件图标
+ plugin_icon = "Wechat_A.png"
+ # 插件版本
+ plugin_version = "2.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "wechatforward_"
+ # 加载顺序
+ plugin_order = 16
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _enabled = False
+ _rebuild = False
+ _wechat_confs = None
+ _specify_confs = None
+ _ignore_userid = None
+ _wechat_token_pattern_confs = {}
+ _extra_msg_history = {}
+ _history_days = None
+
+ # 企业微信发送消息URL
+ _send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s"
+ # 企业微信获取TokenURL
+ _token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
+
+ example = [
+ {
+ "remark": "入库消息",
+ "appid": 1000001,
+ "corpid": "",
+ "appsecret": "",
+ "pattern": "已入库",
+ "extra_confs": [
+
+ ],
+ },
+ {
+ "remark": "站点签到数据统计",
+ "appid": 1000002,
+ "corpid": "",
+ "appsecret": "",
+ "pattern": "自动签到|自动登录|数据统计|刷流任务",
+ "extra_confs": []
+ }
+ ]
+
+ def init_plugin(self, config: dict = None):
+ if config:
+ self._enabled = config.get("enabled")
+ self._rebuild = config.get("rebuild")
+ self._wechat_confs = config.get("wechat_confs") or []
+ self._ignore_userid = config.get("ignore_userid")
+ self._specify_confs = config.get("specify_confs")
+ self._history_days = config.get("history_days") or 7
+
+ # 兼容旧版本配置
+ self.__sync_old_config()
+
+ # 获取token存库
+ if self._enabled and self._wechat_confs:
+ self.__save_wechat_token()
+
+ def __sync_old_config(self):
+ """
+ 兼容旧版本配置
+ """
+ config = self.get_config()
+ if not config or not config.get("wechat") or not config.get("pattern"):
+ return
+
+ __extra_confs = {}
+ if config.get("extra_confs"):
+ for extra_conf in config.get("extra_confs").split("\n"):
+ if not extra_conf:
+ continue
+ if str(extra_conf).startswith("#"):
+ extra_conf = extra_conf.strip()[1:]
+ extras = str(extra_conf).split(" > ")
+ if len(extras) != 4:
+ continue
+ extra_pattern = extras[0]
+ extra_userid = extras[1]
+ extra_title = extras[2]
+ extra_appid = extras[3]
+ __extra = __extra_confs.get(extra_appid, [])
+ __extra.append({
+ "pattern": extra_pattern,
+ "userid": extra_userid,
+ "msg": extra_title,
+ })
+ __extra_confs[extra_appid] = __extra
+
+ wechat_confs = []
+ for index, wechat in enumerate(config.get("wechat").split("\n")):
+ remark = ""
+ if wechat.count("#") == 1:
+ remark = wechat.split("#")[1]
+ wechat = wechat.split("#")[0]
+ wechat_config = wechat.split(":")
+ if len(wechat_config) != 3:
+ continue
+ appid = wechat_config[0]
+ corpid = wechat_config[1]
+ appsecret = wechat_config[2]
+ if not remark:
+ remark = f"{appid}配置"
+
+ # 获取对应appid的正则
+ pattern = config.get("pattern").split("\n")[index] or ""
+ wechat_confs.append({
+ "remark": remark,
+ "appid": appid,
+ "corpid": corpid,
+ "appsecret": appsecret,
+ "pattern": pattern,
+ "extra_confs": __extra_confs.get(appid, []) if __extra_confs else []
+ })
+
+ if wechat_confs:
+ self._wechat_confs = json.dumps(wechat_confs, indent=4, ensure_ascii=False)
+ self.update_config({
+ "enabled": self._enabled,
+ "wechat_confs": self._wechat_confs,
+ "ignore_userid": self._ignore_userid,
+ "specify_confs": self._specify_confs,
+ })
+ logger.info("旧版本配置已转为新版本配置")
+
+ def __save_wechat_token(self):
+ """
+ 获取并存储wechat token
+ """
+ # 如果重建则重新解析存库
+ if self._rebuild:
+ self.__parse_token()
+ else:
+ # 从数据库获取token
+ wechat_confs = self.get_data('wechat_confs')
+
+ if not self._wechat_token_pattern_confs and wechat_confs:
+ self._wechat_token_pattern_confs = wechat_confs
+ logger.info(f"WeChat配置 从数据库获取成功:{len(self._wechat_token_pattern_confs.keys())}条配置")
+ else:
+ self.__parse_token()
+
+ def __parse_token(self):
+ """
+ 解析token存库
+ """
+ # 解析配置
+ for wechat in json.loads(self._wechat_confs):
+ remark = wechat.get("remark")
+ appid = wechat.get("appid")
+ corpid = wechat.get("corpid")
+ appsecret = wechat.get("appsecret")
+ pattern = wechat.get("pattern")
+ extra_confs = wechat.get("extra_confs")
+ if not appid or not corpid or not appsecret:
+ logger.error(f"{remark} 应用配置不正确, 跳过处理")
+ continue
+
+ # 获取token
+ access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
+ appsecret=appsecret)
+ if not access_token:
+ # 没有token,获取token
+ logger.error(f"WeChat配置 {remark} 获取token失败,请检查配置")
+ continue
+
+ self._wechat_token_pattern_confs[appid] = {
+ "remark": remark,
+ "corpid": corpid,
+ "appsecret": appsecret,
+ "access_token": access_token,
+ "expires_in": expires_in,
+ "access_token_time": access_token_time,
+ "pattern": pattern,
+ "extra_confs": extra_confs,
+ }
+ logger.info(f"WeChat配置 {remark} 配置成功:{self._wechat_token_pattern_confs[appid]}")
+
+ if self._rebuild:
+ self._rebuild = False
+ self.__update_config()
+
+ # token存库
+ if len(self._wechat_token_pattern_confs.keys()) > 0:
+ self.__save_wechat_confs()
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "rebuild": self._rebuild,
+ "wechat_confs": self._wechat_confs,
+ "ignore_userid": self._ignore_userid,
+ "specify_confs": self._specify_confs,
+ "history_days": self._history_days
+ })
+
+ def __save_wechat_confs(self):
+ """
+ 保存wechat配置
+ """
+ self.save_data(key="wechat_confs",
+ value=self._wechat_token_pattern_confs)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ 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': 'rebuild',
+ 'label': '重建缓存'
+ }
+ }
+ ]
+ },
+ {
+ "component": "VCol",
+ "props": {
+ "cols": 12,
+ "md": 4
+ },
+ "content": [
+ {
+ "component": "VSwitch",
+ "props": {
+ "model": "dialog_closed",
+ "label": "设置微信配置"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'history_days',
+ 'label': '保留历史天数'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 9
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'ignore_userid',
+ 'rows': '1',
+ 'label': '忽略userid',
+ 'placeholder': '开始下载|添加下载任务失败'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'specify_confs',
+ 'rows': '2',
+ 'label': '特定消息指定用户',
+ 'placeholder': 'title > text > userid'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '12px'
+ },
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'success',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '配置教程请参考:'
+ },
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/WeChatForward.md',
+ 'target': '_blank'
+ },
+ 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/WeChatForward.md'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "component": "VDialog",
+ "props": {
+ "model": "dialog_closed",
+ "max-width": "65rem",
+ "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked",
+ "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t"
+ },
+ "content": [
+ {
+ "component": "VCard",
+ "props": {
+ "title": "设置微信配置"
+ },
+ "content": [
+ {
+ "component": "VDialogCloseBtn",
+ "props": {
+ "model": "dialog_closed"
+ }
+ },
+ {
+ "component": "VCardText",
+ "props": {},
+ "content": [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAceEditor',
+ 'props': {
+ 'modelvalue': 'wechat_confs',
+ 'lang': 'json',
+ 'theme': 'monokai',
+ 'style': 'height: 30rem',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '注意:只有正确配置微信配置时,该配置项才会生效,详细配置参考。'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "rebuild": False,
+ "ignore_userid": "",
+ "specify_confs": "",
+ "history_days": 7,
+ "wechat_confs": json.dumps(WeChatForward.example, indent=4, ensure_ascii=False)
+ }
+
+ def get_page(self) -> List[dict]:
+ # 查询同步详情
+ historys = self.get_data('history')
+ if not historys:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+
+ if not isinstance(historys, list):
+ historys = [historys]
+
+ # 按照时间倒序
+ historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True)
+
+ msgs = [
+ {
+ 'component': 'tr',
+ 'props': {
+ 'class': 'text-sm'
+ },
+ 'content': [
+ {
+ 'component': 'td',
+ 'props': {
+ 'class': 'whitespace-nowrap break-keep text-high-emphasis'
+ },
+ 'text': history.get("time")
+ },
+ {
+ 'component': 'td',
+ 'text': f"{history.get('appid')}{history.get('remark') if history.get('remark') else ''}"
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("userid")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("title")
+ },
+ {
+ 'component': 'td',
+ 'text': history.get("text")
+ }
+ ]
+ } for history in historys
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTable',
+ 'props': {
+ 'hover': True
+ },
+ 'content': [
+ {
+ 'component': 'thead',
+ 'content': [
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': 'time'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': 'appid'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': 'userid'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': 'title'
+ },
+ {
+ 'component': 'th',
+ 'props': {
+ 'class': 'text-start ps-4'
+ },
+ 'text': 'text'
+ },
+ ]
+ },
+ {
+ 'component': 'tbody',
+ 'content': msgs
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ @eventmanager.register(EventType.NoticeMessage)
+ def send(self, event):
+ """
+ 消息转发
+ """
+ if not self._enabled or not self._wechat_token_pattern_confs:
+ logger.error("插件未启用或未配置微信配置")
+ return
+
+ # 消息体
+ data = event.event_data
+ channel = data.get("channel")
+ if channel and channel != MessageChannel.Wechat:
+ return
+
+ title = data.get("title")
+ text = data.get("text")
+ image = data.get("image")
+ userid = data.get("userid")
+
+ # 遍历配置 匹配正则 发送消息
+ for wechat_appid in self._wechat_token_pattern_confs.keys():
+ wechat_conf = self._wechat_token_pattern_confs.get(wechat_appid)
+ if not wechat_conf or not wechat_conf.get("pattern"):
+ continue
+
+ # 匹配正则
+ if re.search(wechat_conf.get("pattern"), title):
+ # 忽略userid
+ if self._ignore_userid and re.search(self._ignore_userid, title):
+ userid = None
+ else:
+ # 特定消息指定用户
+ userid = self.__specify_userid(title=title, text=text, userid=userid)
+
+ access_token = self.__flush_access_token(appid=wechat_appid)
+ if not access_token:
+ logger.error("未获取到有效token,请检查配置")
+ continue
+
+ # 发送消息
+ if image:
+ self.__send_image_message(title=title, text=text, image_url=image, userid=userid,
+ access_token=wechat_conf.get("access_token"), appid=wechat_appid)
+ else:
+ self.__send_message(title=title, text=text, userid=userid,
+ access_token=wechat_conf.get("access_token"),
+ appid=wechat_appid)
+
+ # 发送额外消息
+ # 开始下载 > userid > {name} 后台下载任务已提交,请耐心等候入库通知。 > appid
+ # 已添加订阅 > userid > {name} 电视剧正在更新,已添加订阅,待更新后自动下载。 > appid
+ if wechat_conf.get("extra_confs"):
+ self.__send_extra_msg(wechat_appid=wechat_appid,
+ extra_confs=wechat_conf.get("extra_confs"),
+ access_token=wechat_conf.get("access_token"),
+ title=title,
+ text=text)
+
+ def __specify_userid(self, title, text, userid):
+ """
+ 特定消息指定用户
+ """
+ if self._specify_confs:
+ for specify_conf in self._specify_confs.split("\n"):
+ if not specify_conf:
+ continue
+ # 跳过注释
+ if str(specify_conf).startswith("#"):
+ continue
+ specify = specify_conf.split(" > ")
+ if len(specify) != 3:
+ continue
+ if re.search(specify[0], title) and (re.search(specify[1], text) or re.search(specify[1], title)):
+ userid = specify[2]
+ logger.info(f"消息 {title} {text} 指定用户 {userid}")
+ break
+
+ return userid
+
+ def __send_extra_msg(self, wechat_appid, extra_confs, access_token, title, text):
+ """
+ 根据自定义规则发送额外消息
+ """
+ self._extra_msg_history = self.get_data(key="extra_msg") or {}
+ is_save_history = False
+ for extra_conf in extra_confs:
+ if not extra_conf:
+ continue
+
+ extra_pattern = extra_conf.get("pattern")
+ extra_userid = extra_conf.get("userid")
+ extra_msg = extra_conf.get("msg")
+
+ # 正则匹配额外消息表达式
+ if re.search(extra_pattern, title):
+ logger.info(f"{title} 正则匹配到额外消息 {extra_pattern}")
+
+ # 处理变量{name}
+ if str(extra_msg).find('{name}') != -1:
+ extra_msg = extra_msg.replace('{name}', self.__parse_tv_title(title))
+
+ # 订阅完成消息单独处理
+ if "已完成订阅" in str(title):
+ # 查订阅历史的用户
+ subscribes = SubscribeHistory().list()
+ # 倒叙
+ subscribes = sorted(subscribes, key=lambda x: x.id, reverse=True)
+ for subscribe in subscribes:
+ # 匹配订阅title
+ if f"{subscribe.name} ({subscribe.year}) 已完成订阅" == title \
+ or f"{subscribe.name} ({subscribe.year}) S{str(subscribe.season).rjust(2, '0')} 已完成订阅" == title:
+ user_id = subscribe.username
+ logger.info(f"{title} 获取到订阅用户 {user_id}")
+ if user_id and any(user_id == user for user in extra_userid.split(",")):
+ logger.info(f"{title} 消息用户 {user_id} 匹配到目标用户 {extra_userid}")
+ self.__send_image_message(title=title,
+ text=extra_msg,
+ userid=user_id,
+ access_token=access_token,
+ appid=wechat_appid,
+ image_url=subscribe.backdrop)
+ logger.info(f"{wechat_appid} 发送额外消息 {extra_msg} 成功")
+ break
+ else:
+ # 搜索消息,获取消息text中的用户
+ result = re.search(r"用户:(.*?)\n", text)
+ if not result:
+ # 订阅消息,获取消息text中的用户
+ pattern = r"来自用户:(.*?)$"
+ result = re.search(pattern, text)
+ if not result:
+ logger.error(f"{title} 未获取到用户,跳过处理")
+ continue
+
+ # 获取消息text中的用户
+ user_id = result.group(1)
+ logger.info(f"{title} 获取到消息用户 {user_id}")
+ if user_id and any(user_id == user for user in extra_userid.split(",")):
+ if "开始下载" in str(title):
+ # 判断是否重复发送,10分钟内重复消息title、重复userid算重复消息
+ extra_history_time = self._extra_msg_history.get(
+ f"{user_id}-{self.__parse_tv_title(title)}") or None
+ # 只处理下载消息
+ if extra_history_time:
+ logger.info(
+ f"{title} 获取到额外消息上次发送时间 {datetime.strptime(extra_history_time, '%Y-%m-%d %H:%M:%S')}")
+ if (datetime.now() - datetime.strptime(extra_history_time,
+ '%Y-%m-%d %H:%M:%S')).total_seconds() < 600:
+ logger.warn(
+ f"{title} 额外消息 {self.__parse_tv_title(title)} 十分钟内重复发送,跳过。")
+ continue
+ # 判断当前用户是否订阅,是否订阅后续消息
+ subscribes = SubscribeOper().list_by_username(username=str(user_id),
+ state="R",
+ mtype=MediaType.TV.value)
+ is_subscribe = False
+ for subscribe in subscribes:
+ # 匹配订阅title
+ if f"{subscribe.name} ({subscribe.year})" in title:
+ is_subscribe = True
+ break
+
+ # 电视剧之前该用户订阅下载过,不再发送额外消息
+ if is_subscribe:
+ logger.warn(
+ f"{title} 额外消息 {self.__parse_tv_title(title)} 用户 {user_id} 已订阅,不再发送额外消息。")
+ continue
+
+ logger.info(f"{title} 消息用户 {user_id} 匹配到目标用户 {extra_userid}")
+
+ self.__send_message(title=extra_msg,
+ userid=user_id,
+ access_token=access_token,
+ appid=wechat_appid)
+ logger.info(f"{title} {wechat_appid} 发送额外消息 {extra_msg} 成功")
+ # 保存已发送消息
+ if "开始下载" in str(title):
+ self._extra_msg_history[
+ f"{user_id}-{self.__parse_tv_title(title)}"] = time.strftime(
+ "%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ is_save_history = True
+
+ # 保存额外消息历史
+ if is_save_history:
+ self.save_data(key="extra_msg",
+ value=self._extra_msg_history)
+
+ def __parse_tv_title(self, title):
+ """
+ 解析title标题
+ """
+ titles = title.split(" ")
+ _title = ""
+ for sub_title_str in titles:
+ # 电影 功夫熊猫 (2008) 开始下载
+ # 电影 功夫熊猫 (2008) 已添加订阅
+ # 电视剧 追风者 (2024) S01 E01-E04 开始下载
+ # 电视剧 追风者 (2024) S01 已添加订阅
+ # 电视剧 追风者 (2024) S01 已完成订阅
+ if '开始下载' in sub_title_str:
+ continue
+ if '已添加订阅' in sub_title_str:
+ continue
+ if '已完成订阅' in sub_title_str:
+ continue
+ _title += f"{sub_title_str} "
+ return self.__convert_season_episode(str(_title.rstrip()))
+
+ @staticmethod
+ def __convert_season_episode(text):
+ season_pattern = re.compile(r'S(\d+)')
+ episode_pattern = re.compile(r'E(\d+)')
+
+ def replace_season(match):
+ return f'第{int(match.group(1)):,}季'
+
+ def replace_episode(match):
+ return f'第{int(match.group(1)):,}集'
+
+ def convert_episode_range(text):
+ pattern = re.compile(r'E(\d+)-E(\d+)')
+ result = pattern.sub(lambda x: f'第{int(x.group(1)):02d}-{int(x.group(2)):02d}集', text)
+ return result
+
+ text = re.sub(season_pattern, replace_season, text)
+
+ if text.count("-") == 1:
+ text = convert_episode_range(text)
+ else:
+ text = re.sub(episode_pattern, replace_episode, text)
+
+ return text
+
+ def __flush_access_token(self, appid: int, force: bool = False):
+ """
+ 获取appid wechat token
+ """
+ wechat_confs = self._wechat_token_pattern_confs[appid]
+ if not wechat_confs:
+ logger.error(f"未获取到 {appid} 配置信息,请检查配置")
+ return None
+
+ access_token = wechat_confs.get("access_token")
+ expires_in = wechat_confs.get("expires_in")
+ access_token_time = wechat_confs.get("access_token_time")
+ corpid = wechat_confs.get("corpid")
+ appsecret = wechat_confs.get("appsecret")
+
+ # 判断token有效期
+ if force or (datetime.now() - datetime.strptime(access_token_time, '%Y-%m-%d %H:%M:%S')).seconds >= expires_in:
+ # 重新获取token
+ access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
+ appsecret=appsecret)
+
+ if not access_token:
+ logger.error(f"WeChat配置 {appid} 获取token失败,请检查配置")
+ return None
+
+ # 更新token回配置
+ wechat_confs.update({
+ "access_token": access_token,
+ "expires_in": expires_in,
+ "access_token_time": access_token_time,
+ })
+ self._wechat_token_pattern_confs[appid] = wechat_confs
+ # 更新回库
+ self.__save_wechat_confs()
+
+ return access_token
+
+ def __send_message(self, title: str, text: str = None, userid: str = None,
+ access_token: str = None, appid: int = None) -> Optional[bool]:
+ """
+ 发送文本消息
+ :param title: 消息标题
+ :param text: 消息内容
+ :param userid: 消息发送对象的ID,为空则发给所有人
+ :return: 发送状态,错误信息
+ """
+ if text:
+ conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
+ else:
+ conent = title
+
+ if not userid:
+ userid = "@all"
+ req_json = {
+ "touser": userid,
+ "msgtype": "text",
+ "agentid": appid,
+ "text": {
+ "content": conent
+ },
+ "safe": 0,
+ "enable_id_trans": 0,
+ "enable_duplicate_check": 0
+ }
+ return self.__post_request(access_token=access_token, req_json=req_json, appid=appid, title=title, text=text,
+ userid=userid)
+
+ def __send_image_message(self, title: str, image_url: str, text: str = None, userid: str = None,
+ access_token: str = None, appid: int = None) -> Optional[bool]:
+ """
+ 发送图文消息
+ :param title: 消息标题
+ :param text: 消息内容
+ :param image_url: 图片地址
+ :param userid: 消息发送对象的ID,为空则发给所有人
+ :return: 发送状态,错误信息
+ """
+ if text:
+ text = text.replace("\n\n", "\n")
+ if not userid:
+ userid = "@all"
+ req_json = {
+ "touser": userid,
+ "msgtype": "news",
+ "agentid": appid,
+ "news": {
+ "articles": [
+ {
+ "title": title,
+ "description": text,
+ "picurl": image_url,
+ "url": ''
+ }
+ ]
+ }
+ }
+ return self.__post_request(access_token=access_token, req_json=req_json, appid=appid, title=title, text=text,
+ userid=userid)
+
+ def __post_request(self, access_token: str, req_json: dict, appid: int, title: str, retry: int = 0,
+ text: str = None, userid: str = None) -> bool:
+ message_url = self._send_msg_url % access_token
+ """
+ 向微信发送请求
+ """
+ try:
+ res = RequestUtils(content_type='application/json').post(
+ message_url,
+ data=json.dumps(req_json, ensure_ascii=False).encode('utf-8')
+ )
+ if res and res.status_code == 200:
+ ret_json = res.json()
+ if ret_json.get('errcode') == 0:
+ logger.info(f"转发 配置 {appid} 消息 {title} {req_json} 成功")
+ # 读取历史记录
+ history = self.get_data('history') or []
+ history.append({
+ "appid": appid,
+ "remark": f"({self._wechat_token_pattern_confs.get(appid).get('remark')})" if self._wechat_token_pattern_confs.get(
+ appid).get('remark') else "",
+ "title": title,
+ "text": text,
+ "userid": userid,
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+ thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60
+ history = [record for record in history if
+ datetime.strptime(record["time"],
+ '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago]
+ # 保存历史
+ self.save_data(key="history", value=history)
+ return True
+ else:
+ if ret_json.get('errcode') == 81013:
+ return False
+
+ logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 失败,错误信息:{ret_json}")
+ if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
+ logger.info("token已过期,正在重新刷新token重试")
+ # 重新获取token
+ access_token = self.__flush_access_token(appid=appid,
+ force=True)
+ if access_token:
+ retry += 1
+ # 重发请求
+ if retry <= 3:
+ return self.__post_request(access_token=access_token,
+ req_json=req_json,
+ appid=appid,
+ title=title,
+ retry=retry,
+ text=text,
+ userid=userid)
+ return False
+ elif res is not None:
+ logger.error(
+ f"转发 配置 {appid} 消息 {title} {req_json} 失败,错误码:{res.status_code},错误原因:{res.reason}")
+ return False
+ else:
+ logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 失败,未获取到返回信息")
+ return False
+ except Exception as err:
+ logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 异常,错误信息:{str(err)}")
+ return False
+
+ def __get_access_token(self, corpid: str, appsecret: str):
+ """
+ 获取微信Token
+ :return: 微信Token
+ """
+ try:
+ token_url = self._token_url % (corpid, appsecret)
+ res = RequestUtils().get_res(token_url)
+ if res:
+ ret_json = res.json()
+ if ret_json.get('errcode') == 0:
+ access_token = ret_json.get('access_token')
+ expires_in = ret_json.get('expires_in')
+ access_token_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+ return access_token, expires_in, access_token_time
+ else:
+ logger.error(f"{ret_json.get('errmsg')}")
+ return None, None, None
+ else:
+ logger.error(f"{corpid} {appsecret} 获取token失败")
+ return None, None, None
+ except Exception as e:
+ logger.error(f"获取微信access_token失败,错误信息:{str(e)}")
+ return None, None, None
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ pass
+
+
+if __name__ == '__main__':
+ def __parse_tv_title(title):
+ """
+ 解析title标题
+ """
+ titles = title.split(" ")
+ _title = ""
+ for sub_title_str in titles:
+ # 电影 功夫熊猫 (2008) 开始下载
+ # 电影 功夫熊猫 (2008) 已添加订阅
+ # 电视剧 追风者 (2024) S01 E01-E04 开始下载
+ # 电视剧 追风者 (2024) S01 已添加订阅
+ if '开始下载' in sub_title_str:
+ continue
+ if '已添加订阅' in sub_title_str:
+ continue
+ _title += f"{sub_title_str} "
+ return __convert_season_episode(str(_title.rstrip()))
+
+
+ def __convert_season_episode(text):
+ season_pattern = re.compile(r'S(\d+)')
+ episode_pattern = re.compile(r'E(\d+)')
+
+ def replace_season(match):
+ return f'第{int(match.group(1)):,}季'
+
+ def replace_episode(match):
+ return f'第{int(match.group(1)):,}集'
+
+ def convert_episode_range(text):
+ pattern = re.compile(r'E(\d+)-E(\d+)')
+ result = pattern.sub(lambda x: f'第{int(x.group(1)):02d}-{int(x.group(2)):02d}集', text)
+ return result
+
+ text = re.sub(season_pattern, replace_season, text)
+ if text.count("-") == 1:
+ text = convert_episode_range(text)
+ else:
+ text = re.sub(episode_pattern, replace_episode, text)
+
+ return text
+
+
+ print(__parse_tv_title("时光代理人 (2021) S02 E01-E22 开始下载"))