diff --git a/README.md b/README.md
index d41afab..ab89f02 100644
--- a/README.md
+++ b/README.md
@@ -2,46 +2,6 @@
MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/
-# 保持插件最新
-
-- 安装并开启`插件自动更新`插件,每次重启会更新已安装插件最新版本。也可设置cron定时任务更新插件。
-- 如未刷新到插件更新,或者插件更新版本未变,可用`插件强制重装`插件进行重装。
-
### 插件新增
-- 站点数据统计 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
+- 云盘助手(docs%2FCloudAssistant.md) v1.7
\ No newline at end of file
diff --git a/data/EmbyReporter/res/PingFang Bold.ttf b/data/EmbyReporter/res/PingFang Bold.ttf
deleted file mode 100644
index accaf1f..0000000
Binary files a/data/EmbyReporter/res/PingFang Bold.ttf and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/0.jpg b/data/EmbyReporter/res/bg/0.jpg
deleted file mode 100644
index 91038ee..0000000
Binary files a/data/EmbyReporter/res/bg/0.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/1.jpg b/data/EmbyReporter/res/bg/1.jpg
deleted file mode 100644
index 8db3393..0000000
Binary files a/data/EmbyReporter/res/bg/1.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/2.jpg b/data/EmbyReporter/res/bg/2.jpg
deleted file mode 100644
index 5d114ee..0000000
Binary files a/data/EmbyReporter/res/bg/2.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/3.jpg b/data/EmbyReporter/res/bg/3.jpg
deleted file mode 100644
index f964c26..0000000
Binary files a/data/EmbyReporter/res/bg/3.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/4.jpg b/data/EmbyReporter/res/bg/4.jpg
deleted file mode 100644
index bc665b6..0000000
Binary files a/data/EmbyReporter/res/bg/4.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/5.jpg b/data/EmbyReporter/res/bg/5.jpg
deleted file mode 100644
index 95ef6a5..0000000
Binary files a/data/EmbyReporter/res/bg/5.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/bg/6.jpg b/data/EmbyReporter/res/bg/6.jpg
deleted file mode 100644
index 1343af2..0000000
Binary files a/data/EmbyReporter/res/bg/6.jpg and /dev/null differ
diff --git a/data/EmbyReporter/res/cover-ranks-mask-2.png b/data/EmbyReporter/res/cover-ranks-mask-2.png
deleted file mode 100644
index 335c61b..0000000
Binary files a/data/EmbyReporter/res/cover-ranks-mask-2.png and /dev/null differ
diff --git a/docs/CloudStrm.md b/docs/CloudStrm.md
deleted file mode 100644
index 29a8869..0000000
--- a/docs/CloudStrm.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# 云盘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
deleted file mode 100644
index dbb8b71..0000000
--- a/docs/CloudStrmIncrement.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# 云盘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
deleted file mode 100644
index 0d5c7ed..0000000
--- a/docs/CustomCommand.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# 自定义命令
-
-### 使用说明
-
-默认把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
deleted file mode 100644
index 034b2f6..0000000
--- a/docs/EmbyReporter.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# 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
deleted file mode 100644
index 78c51d1..0000000
--- a/docs/HomePage.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# 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
deleted file mode 100644
index 9b59840..0000000
--- a/docs/ShortPlayMonitor.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# 短剧刮削
-
-### 使用说明
-
-监控方式:
-
-- 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
deleted file mode 100644
index f47c673..0000000
--- a/docs/StrmConvert.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# 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
deleted file mode 100644
index 1cfbd5e..0000000
--- a/docs/WeChatForward.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# 微信消息转发
-
-### 使用说明
-
-#### 消息转发插件加强版
-
-根据正则表达式将对应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
deleted file mode 100644
index c295f91..0000000
Binary files a/icons/SpeedLimiter.jpg and /dev/null differ
diff --git a/icons/actor.png b/icons/actor.png
deleted file mode 100644
index da023fb..0000000
Binary files a/icons/actor.png and /dev/null differ
diff --git a/icons/autosubtitles.jpeg b/icons/autosubtitles.jpeg
deleted file mode 100644
index bdd1232..0000000
Binary files a/icons/autosubtitles.jpeg and /dev/null differ
diff --git a/icons/backup.png b/icons/backup.png
deleted file mode 100644
index efef44f..0000000
Binary files a/icons/backup.png and /dev/null differ
diff --git a/icons/bark.webp b/icons/bark.webp
deleted file mode 100644
index 0d6bb2f..0000000
Binary files a/icons/bark.webp and /dev/null differ
diff --git a/icons/broom.png b/icons/broom.png
deleted file mode 100644
index 98cb4ec..0000000
Binary files a/icons/broom.png and /dev/null differ
diff --git a/icons/brush.jpg b/icons/brush.jpg
deleted file mode 100644
index 8d79a06..0000000
Binary files a/icons/brush.jpg and /dev/null differ
diff --git a/icons/chatgpt.png b/icons/chatgpt.png
deleted file mode 100644
index 2bff26e..0000000
Binary files a/icons/chatgpt.png and /dev/null differ
diff --git a/icons/chinesesubfinder.png b/icons/chinesesubfinder.png
deleted file mode 100644
index 5ba3ea5..0000000
Binary files a/icons/chinesesubfinder.png and /dev/null differ
diff --git a/icons/clean.png b/icons/clean.png
deleted file mode 100644
index fcebe9d..0000000
Binary files a/icons/clean.png and /dev/null differ
diff --git a/icons/cloud.png b/icons/cloud.png
deleted file mode 100644
index 1ed7308..0000000
Binary files a/icons/cloud.png and /dev/null differ
diff --git a/icons/cloudassistant.png b/icons/cloudassistant.png
deleted file mode 100644
index 1ff4b1d..0000000
Binary files a/icons/cloudassistant.png and /dev/null differ
diff --git a/icons/clouddisk.png b/icons/clouddisk.png
deleted file mode 100644
index 3f4feb2..0000000
Binary files a/icons/clouddisk.png and /dev/null differ
diff --git a/icons/clouddrive.png b/icons/clouddrive.png
deleted file mode 100644
index ce78fc4..0000000
Binary files a/icons/clouddrive.png and /dev/null differ
diff --git a/icons/cloudflare.jpg b/icons/cloudflare.jpg
deleted file mode 100644
index c57cbd4..0000000
Binary files a/icons/cloudflare.jpg and /dev/null differ
diff --git a/icons/cloudstrm.png b/icons/cloudstrm.png
deleted file mode 100644
index 3b3eaa3..0000000
Binary files a/icons/cloudstrm.png and /dev/null differ
diff --git a/icons/code.png b/icons/code.png
deleted file mode 100644
index 145190c..0000000
Binary files a/icons/code.png and /dev/null differ
diff --git a/icons/command.png b/icons/command.png
deleted file mode 100644
index f077761..0000000
Binary files a/icons/command.png and /dev/null differ
diff --git a/icons/convert.png b/icons/convert.png
deleted file mode 100644
index 5293068..0000000
Binary files a/icons/convert.png and /dev/null differ
diff --git a/icons/cookiecloud.png b/icons/cookiecloud.png
deleted file mode 100644
index 00c7408..0000000
Binary files a/icons/cookiecloud.png and /dev/null differ
diff --git a/icons/create.png b/icons/create.png
deleted file mode 100644
index 1e95c81..0000000
Binary files a/icons/create.png and /dev/null differ
diff --git a/icons/database.png b/icons/database.png
deleted file mode 100644
index 1073aea..0000000
Binary files a/icons/database.png and /dev/null differ
diff --git a/icons/delete.png b/icons/delete.png
deleted file mode 100644
index efc47ec..0000000
Binary files a/icons/delete.png and /dev/null differ
diff --git a/icons/directory.png b/icons/directory.png
deleted file mode 100644
index 20e5897..0000000
Binary files a/icons/directory.png and /dev/null differ
diff --git a/icons/diskusage.jpg b/icons/diskusage.jpg
deleted file mode 100644
index ceb145d..0000000
Binary files a/icons/diskusage.jpg and /dev/null differ
diff --git a/icons/douban.png b/icons/douban.png
deleted file mode 100644
index 4dfac61..0000000
Binary files a/icons/douban.png and /dev/null differ
diff --git a/icons/download.png b/icons/download.png
deleted file mode 100644
index ac55b0f..0000000
Binary files a/icons/download.png and /dev/null differ
diff --git a/icons/downloadmsg.png b/icons/downloadmsg.png
deleted file mode 100644
index e070dff..0000000
Binary files a/icons/downloadmsg.png and /dev/null differ
diff --git a/icons/emby-icon.png b/icons/emby-icon.png
deleted file mode 100644
index 1f4a9f1..0000000
Binary files a/icons/emby-icon.png and /dev/null differ
diff --git a/icons/emby.png b/icons/emby.png
deleted file mode 100644
index 3eb232b..0000000
Binary files a/icons/emby.png and /dev/null differ
diff --git a/icons/fileupload.png b/icons/fileupload.png
deleted file mode 100644
index 6d3e6cc..0000000
Binary files a/icons/fileupload.png and /dev/null differ
diff --git a/icons/forward.png b/icons/forward.png
deleted file mode 100644
index ccf655f..0000000
Binary files a/icons/forward.png and /dev/null differ
diff --git a/icons/homepage.png b/icons/homepage.png
deleted file mode 100644
index aa475b5..0000000
Binary files a/icons/homepage.png and /dev/null differ
diff --git a/icons/hosts.png b/icons/hosts.png
deleted file mode 100644
index 6cfbb6c..0000000
Binary files a/icons/hosts.png and /dev/null differ
diff --git a/icons/invites.png b/icons/invites.png
deleted file mode 100644
index d3ea6d4..0000000
Binary files a/icons/invites.png and /dev/null differ
diff --git a/icons/iyuu.png b/icons/iyuu.png
deleted file mode 100644
index 1904a7d..0000000
Binary files a/icons/iyuu.png and /dev/null differ
diff --git a/icons/like.jpg b/icons/like.jpg
deleted file mode 100644
index ad08156..0000000
Binary files a/icons/like.jpg and /dev/null differ
diff --git a/icons/login.png b/icons/login.png
deleted file mode 100644
index 5c00763..0000000
Binary files a/icons/login.png and /dev/null differ
diff --git a/icons/media.png b/icons/media.png
deleted file mode 100644
index bc8b5eb..0000000
Binary files a/icons/media.png and /dev/null differ
diff --git a/icons/mediaplay.png b/icons/mediaplay.png
deleted file mode 100644
index d1a1a8b..0000000
Binary files a/icons/mediaplay.png and /dev/null differ
diff --git a/icons/mediasyncdel.png b/icons/mediasyncdel.png
deleted file mode 100644
index f1236a9..0000000
Binary files a/icons/mediasyncdel.png and /dev/null differ
diff --git a/icons/movie.jpg b/icons/movie.jpg
deleted file mode 100644
index 02bf36f..0000000
Binary files a/icons/movie.jpg and /dev/null differ
diff --git a/icons/nfo.png b/icons/nfo.png
deleted file mode 100644
index 37f0276..0000000
Binary files a/icons/nfo.png and /dev/null differ
diff --git a/icons/opensubtitles.png b/icons/opensubtitles.png
deleted file mode 100644
index 85e0099..0000000
Binary files a/icons/opensubtitles.png and /dev/null differ
diff --git a/icons/pluginupdate.png b/icons/pluginupdate.png
deleted file mode 100644
index 33f063f..0000000
Binary files a/icons/pluginupdate.png and /dev/null differ
diff --git a/icons/popular.png b/icons/popular.png
deleted file mode 100644
index 3bf5f15..0000000
Binary files a/icons/popular.png and /dev/null differ
diff --git a/icons/pushdeer.png b/icons/pushdeer.png
deleted file mode 100644
index 771a37a..0000000
Binary files a/icons/pushdeer.png and /dev/null differ
diff --git a/icons/random.png b/icons/random.png
deleted file mode 100644
index 99d8e6a..0000000
Binary files a/icons/random.png and /dev/null differ
diff --git a/icons/refresh.png b/icons/refresh.png
deleted file mode 100644
index d70bb0f..0000000
Binary files a/icons/refresh.png and /dev/null differ
diff --git a/icons/refresh2.png b/icons/refresh2.png
deleted file mode 100644
index 61342d6..0000000
Binary files a/icons/refresh2.png and /dev/null differ
diff --git a/icons/regex.png b/icons/regex.png
deleted file mode 100644
index 178abb4..0000000
Binary files a/icons/regex.png and /dev/null differ
diff --git a/icons/reinstall.png b/icons/reinstall.png
deleted file mode 100644
index cf59ad4..0000000
Binary files a/icons/reinstall.png and /dev/null differ
diff --git a/icons/reminder.png b/icons/reminder.png
deleted file mode 100644
index 001cf87..0000000
Binary files a/icons/reminder.png and /dev/null differ
diff --git a/icons/removetorrent.png b/icons/removetorrent.png
deleted file mode 100644
index 56cb60b..0000000
Binary files a/icons/removetorrent.png and /dev/null differ
diff --git a/icons/rss.png b/icons/rss.png
deleted file mode 100644
index d13b4fc..0000000
Binary files a/icons/rss.png and /dev/null differ
diff --git a/icons/scraper.png b/icons/scraper.png
deleted file mode 100644
index 99d0ba5..0000000
Binary files a/icons/scraper.png and /dev/null differ
diff --git a/icons/seed.png b/icons/seed.png
deleted file mode 100644
index 0aa907a..0000000
Binary files a/icons/seed.png and /dev/null differ
diff --git a/icons/signin.png b/icons/signin.png
deleted file mode 100644
index 005432d..0000000
Binary files a/icons/signin.png and /dev/null differ
diff --git a/icons/sitesafe.png b/icons/sitesafe.png
deleted file mode 100644
index 72e6770..0000000
Binary files a/icons/sitesafe.png and /dev/null differ
diff --git a/icons/softlink.png b/icons/softlink.png
deleted file mode 100644
index 1fb2b9c..0000000
Binary files a/icons/softlink.png and /dev/null differ
diff --git a/icons/softlinkredirect.png b/icons/softlinkredirect.png
deleted file mode 100644
index 28c3870..0000000
Binary files a/icons/softlinkredirect.png and /dev/null differ
diff --git a/icons/sqlite.png b/icons/sqlite.png
deleted file mode 100644
index eb2e910..0000000
Binary files a/icons/sqlite.png and /dev/null differ
diff --git a/icons/statistic.png b/icons/statistic.png
deleted file mode 100644
index 01daf9b..0000000
Binary files a/icons/statistic.png and /dev/null differ
diff --git a/icons/subscribe_reminder.png b/icons/subscribe_reminder.png
deleted file mode 100644
index 6513579..0000000
Binary files a/icons/subscribe_reminder.png and /dev/null differ
diff --git a/icons/subscribeclear.png b/icons/subscribeclear.png
deleted file mode 100644
index a48c9cd..0000000
Binary files a/icons/subscribeclear.png and /dev/null differ
diff --git a/icons/subscribestatistic.png b/icons/subscribestatistic.png
deleted file mode 100644
index cb4a97d..0000000
Binary files a/icons/subscribestatistic.png and /dev/null differ
diff --git a/icons/sync.png b/icons/sync.png
deleted file mode 100644
index 309205b..0000000
Binary files a/icons/sync.png and /dev/null differ
diff --git a/icons/sync_file.png b/icons/sync_file.png
deleted file mode 100644
index 090319b..0000000
Binary files a/icons/sync_file.png and /dev/null differ
diff --git a/icons/synology.png b/icons/synology.png
deleted file mode 100644
index 53b23d3..0000000
Binary files a/icons/synology.png and /dev/null differ
diff --git a/icons/tag.png b/icons/tag.png
deleted file mode 100644
index fd73431..0000000
Binary files a/icons/tag.png and /dev/null differ
diff --git a/icons/teamwork.png b/icons/teamwork.png
deleted file mode 100644
index 3995bfb..0000000
Binary files a/icons/teamwork.png and /dev/null differ
diff --git a/icons/torrent.png b/icons/torrent.png
deleted file mode 100644
index 0bee011..0000000
Binary files a/icons/torrent.png and /dev/null differ
diff --git a/icons/torrenttransfer.jpg b/icons/torrenttransfer.jpg
deleted file mode 100644
index 088aa37..0000000
Binary files a/icons/torrenttransfer.jpg and /dev/null differ
diff --git a/icons/uninstall.png b/icons/uninstall.png
deleted file mode 100644
index 5f36821..0000000
Binary files a/icons/uninstall.png and /dev/null differ
diff --git a/icons/unread.png b/icons/unread.png
deleted file mode 100644
index af247f9..0000000
Binary files a/icons/unread.png and /dev/null differ
diff --git a/icons/update.png b/icons/update.png
deleted file mode 100644
index 5804f34..0000000
Binary files a/icons/update.png and /dev/null differ
diff --git a/icons/upload.png b/icons/upload.png
deleted file mode 100644
index 0f2aba2..0000000
Binary files a/icons/upload.png and /dev/null differ
diff --git a/icons/webhook.png b/icons/webhook.png
deleted file mode 100644
index f52a25f..0000000
Binary files a/icons/webhook.png and /dev/null differ
diff --git a/icons/world.png b/icons/world.png
deleted file mode 100644
index 2322d01..0000000
Binary files a/icons/world.png and /dev/null differ
diff --git a/img/EmbyReporter/img.png b/img/EmbyReporter/img.png
deleted file mode 100644
index f9d4b6d..0000000
Binary files a/img/EmbyReporter/img.png and /dev/null differ
diff --git a/img/EmbyReporter/img_1.png b/img/EmbyReporter/img_1.png
deleted file mode 100644
index 9240b23..0000000
Binary files a/img/EmbyReporter/img_1.png and /dev/null differ
diff --git a/img/HomePage/img.png b/img/HomePage/img.png
deleted file mode 100644
index c7b9ad8..0000000
Binary files a/img/HomePage/img.png and /dev/null differ
diff --git a/package.json b/package.json
index da9ce6a..4b9c220 100644
--- a/package.json
+++ b/package.json
@@ -1,522 +1,4 @@
{
- "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回本地,定时清理无效软连接。",
@@ -535,30 +17,5 @@
"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
deleted file mode 100644
index e5aceca..0000000
--- a/plugins/actorsubscribe/__init__.py
+++ /dev/null
@@ -1,891 +0,0 @@
-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
deleted file mode 100644
index 8cc3716..0000000
--- a/plugins/cd2assistant/__init__.py
+++ /dev/null
@@ -1,1440 +0,0 @@
-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
deleted file mode 100644
index 765fe4c..0000000
--- a/plugins/cd2assistant/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-clouddrive
\ No newline at end of file
diff --git a/plugins/cloudlinkmonitor/__init__.py b/plugins/cloudlinkmonitor/__init__.py
deleted file mode 100644
index 73da69e..0000000
--- a/plugins/cloudlinkmonitor/__init__.py
+++ /dev/null
@@ -1,1008 +0,0 @@
-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
deleted file mode 100644
index 3a3b94c..0000000
--- a/plugins/cloudstrm/__init__.py
+++ /dev/null
@@ -1,735 +0,0 @@
-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
deleted file mode 100644
index 5428d0f..0000000
--- a/plugins/cloudstrmapi/__init__.py
+++ /dev/null
@@ -1,728 +0,0 @@
-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
deleted file mode 100644
index 1dff14c..0000000
--- a/plugins/cloudstrmincrement/__init__.py
+++ /dev/null
@@ -1,746 +0,0 @@
-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
deleted file mode 100644
index ae1ea97..0000000
--- a/plugins/cloudstrmlocal/__init__.py
+++ /dev/null
@@ -1,728 +0,0 @@
-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
deleted file mode 100644
index 8d16d91..0000000
--- a/plugins/commandexecute/__init__.py
+++ /dev/null
@@ -1,242 +0,0 @@
-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
deleted file mode 100644
index fa80495..0000000
--- a/plugins/customcommand/__init__.py
+++ /dev/null
@@ -1,534 +0,0 @@
-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
deleted file mode 100644
index 6df0af4..0000000
--- a/plugins/dirmonitorenhanced/__init__.py
+++ /dev/null
@@ -1,1063 +0,0 @@
-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
deleted file mode 100644
index fb3063f..0000000
--- a/plugins/dockermanager/__init__.py
+++ /dev/null
@@ -1,510 +0,0 @@
-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
deleted file mode 100644
index 1056aad..0000000
--- a/plugins/downloadtorrent/__init__.py
+++ /dev/null
@@ -1,227 +0,0 @@
-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
deleted file mode 100644
index 627a330..0000000
--- a/plugins/embymetarefresh/__init__.py
+++ /dev/null
@@ -1,416 +0,0 @@
-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
deleted file mode 100644
index ff5d36e..0000000
--- a/plugins/embymetatag/__init__.py
+++ /dev/null
@@ -1,462 +0,0 @@
-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
deleted file mode 100644
index 1a7dec0..0000000
--- a/plugins/embyreporter/__init__.py
+++ /dev/null
@@ -1,785 +0,0 @@
-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
deleted file mode 100644
index e75a942..0000000
--- a/plugins/filesoftlink/__init__.py
+++ /dev/null
@@ -1,647 +0,0 @@
-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
deleted file mode 100644
index 8343abe..0000000
--- a/plugins/homepage/__init__.py
+++ /dev/null
@@ -1,648 +0,0 @@
-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
deleted file mode 100644
index d3f96e6..0000000
--- a/plugins/linktosrc/__init__.py
+++ /dev/null
@@ -1,204 +0,0 @@
-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
deleted file mode 100644
index 403b198..0000000
--- a/plugins/pluginautoupdate/__init__.py
+++ /dev/null
@@ -1,574 +0,0 @@
-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
deleted file mode 100644
index a6103d8..0000000
--- a/plugins/pluginreinstall/__init__.py
+++ /dev/null
@@ -1,330 +0,0 @@
-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
deleted file mode 100644
index 689b2ea..0000000
--- a/plugins/pluginuninstall/__init__.py
+++ /dev/null
@@ -1,184 +0,0 @@
-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
deleted file mode 100644
index 58ba1b9..0000000
--- a/plugins/popularsubscribe/__init__.py
+++ /dev/null
@@ -1,953 +0,0 @@
-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
deleted file mode 100644
index 7829507..0000000
--- a/plugins/removetorrent/__init__.py
+++ /dev/null
@@ -1,425 +0,0 @@
-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
deleted file mode 100644
index 04a0640..0000000
--- a/plugins/schedulereminder/__init__.py
+++ /dev/null
@@ -1,186 +0,0 @@
-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
deleted file mode 100644
index 078aba1..0000000
--- a/plugins/shortplaymonitor/__init__.py
+++ /dev/null
@@ -1,1058 +0,0 @@
-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
deleted file mode 100644
index 603fc12..0000000
--- a/plugins/siteunreadmsg/__init__.py
+++ /dev/null
@@ -1,708 +0,0 @@
-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
deleted file mode 100644
index f1e982b..0000000
--- a/plugins/softlinkredirect/__init__.py
+++ /dev/null
@@ -1,212 +0,0 @@
-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
deleted file mode 100644
index d544aa5..0000000
--- a/plugins/sqlexecute/__init__.py
+++ /dev/null
@@ -1,279 +0,0 @@
-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
deleted file mode 100644
index 216b4bf..0000000
--- a/plugins/strmconvert/__init__.py
+++ /dev/null
@@ -1,320 +0,0 @@
-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
deleted file mode 100644
index 89d834c..0000000
--- a/plugins/subscribeclear/__init__.py
+++ /dev/null
@@ -1,122 +0,0 @@
-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
deleted file mode 100644
index d9d4632..0000000
--- a/plugins/subscribegroup/__init__.py
+++ /dev/null
@@ -1,755 +0,0 @@
-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
deleted file mode 100644
index 79e5a99..0000000
--- a/plugins/subscribereminder/__init__.py
+++ /dev/null
@@ -1,284 +0,0 @@
-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
deleted file mode 100644
index 18594cc..0000000
--- a/plugins/subscribestatistic/__init__.py
+++ /dev/null
@@ -1,730 +0,0 @@
-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
deleted file mode 100644
index 768ed9d..0000000
--- a/plugins/synccookiecloud/__init__.py
+++ /dev/null
@@ -1,276 +0,0 @@
-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
deleted file mode 100644
index 6ed1ad7..0000000
--- a/plugins/synologynotify/__init__.py
+++ /dev/null
@@ -1,215 +0,0 @@
-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
deleted file mode 100644
index 5c7e309..0000000
--- a/plugins/wechatforward/__init__.py
+++ /dev/null
@@ -1,1093 +0,0 @@
-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 开始下载"))