diff --git a/README.md b/README.md index ab89f02..d41afab 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,46 @@ MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/ +# 保持插件最新 + +- 安装并开启`插件自动更新`插件,每次重启会更新已安装插件最新版本。也可设置cron定时任务更新插件。 +- 如未刷新到插件更新,或者插件更新版本未变,可用`插件强制重装`插件进行重装。 + ### 插件新增 -- 云盘助手(docs%2FCloudAssistant.md) v1.7 \ No newline at end of file +- 站点数据统计 v1.4 (无未读消息版本)(废弃) +- 站点未读消息 v1.9 (依赖于[站点数据统计]插件) +- [云盘Strm生成 v4.4](docs%2FCloudStrm.md) +- [云盘Strm生成(增量版) v1.0](docs%2FCloudStrmIncrement.md) +- [Strm文件模式转换 v1.0](docs%2FStrmConvert.md) +- 清理订阅缓存 v1.0 +- 添加种子下载 v1.0 +- 删除站点种子 v1.2 +- 插件更新管理 v1.9 +- 插件强制重装 v1.7 +- 群辉Webhook通知 v1.1 +- 同步CookieCloud v1.2 +- 日程提醒 v1.0 +- 订阅提醒 v1.1 +- [Emby观影报告 v1.5](docs%2FEmbyReporter.md) +- 演员订阅 v2.1 +- [短剧刮削 v3.2](docs%2FShortPlayMonitor.md) +- 云盘实时监控 v2.2 +- 源文件恢复 v1.2 +- [微信消息转发 v2.7](docs%2FWeChatForward.md) +- 订阅下载统计 v1.5 +- [自定义命令 v1.7](docs%2FCustomCommand.md) +- docker自定义任务 v1.3 +- 插件彻底卸载 v1.0 +- 实时软连接 v1.8 +- 订阅规则自动填充 v2.7 +- Emby元数据刷新 v1.1 +- Emby媒体标签 v1.2 +- 热门媒体订阅 v1.7 +- [HomePage v1.2](docs%2FHomePage.md) +- 目录监控(统一入库消息增强版) v1.0 +- Sql执行器 v1.2 +- 命令执行器 v1.2 +- 云盘助手(docs%2FCloudAssistant.md) v1.7 +- CloudDrive2助手 v1.1 +- 软连接重定向 v1.0 \ No newline at end of file diff --git a/data/EmbyReporter/res/PingFang Bold.ttf b/data/EmbyReporter/res/PingFang Bold.ttf new file mode 100644 index 0000000..accaf1f Binary files /dev/null and b/data/EmbyReporter/res/PingFang Bold.ttf differ diff --git a/data/EmbyReporter/res/bg/0.jpg b/data/EmbyReporter/res/bg/0.jpg new file mode 100644 index 0000000..91038ee Binary files /dev/null and b/data/EmbyReporter/res/bg/0.jpg differ diff --git a/data/EmbyReporter/res/bg/1.jpg b/data/EmbyReporter/res/bg/1.jpg new file mode 100644 index 0000000..8db3393 Binary files /dev/null and b/data/EmbyReporter/res/bg/1.jpg differ diff --git a/data/EmbyReporter/res/bg/2.jpg b/data/EmbyReporter/res/bg/2.jpg new file mode 100644 index 0000000..5d114ee Binary files /dev/null and b/data/EmbyReporter/res/bg/2.jpg differ diff --git a/data/EmbyReporter/res/bg/3.jpg b/data/EmbyReporter/res/bg/3.jpg new file mode 100644 index 0000000..f964c26 Binary files /dev/null and b/data/EmbyReporter/res/bg/3.jpg differ diff --git a/data/EmbyReporter/res/bg/4.jpg b/data/EmbyReporter/res/bg/4.jpg new file mode 100644 index 0000000..bc665b6 Binary files /dev/null and b/data/EmbyReporter/res/bg/4.jpg differ diff --git a/data/EmbyReporter/res/bg/5.jpg b/data/EmbyReporter/res/bg/5.jpg new file mode 100644 index 0000000..95ef6a5 Binary files /dev/null and b/data/EmbyReporter/res/bg/5.jpg differ diff --git a/data/EmbyReporter/res/bg/6.jpg b/data/EmbyReporter/res/bg/6.jpg new file mode 100644 index 0000000..1343af2 Binary files /dev/null and b/data/EmbyReporter/res/bg/6.jpg differ diff --git a/data/EmbyReporter/res/cover-ranks-mask-2.png b/data/EmbyReporter/res/cover-ranks-mask-2.png new file mode 100644 index 0000000..335c61b Binary files /dev/null and b/data/EmbyReporter/res/cover-ranks-mask-2.png differ diff --git a/docs/CloudStrm.md b/docs/CloudStrm.md new file mode 100644 index 0000000..29a8869 --- /dev/null +++ b/docs/CloudStrm.md @@ -0,0 +1,25 @@ +# 云盘strm生成 + +### 使用说明 + +目录监控格式: + +- 1.监控目录#目的目录#媒体服务器内源文件路径 +- 2.监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址 +- 3.监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址 + +路径: + +- 监控目录:源文件目录即云盘挂载到MoviePilot中的路径 +- 目的路径:MoviePilot中strm生成路径 +- 媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径 + +示例: + +- MoviePilot上云盘源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- MoviePilot上strm生成路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm` + +- 媒体服务器内源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- 监控配置为:/mount/cloud/aliyun/emby#/mnt/link/aliyun#/mount/cloud/aliyun/emby diff --git a/docs/CloudStrmIncrement.md b/docs/CloudStrmIncrement.md new file mode 100644 index 0000000..dbb8b71 --- /dev/null +++ b/docs/CloudStrmIncrement.md @@ -0,0 +1,42 @@ +# 云盘strm生成(增量版) + +### 使用说明 + +目录监控格式: + +- 1.增量目录#监控目录#目的目录#媒体服务器内源文件路径 +- 2.增量目录#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址 +- 3.增量目录#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址 + +路径: + +- 增量目录:转存到云盘的路径,插件只会扫描该路径下的文件,移动到监控路径,生成目的路径的strm文件 +- 监控目录:源文件目录即云盘挂载到MoviePilot中的路径 +- 目的路径:MoviePilot中strm生成路径 +- 媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径 + +示例: + +- 增量目录:/increment`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- MoviePilot上云盘源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- MoviePilot上strm生成路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm` + +- 媒体服务器内源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- 监控配置为:/increment#/mount/cloud/aliyun/emby#/mnt/link/aliyun#/mount/cloud/aliyun/emby + + +保留路径: + +扫描到增量目录的文件,会移动到监控目录,并生成目的路径的strm文件,删除空的增量目录,如果想保留某些父目录,可以将它们添加到保留路径中。 + +例如: + +/increment/series/庆余年/Season 1/1.第一集.mp4 + +保留路径为series + +则文件移动到目的路径名后,会删除庆余年/Season 1,父路径/increment/series保留 + diff --git a/docs/CustomCommand.md b/docs/CustomCommand.md new file mode 100644 index 0000000..0d5c7ed --- /dev/null +++ b/docs/CustomCommand.md @@ -0,0 +1,11 @@ +# 自定义命令 + +### 使用说明 + +默认把python脚本最后一个print作为返回值 + +命令名#0 9 * * *#python main.py +命令名#0 9 * * *#python main.py#1-600 + + +1-600为随机延时,单位秒 \ No newline at end of file diff --git a/docs/EmbyReporter.md b/docs/EmbyReporter.md new file mode 100644 index 0000000..034b2f6 --- /dev/null +++ b/docs/EmbyReporter.md @@ -0,0 +1,31 @@ +# Emby观影报告 + +### 使用说明 + +**注意**:需 `Emby` 安装 `Playback Report` 插件 + +将本项目**下载到本地**,并将 `/data/EmbyReporter/res` 下文件路径映射到 `MoviePilot` 容器可访问的目录下,如 `/config/plugins/EmbyReporter` + +
+ 具体步骤 + + 1. 下载源码:`git clone https://github.com/thsrite/MoviePilot-Plugins.git` 或者从网页直接下载并解压 + 2. 复制 `/data/EmbyReporter/res` 到容器可访问目录,如 `/config/plugins/EmbyReporter` + 3. 配置该插件的素材路径 `/config/plugins/EmbyReporter/`,如下面图中所示 + 4. 立即运行一次,如果网络正常,`tg` 通道已配置的话,`tg` 即可收到推送 + +
+ +![img.png](../img/EmbyReporter/img.png) +![img_1.png](../img/EmbyReporter/img_1.png) + +每日一言推荐 +`` +https://v.api.aa1.cn/api/yiyan/index.php +https://yijuzhan.com/api/word.php +`` + +点点推荐舔狗 +`` +https://v.api.aa1.cn/api/tiangou/index.php +`` diff --git a/docs/HomePage.md b/docs/HomePage.md new file mode 100644 index 0000000..78c51d1 --- /dev/null +++ b/docs/HomePage.md @@ -0,0 +1,51 @@ +# HomePage自定义API + +![img.png](../img/HomePage/img.png) + +HomePage services.yaml配置 +```angular2html +- Media: + - MoviePilot: + icon: /icons/icon/MoviePilot.png + href: http://MoviePilot_IP:NGINX_PORT + ping: http://MoviePilot_IP:NGINX_PORT + # server: unraid + # container: MoviePilot + showStats: true + widget: + type: customapi + url: http://MoviePilot_IP:NGINX_PORT/api/v1/plugin/HomePage/statistic?apikey=api_token + method: GET + mappings: + - field: movie_subscribes + label: 电影订阅 + - field: tv_subscribes + label: 电视剧订阅 + - field: movie_count + label: 电影数量 + - field: tv_count + label: 电视剧数量 + # - field: episode_count + # label: 电影剧集数量 + # - field: user_count + # label: 用户数量 + # - field: total_storage + # label: 总空间 + # - field: free_storage + # label: 剩余空间 + # - field: now_tasks +``` + +### 自定义API Response字段 +- movie_subscribes: 电影订阅 +- tv_subscribes: 电视剧订阅 +- movie_count: 电影数量 +- tv_count: 电视剧数量 +- episode_count: 电影剧集数量 +- user_count: 用户数量 +- total_storage: 总空间 +- used_storage: 已用空间 +- free_storage: 剩余空间 + +### HomePage自定义API文档 +https://gethomepage.dev/latest/widgets/services/customapi/#custom-request-body \ No newline at end of file diff --git a/docs/ShortPlayMonitor.md b/docs/ShortPlayMonitor.md new file mode 100644 index 0000000..9b59840 --- /dev/null +++ b/docs/ShortPlayMonitor.md @@ -0,0 +1,17 @@ +# 短剧刮削 + +### 使用说明 + +监控方式: + +- fast:性能模式,内部处理系统操作类型选择最优解 +- compatibility:兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB (建议使用) + +是否重命名 + +- true 自定义识别词 +- false +- smart 我看着取 (尝试从agsv、萝莉站获取封面) + +封面比例: +2:3 \ No newline at end of file diff --git a/docs/StrmConvert.md b/docs/StrmConvert.md new file mode 100644 index 0000000..f47c673 --- /dev/null +++ b/docs/StrmConvert.md @@ -0,0 +1,18 @@ +# Strm文件模式转换 + +### 使用说明 + +#### 本地模式 +- MoviePilot上strm视频路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm` +- 云盘源文件挂载本地后 挂载`进媒体服务器的路径`,与上方对应 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- 转换配置为:`/mnt/link/aliyun#/mount/cloud/aliyun/emby` + +#### API模式 +- MoviePilot上strm视频根路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm` +- cd2挂载后路径 /aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` + +- 转换配置为:`/mnt/link/aliyun#/aliyun/emby#cd2#192.168.31.103:19798` + + +## 具体自己多尝试吧。 \ No newline at end of file diff --git a/docs/WeChatForward.md b/docs/WeChatForward.md new file mode 100644 index 0000000..1cfbd5e --- /dev/null +++ b/docs/WeChatForward.md @@ -0,0 +1,37 @@ +# 微信消息转发 + +### 使用说明 + +#### 消息转发插件加强版 + +根据正则表达式将对应title的消息转发到不同的企业微信应用上 + +企业微信应用配置与正则表达式一一对应(一行对应一行) + +如果某条消息不想指定userid发送,则填写忽略userid正则表达式. + +#### 额外消息配置 + +`开始下载 > userid > 后台下载任务已提交,请耐心等候入库通知。 > appid` + +`已添加订阅 > userid > 电视剧正在更新,已添加订阅,待更新后自动下载。 > appid` + +中间用` > `分割 + +消息title匹配到`开始下载`的正则 + +且消息text中的`用户:`匹配到userid, + +则发送`后台下载任务已提交,请耐心等候入库通知。`额外通知。 + +发送给appid为`appid`的企业微信应用。(环境变量配置或者本插件配置均可。) + +#### 特定消息指定用户 + +`title正则 > text|title正则 > userid` + +当要发送的消息的`title`和`text|title`均匹配正则,则强制指定该消息的userid + +#### 2.0版本兼容旧版配置 + +如更新到2.0版本,设置微信配置界面配置没有格式化,无须担心,重启下即可(不重启功能也正常使用)。 \ No newline at end of file diff --git a/icons/SpeedLimiter.jpg b/icons/SpeedLimiter.jpg new file mode 100644 index 0000000..c295f91 Binary files /dev/null and b/icons/SpeedLimiter.jpg differ diff --git a/icons/actor.png b/icons/actor.png new file mode 100644 index 0000000..da023fb Binary files /dev/null and b/icons/actor.png differ diff --git a/icons/autosubtitles.jpeg b/icons/autosubtitles.jpeg new file mode 100644 index 0000000..bdd1232 Binary files /dev/null and b/icons/autosubtitles.jpeg differ diff --git a/icons/backup.png b/icons/backup.png new file mode 100644 index 0000000..efef44f Binary files /dev/null and b/icons/backup.png differ diff --git a/icons/bark.webp b/icons/bark.webp new file mode 100644 index 0000000..0d6bb2f Binary files /dev/null and b/icons/bark.webp differ diff --git a/icons/broom.png b/icons/broom.png new file mode 100644 index 0000000..98cb4ec Binary files /dev/null and b/icons/broom.png differ diff --git a/icons/brush.jpg b/icons/brush.jpg new file mode 100644 index 0000000..8d79a06 Binary files /dev/null and b/icons/brush.jpg differ diff --git a/icons/chatgpt.png b/icons/chatgpt.png new file mode 100644 index 0000000..2bff26e Binary files /dev/null and b/icons/chatgpt.png differ diff --git a/icons/chinesesubfinder.png b/icons/chinesesubfinder.png new file mode 100644 index 0000000..5ba3ea5 Binary files /dev/null and b/icons/chinesesubfinder.png differ diff --git a/icons/clean.png b/icons/clean.png new file mode 100644 index 0000000..fcebe9d Binary files /dev/null and b/icons/clean.png differ diff --git a/icons/cloud.png b/icons/cloud.png new file mode 100644 index 0000000..1ed7308 Binary files /dev/null and b/icons/cloud.png differ diff --git a/icons/cloudassistant.png b/icons/cloudassistant.png new file mode 100644 index 0000000..1ff4b1d Binary files /dev/null and b/icons/cloudassistant.png differ diff --git a/icons/clouddisk.png b/icons/clouddisk.png new file mode 100644 index 0000000..3f4feb2 Binary files /dev/null and b/icons/clouddisk.png differ diff --git a/icons/clouddrive.png b/icons/clouddrive.png new file mode 100644 index 0000000..ce78fc4 Binary files /dev/null and b/icons/clouddrive.png differ diff --git a/icons/cloudflare.jpg b/icons/cloudflare.jpg new file mode 100644 index 0000000..c57cbd4 Binary files /dev/null and b/icons/cloudflare.jpg differ diff --git a/icons/cloudstrm.png b/icons/cloudstrm.png new file mode 100644 index 0000000..3b3eaa3 Binary files /dev/null and b/icons/cloudstrm.png differ diff --git a/icons/code.png b/icons/code.png new file mode 100644 index 0000000..145190c Binary files /dev/null and b/icons/code.png differ diff --git a/icons/command.png b/icons/command.png new file mode 100644 index 0000000..f077761 Binary files /dev/null and b/icons/command.png differ diff --git a/icons/convert.png b/icons/convert.png new file mode 100644 index 0000000..5293068 Binary files /dev/null and b/icons/convert.png differ diff --git a/icons/cookiecloud.png b/icons/cookiecloud.png new file mode 100644 index 0000000..00c7408 Binary files /dev/null and b/icons/cookiecloud.png differ diff --git a/icons/create.png b/icons/create.png new file mode 100644 index 0000000..1e95c81 Binary files /dev/null and b/icons/create.png differ diff --git a/icons/database.png b/icons/database.png new file mode 100644 index 0000000..1073aea Binary files /dev/null and b/icons/database.png differ diff --git a/icons/delete.png b/icons/delete.png new file mode 100644 index 0000000..efc47ec Binary files /dev/null and b/icons/delete.png differ diff --git a/icons/directory.png b/icons/directory.png new file mode 100644 index 0000000..20e5897 Binary files /dev/null and b/icons/directory.png differ diff --git a/icons/diskusage.jpg b/icons/diskusage.jpg new file mode 100644 index 0000000..ceb145d Binary files /dev/null and b/icons/diskusage.jpg differ diff --git a/icons/douban.png b/icons/douban.png new file mode 100644 index 0000000..4dfac61 Binary files /dev/null and b/icons/douban.png differ diff --git a/icons/download.png b/icons/download.png new file mode 100644 index 0000000..ac55b0f Binary files /dev/null and b/icons/download.png differ diff --git a/icons/downloadmsg.png b/icons/downloadmsg.png new file mode 100644 index 0000000..e070dff Binary files /dev/null and b/icons/downloadmsg.png differ diff --git a/icons/emby-icon.png b/icons/emby-icon.png new file mode 100644 index 0000000..1f4a9f1 Binary files /dev/null and b/icons/emby-icon.png differ diff --git a/icons/emby.png b/icons/emby.png new file mode 100644 index 0000000..3eb232b Binary files /dev/null and b/icons/emby.png differ diff --git a/icons/fileupload.png b/icons/fileupload.png new file mode 100644 index 0000000..6d3e6cc Binary files /dev/null and b/icons/fileupload.png differ diff --git a/icons/forward.png b/icons/forward.png new file mode 100644 index 0000000..ccf655f Binary files /dev/null and b/icons/forward.png differ diff --git a/icons/homepage.png b/icons/homepage.png new file mode 100644 index 0000000..aa475b5 Binary files /dev/null and b/icons/homepage.png differ diff --git a/icons/hosts.png b/icons/hosts.png new file mode 100644 index 0000000..6cfbb6c Binary files /dev/null and b/icons/hosts.png differ diff --git a/icons/invites.png b/icons/invites.png new file mode 100644 index 0000000..d3ea6d4 Binary files /dev/null and b/icons/invites.png differ diff --git a/icons/iyuu.png b/icons/iyuu.png new file mode 100644 index 0000000..1904a7d Binary files /dev/null and b/icons/iyuu.png differ diff --git a/icons/like.jpg b/icons/like.jpg new file mode 100644 index 0000000..ad08156 Binary files /dev/null and b/icons/like.jpg differ diff --git a/icons/login.png b/icons/login.png new file mode 100644 index 0000000..5c00763 Binary files /dev/null and b/icons/login.png differ diff --git a/icons/media.png b/icons/media.png new file mode 100644 index 0000000..bc8b5eb Binary files /dev/null and b/icons/media.png differ diff --git a/icons/mediaplay.png b/icons/mediaplay.png new file mode 100644 index 0000000..d1a1a8b Binary files /dev/null and b/icons/mediaplay.png differ diff --git a/icons/mediasyncdel.png b/icons/mediasyncdel.png new file mode 100644 index 0000000..f1236a9 Binary files /dev/null and b/icons/mediasyncdel.png differ diff --git a/icons/movie.jpg b/icons/movie.jpg new file mode 100644 index 0000000..02bf36f Binary files /dev/null and b/icons/movie.jpg differ diff --git a/icons/nfo.png b/icons/nfo.png new file mode 100644 index 0000000..37f0276 Binary files /dev/null and b/icons/nfo.png differ diff --git a/icons/opensubtitles.png b/icons/opensubtitles.png new file mode 100644 index 0000000..85e0099 Binary files /dev/null and b/icons/opensubtitles.png differ diff --git a/icons/pluginupdate.png b/icons/pluginupdate.png new file mode 100644 index 0000000..33f063f Binary files /dev/null and b/icons/pluginupdate.png differ diff --git a/icons/popular.png b/icons/popular.png new file mode 100644 index 0000000..3bf5f15 Binary files /dev/null and b/icons/popular.png differ diff --git a/icons/pushdeer.png b/icons/pushdeer.png new file mode 100644 index 0000000..771a37a Binary files /dev/null and b/icons/pushdeer.png differ diff --git a/icons/random.png b/icons/random.png new file mode 100644 index 0000000..99d8e6a Binary files /dev/null and b/icons/random.png differ diff --git a/icons/refresh.png b/icons/refresh.png new file mode 100644 index 0000000..d70bb0f Binary files /dev/null and b/icons/refresh.png differ diff --git a/icons/refresh2.png b/icons/refresh2.png new file mode 100644 index 0000000..61342d6 Binary files /dev/null and b/icons/refresh2.png differ diff --git a/icons/regex.png b/icons/regex.png new file mode 100644 index 0000000..178abb4 Binary files /dev/null and b/icons/regex.png differ diff --git a/icons/reinstall.png b/icons/reinstall.png new file mode 100644 index 0000000..cf59ad4 Binary files /dev/null and b/icons/reinstall.png differ diff --git a/icons/reminder.png b/icons/reminder.png new file mode 100644 index 0000000..001cf87 Binary files /dev/null and b/icons/reminder.png differ diff --git a/icons/removetorrent.png b/icons/removetorrent.png new file mode 100644 index 0000000..56cb60b Binary files /dev/null and b/icons/removetorrent.png differ diff --git a/icons/rss.png b/icons/rss.png new file mode 100644 index 0000000..d13b4fc Binary files /dev/null and b/icons/rss.png differ diff --git a/icons/scraper.png b/icons/scraper.png new file mode 100644 index 0000000..99d0ba5 Binary files /dev/null and b/icons/scraper.png differ diff --git a/icons/seed.png b/icons/seed.png new file mode 100644 index 0000000..0aa907a Binary files /dev/null and b/icons/seed.png differ diff --git a/icons/signin.png b/icons/signin.png new file mode 100644 index 0000000..005432d Binary files /dev/null and b/icons/signin.png differ diff --git a/icons/sitesafe.png b/icons/sitesafe.png new file mode 100644 index 0000000..72e6770 Binary files /dev/null and b/icons/sitesafe.png differ diff --git a/icons/softlink.png b/icons/softlink.png new file mode 100644 index 0000000..1fb2b9c Binary files /dev/null and b/icons/softlink.png differ diff --git a/icons/softlinkredirect.png b/icons/softlinkredirect.png new file mode 100644 index 0000000..28c3870 Binary files /dev/null and b/icons/softlinkredirect.png differ diff --git a/icons/sqlite.png b/icons/sqlite.png new file mode 100644 index 0000000..eb2e910 Binary files /dev/null and b/icons/sqlite.png differ diff --git a/icons/statistic.png b/icons/statistic.png new file mode 100644 index 0000000..01daf9b Binary files /dev/null and b/icons/statistic.png differ diff --git a/icons/subscribe_reminder.png b/icons/subscribe_reminder.png new file mode 100644 index 0000000..6513579 Binary files /dev/null and b/icons/subscribe_reminder.png differ diff --git a/icons/subscribeclear.png b/icons/subscribeclear.png new file mode 100644 index 0000000..a48c9cd Binary files /dev/null and b/icons/subscribeclear.png differ diff --git a/icons/subscribestatistic.png b/icons/subscribestatistic.png new file mode 100644 index 0000000..cb4a97d Binary files /dev/null and b/icons/subscribestatistic.png differ diff --git a/icons/sync.png b/icons/sync.png new file mode 100644 index 0000000..309205b Binary files /dev/null and b/icons/sync.png differ diff --git a/icons/sync_file.png b/icons/sync_file.png new file mode 100644 index 0000000..090319b Binary files /dev/null and b/icons/sync_file.png differ diff --git a/icons/synology.png b/icons/synology.png new file mode 100644 index 0000000..53b23d3 Binary files /dev/null and b/icons/synology.png differ diff --git a/icons/tag.png b/icons/tag.png new file mode 100644 index 0000000..fd73431 Binary files /dev/null and b/icons/tag.png differ diff --git a/icons/teamwork.png b/icons/teamwork.png new file mode 100644 index 0000000..3995bfb Binary files /dev/null and b/icons/teamwork.png differ diff --git a/icons/torrent.png b/icons/torrent.png new file mode 100644 index 0000000..0bee011 Binary files /dev/null and b/icons/torrent.png differ diff --git a/icons/torrenttransfer.jpg b/icons/torrenttransfer.jpg new file mode 100644 index 0000000..088aa37 Binary files /dev/null and b/icons/torrenttransfer.jpg differ diff --git a/icons/uninstall.png b/icons/uninstall.png new file mode 100644 index 0000000..5f36821 Binary files /dev/null and b/icons/uninstall.png differ diff --git a/icons/unread.png b/icons/unread.png new file mode 100644 index 0000000..af247f9 Binary files /dev/null and b/icons/unread.png differ diff --git a/icons/update.png b/icons/update.png new file mode 100644 index 0000000..5804f34 Binary files /dev/null and b/icons/update.png differ diff --git a/icons/upload.png b/icons/upload.png new file mode 100644 index 0000000..0f2aba2 Binary files /dev/null and b/icons/upload.png differ diff --git a/icons/webhook.png b/icons/webhook.png new file mode 100644 index 0000000..f52a25f Binary files /dev/null and b/icons/webhook.png differ diff --git a/icons/world.png b/icons/world.png new file mode 100644 index 0000000..2322d01 Binary files /dev/null and b/icons/world.png differ diff --git a/img/EmbyReporter/img.png b/img/EmbyReporter/img.png new file mode 100644 index 0000000..f9d4b6d Binary files /dev/null and b/img/EmbyReporter/img.png differ diff --git a/img/EmbyReporter/img_1.png b/img/EmbyReporter/img_1.png new file mode 100644 index 0000000..9240b23 Binary files /dev/null and b/img/EmbyReporter/img_1.png differ diff --git a/img/HomePage/img.png b/img/HomePage/img.png new file mode 100644 index 0000000..c7b9ad8 Binary files /dev/null and b/img/HomePage/img.png differ diff --git a/package.json b/package.json index 4b9c220..da9ce6a 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,522 @@ { + "CloudStrm": { + "name": "云盘Strm生成", + "description": "监控文件创建,生成Strm文件。", + "labels": "云盘", + "version": "4.4", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png", + "author": "thsrite", + "level": 1, + "history": { + "v4.4": "修复bug", + "v4.3": "回滚自定义媒体类型", + "v4.2": "扩展名转小写", + "v4.1": "支持自定义媒体类型", + "v4.0": "回归老版本", + "v3.8": "支持增量路径、支持自定义媒体类型(注:本次更新需修改配置使用)", + "v3.7": "api模式支持启用https", + "v3.6": "支持重建索引周期运行", + "v3.4": "交互命令", + "v3.1": "注册交互命令、注册公共服务", + "v3.0": "实现改为定时扫描" + } + }, + "CloudStrmIncrement": { + "name": "云盘Strm生成(增量版)", + "description": "监控文件创建,生成Strm文件(增量版)。", + "labels": "云盘", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "增量监控" + } + }, + "StrmConvert": { + "name": "Strm文件模式转换", + "description": "Strm文件内容转为本地路径或者cd2/alist API路径。", + "labels": "云盘", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/convert.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "Strm文件内容转为本地路径或者cd2/alist API路径" + } + }, + "SiteUnreadMsg": { + "name": "站点未读消息", + "description": "发送站点未读消息。", + "labels": "站点", + "version": "1.9", + "icon": "Synomail_A.png", + "author": "thsrite", + "level": 2, + "history": { + "v1.9": "同步主仓库", + "v1.8": "自定义保留消息天数", + "v1.7": "删除重复代码、依赖于[站点数据统计]插件", + "v1.6": "增加解析失败日志", + "v1.5": "修复馒头未读消息1", + "v1.4": "sync主仓库", + "v1.3": "feat mtorrent", + "v1.2": "站点消息历史存库", + "v1.1": "防止同一消息重复发送", + "v1.0": "定时获取站点消息" + } + }, + "SubscribeClear": { + "name": "清理订阅缓存", + "description": "清理订阅已下载集数。", + "labels": "订阅", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/broom.png", + "author": "thsrite", + "level": 2, + "history": { + "v1.0": "清理订阅已下载集数" + } + }, + "DownloadTorrent": { + "name": "添加种子下载", + "description": "选择下载器,添加种子任务。", + "labels": "站点", + "version": "1.0", + "icon": "download.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "删除下载器中该站点辅种,保留该站点没有辅种的种子" + } + }, + "RemoveTorrent": { + "name": "删除站点种子", + "description": "删除下载器中某站点种子。", + "labels": "站点", + "version": "1.2", + "icon": "delete.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "修复删除种子bug", + "v1.1": "可选择删除有无辅种", + "v1.0": "选择下载器,添加种子任务" + } + }, + "PluginAutoUpdate": { + "name": "插件更新管理", + "description": "监测已安装插件,推送更新提醒,可配置自动更新。", + "labels": "自动更新,插件管理", + "version": "1.9", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/pluginupdate.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.9": "过滤相同ID插件,保留最新版本检查更新", + "v1.8": "修复已安装插件列表", + "v1.7": "插件API立即生效", + "v1.6": "插件重载,插件自动更新注册成为服务、命令", + "v1.5": "自动更新增加排除列表", + "v1.4": "正在运行的插件跳过更新,可选更新插件列表", + "v1.3": "配置更新提醒", + "v1.2": "重启后立即执行一遍更新插件", + "v1.1": "修复插件重载", + "v1.0": "监测已安装插件,自动更新最新版本" + } + }, + "PluginReInstall": { + "name": "插件强制重装", + "description": "卸载当前插件,强制重装。", + "labels": "插件管理", + "version": "1.7", + "icon": "refresh.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.7": "使用主程序GITHUB_PROXY代理", + "v1.6": "插件API立即生效", + "v1.5": "支持插件热重载", + "v1.4": "支持代理地址", + "v1.3": "插件重载", + "v1.2": "支持指定插件仓库地址", + "v1.1": "修复插件重载", + "v1.0": "卸载当前插件,强制重装" + } + }, + "SynologyNotify": { + "name": "群辉Webhook通知", + "description": "接收群辉webhook通知并推送。", + "labels": "消息通知", + "version": "1.1", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/synology.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.1": "修复bug", + "v1.0": "接收群辉webhook通知并推送" + } + }, + "SyncCookieCloud": { + "name": "同步CookieCloud", + "description": "同步MoviePilot站点Cookie到本地CookieCloud。", + "labels": "站点", + "version": "1.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cookiecloud.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "同步到本地CookieCloud", + "v1.1": "修复CookieCloud覆盖到浏览器", + "v1.0": "同步MoviePilot站点Cookie到CookieCloud" + } + }, + "ScheduleReminder": { + "name": "日程提醒", + "description": "自定义提醒事项、提醒时间。", + "labels": "消息通知", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/reminder.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "自定义提醒事项、提醒时间" + } + }, + "SubscribeReminder": { + "name": "订阅提醒", + "description": "推送当天订阅更新内容。", + "labels": "订阅", + "version": "1.1", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribe_reminder.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.1": "fix icon", + "v1.0": "推送当天订阅更新内容" + } + }, + "EmbyReporter": { + "name": "Emby观影报告", + "description": "推送Emby观影报告,需Emby安装Playback Report 插件。", + "labels": "Emby", + "version": "1.5", + "icon": "Pydiocells_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.5": "按观影市场排序", + "v1.4": "支持自定义emby && 支持每日一言", + "v1.3": "修复bug", + "v1.2": "过滤已删除媒体", + "v1.1": "修复推送", + "v1.0": "推送Emby观影报告" + } + }, + "ActorSubscribe": { + "name": "演员订阅", + "description": "自动订阅指定演员热映电影、电视剧。", + "labels": "订阅", + "version": "2.1", + "icon": "Mdcng_A.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.1": "逻辑优化", + "v2.0": "修复订阅", + "v1.8": "支持自定义订阅username,默认`演员订阅`", + "v1.7": "修复bug", + "v1.6": "增加历史删除按钮", + "v1.5": "rename", + "v1.4": "支持多个订阅源", + "v1.3": "修复bug", + "v1.2": "修复订阅重复处理的bug", + "v1.1": "支持自定义分辨率、质量、特效", + "v1.0": "自动订阅豆瓣演员最新电影" + } + }, + "ShortPlayMonitor": { + "name": "短剧刮削", + "description": "监控视频短剧创建,刮削。", + "labels": "刮削", + "version": "3.2", + "icon": "Amule_B.png", + "author": "thsrite", + "level": 1, + "history": { + "v3.2": "支持消息发送", + "v3.1": "支持自定义转移方式", + "v3.0": "默认从tmdb刮削,刮削失败则从pt站刮削" + } + }, + "CloudLinkMonitor": { + "name": "云盘实时监控", + "description": "监控云盘目录文件变化,自动转移链接。", + "labels": "云盘", + "version": "2.2", + "icon": "Linkease_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.2": "优化配置一二级分类流程", + "v2.1": "可配置是否存储转移记录", + "v2.0": "修复不刮削不生效bug", + "v1.8": "fix S00转移", + "v1.7": "fix 刮削", + "v1.6": "可配置是否刮削", + "v1.5": "fix 消息推送", + "v1.4": "fix 转移后路径", + "v1.3": "修复bug", + "v1.2": "修复订阅重复处理的bug", + "v1.1": "自动转移链接(不刮削)", + "v1.0": "监控云盘目录文件变化,按原文件名软连接" + } + }, + "LinkToSrc": { + "name": "源文件恢复", + "description": "根据MoviePilot的转移记录中的硬链文件恢复源文件。", + "labels": "媒体库", + "version": "1.2", + "icon": "Time_machine_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "fix 路径", + "v1.1": "支持指定需要恢复的硬链接目录", + "v1.0": "根据MoviePilot的转移记录中的硬链文件恢复源文件" + } + }, + "WeChatForward": { + "name": "微信消息转发", + "description": "根据正则转发通知到其他WeChat应用。", + "labels": "消息通知", + "version": "2.7", + "icon": "Wechat_A.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.7": "特殊消息指定用户支持title匹配", + "v2.6": "已完成订阅额外消息查询订阅历史订阅用户", + "v2.5.1": "修复token过期重发未存储userid问题", + "v2.5": "增强额外消息发送", + "v2.4": "修复配置修改后不重建缓存bug", + "v2.3": "增加重建缓存,丰富转发历史", + "v2.2": "增加消息发送历史", + "v2.1": "微信配置持久化存库", + "v2.0": "优化微信配置,兼容旧版本配置", + "v1.6": "修改获取指定用户订阅列表方法", + "v1.5": "丰富日志", + "v1.4": "特定消息强制指定userid", + "v1.3": "防重复发送额外消息", + "v1.2": "fix规则", + "v1.1": "自定义发送额外消息", + "v1.0": "根据正则转发通知到其他WeChat应用" + } + }, + "SubscribeStatistic": { + "name": "订阅下载统计", + "description": "统计指定时间内各站点订阅及下载情况。", + "labels": "订阅", + "version": "1.5", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribestatistic.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.5": "增加消息推送", + "v1.4": "无订阅站点也统计数量", + "v1.3": "fix 数据统计", + "v1.2": "fix 订阅数量", + "v1.1": "站点去重", + "v1.0": "统计指定时间内各站点订阅及下载情况" + } + }, + "CustomCommand": { + "name": "自定义命令", + "description": "自定义执行周期执行命令并推送结果。", + "labels": "自定义命令", + "version": "1.7", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/code.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.7": "自定义通知关键词", + "v1.6": "自定义保留消息天数", + "v1.5": "修复多个任务立即运行一次", + "v1.4": "fix icon", + "v1.3": "清除历史记录", + "v1.2": "增加执行历史", + "v1.1": "打印命令日志", + "v1.0": "自定义执行周期执行命令并推送结果" + } + }, + "DockerManager": { + "name": "docker自定义任务", + "description": "管理宿主机docker,自定义容器定时任务。", + "labels": "自定义命令", + "version": "1.3", + "icon": "Docker_F.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.3": "自定义保留消息天数", + "v1.2": "多个容器名,拼接", + "v1.1": "修复多个任务立即运行一次", + "v1.0": "init" + } + }, + "PluginUnInstall": { + "name": "插件彻底卸载", + "description": "删除数据库中已安装插件记录、清理插件文件。", + "labels": "插件管理", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "init" + } + }, + "FileSoftLink": { + "name": "实时软连接", + "description": "监控目录文件变化,媒体文件软连接,其他文件可选复制。", + "labels": "文件管理", + "version": "1.8", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.8": "修复bug", + "v1.6": "bug修复", + "v1.5": "优化性能,提高处理速度", + "v1.4": "支持自定义视频格式", + "v1.3": "异步启动" + } + }, + "SubscribeGroup": { + "name": "订阅规则自动填充", + "description": "电视剧下载后自动添加官组等信息到订阅;添加订阅后根据二级分类名称自定义订阅规则。", + "labels": "订阅", + "version": "2.7", + "icon": "teamwork.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.7": "下载填充判断当前站点是否在已选订阅站点范围内", + "v2.6": "兼容属性值包含:号", + "v2.5": "操作历史Unicode编码转中文", + "v2.4": "保存路径支持变量{name} (订阅名称 (年份))", + "v2.3": "二级分类自定义填充支持保存路径", + "v2.1": "站点与官组分开,修复质量无填充", + "v2.0": "种子下载自定义填充支持自定义占位符", + "v1.8": "修复种子下载不填充bug", + "v1.7": "操作历史Unicode编码转中文", + "v1.6": "支持一行配置多个二级分类名称", + "v1.5": "支持操作历史", + "v1.4": "支持根据二级分类名称自定义订阅规则", + "v1.3": "增加质量、分辨率、特效信息填充", + "v1.2": "修复订阅已存在包含关键词和订阅站点" + } + }, + "EmbyMetaRefresh": { + "name": "Emby元数据刷新", + "description": "定时刷新Emby媒体库元数据。", + "labels": "Emby", + "version": "1.1", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.1": "添加远程交互命令", + "v1.0": "定时刷新Emby媒体库元数据" + } + }, + "EmbyMetaTag": { + "name": "Emby媒体标签", + "description": "自动给媒体库媒体添加标签。", + "labels": "Emby", + "version": "1.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/tag.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "支持指定特殊媒体名称添加标签", + "v1.1": "添加远程交互命令", + "v1.0": "自动给媒体库媒体添加标签" + } + }, + "PopularSubscribe": { + "name": "热门媒体订阅", + "description": "自定添加热门媒体到订阅。", + "labels": "订阅", + "version": "1.7", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/popular.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.7": "调整订阅用户名,默认:热门订阅", + "v1.6": "调整历史unique唯一索引(可删除本次更新后的历史)", + "v1.5": "修复电视剧订阅、订阅历史展示", + "v1.4": "动漫单独订阅(本子佬启动!)", + "v1.3": "增加立即运行、历史删除按钮", + "v1.2": "增加历史删除按钮", + "v1.1": "修正流行度校验", + "v1.0": "自定添加热门媒体到订阅" + } + }, + "HomePage": { + "name": "HomePage", + "description": "HomePage自定义API。", + "labels": "工具", + "version": "1.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/homepage.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "适配v1.9.1-beta(不生效就重启)", + "v1.1": "支持更多返回值、插件展示数据", + "v1.0": "HomePage自定义API" + } + }, + "DirMonitorEnhanced": { + "name": "目录监控", + "description": "监控目录文件发生变化时实时整理到媒体库。(统一入库消息增强版)(测试中-.-)", + "labels": "文件整理", + "version": "1.0", + "icon": "directory.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.0": "同步merge主仓库[目录监控]插件,增加统一发送消息逻辑(Testing…)" + } + }, + "SqlExecute": { + "name": "Sql执行器", + "description": "自定义MoviePilot数据库Sql执行。", + "labels": "工具", + "version": "1.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/sqlite.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "调整交互命令返回信息", + "v1.1": "支持交互命令/sql [command]执行,需主程序1.9.4+", + "v1.0": "自定义MoviePilot数据库Sql执行" + } + }, + "CommandExecute": { + "name": "命令执行器", + "description": "自定义容器命令执行。", + "labels": "工具", + "version": "1.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/command.png", + "author": "thsrite", + "level": 1, + "history": { + "v1.2": "调整交互命令返回信息", + "v1.1": "支持交互命令/cmd [sql]执行,需主程序1.9.4+", + "v1.0": "自定义容器命令执行" + } + }, "CloudAssistant": { "name": "云盘助手", "description": "本地文件定时转移到云盘,软连接/strm回本地,定时清理无效软连接。", @@ -17,5 +535,30 @@ "v1.1": "支持cd2上传、支持定时清理无效软连接、支持strm生成方式", "v1.0": "定时移动到云盘,软连接回本地(清理无效软连接暂未开发)" } + }, + "Cd2Assistant": { + "name": "CloudDrive2助手", + "description": "监控上传任务,检测是否有异常,发送通知。", + "labels": "云盘", + "version": "1.1", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/clouddrive.png", + "author": "thsrite", + "level": 2, + "history": { + "v1.1": "交互命令重启cd2、获取cd2系统信息,支持仪表盘", + "v1.0": "监控上传任务,检测是否有异常,发送通知" + } + }, + "SoftLinkRedirect": { + "name": "软连接重定向", + "description": "重定向软连接指向。", + "labels": "云盘", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlinkredirect.png", + "author": "thsrite", + "level": 2, + "history": { + "v1.0": "重定向软连接指向" + } } } diff --git a/plugins/actorsubscribe/__init__.py b/plugins/actorsubscribe/__init__.py new file mode 100644 index 0000000..e5aceca --- /dev/null +++ b/plugins/actorsubscribe/__init__.py @@ -0,0 +1,891 @@ +import time +from datetime import datetime, timedelta + +import pytz + +from app import schemas +from app.chain.douban import DoubanChain +from app.chain.tmdb import TmdbChain +from app.chain.download import DownloadChain +from app.chain.subscribe import SubscribeChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.schemas import MediaType +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + + +class ActorSubscribe(_PluginBase): + # 插件名称 + plugin_name = "演员订阅" + # 插件描述 + plugin_desc = "自动订阅指定演员热映电影、电视剧。" + # 插件图标 + plugin_icon = "Mdcng_A.png" + # 插件版本 + plugin_version = "2.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "actorsubscribe_" + # 加载顺序 + plugin_order = 25 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _cron: str = "" + _actors = None + subscribechain = None + downloadchain = None + _scheduler: Optional[BackgroundScheduler] = None + _quality = None + _resolution = None + _effect = None + _username = None + _clear = False + _clear_already_handle = False + _source = ["douban_showing"] + # 质量选择框数据 + _qualityOptions = { + '全部': '', + '蓝光原盘': 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD', + 'Remux': 'Remux', + 'BluRay': 'Blu-?Ray', + 'UHD': 'UHD|UltraHD', + 'WEB-DL': 'WEB-?DL|WEB-?RIP', + 'HDTV': 'HDTV', + 'H265': '[Hx].?265|HEVC', + 'H264': '[Hx].?264|AVC' + } + + # 分辨率选择框数据 + _resolutionOptions = { + '全部': '', + '4k': '4K|2160p|x2160', + '1080p': '1080[pi]|x1080', + '720p': '720[pi]|x720' + } + + # 特效选择框数据 + _effectOptions = { + '全部': '', + '杜比视界': 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+', + '杜比全景声': 'Dolby[\\s.]*\\+?Atmos|Atmos', + 'HDR': '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+', + 'SDR': '[\\s.]+SDR[\\s.]+', + } + + def init_plugin(self, config: dict = None): + self.downloadchain = DownloadChain() + self.subscribechain = SubscribeChain() + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._actors = config.get("actors") + self._quality = config.get("quality") + self._resolution = config.get("resolution") + self._effect = config.get("effect") + self._clear = config.get("clear") + self._clear_already_handle = config.get("clear_already_handle") + self._source = config.get("source") + self._username = config.get("username") or '演员订阅' + + # 清理插件订阅历史 + if self._clear: + self.del_data(key="history") + + self._clear = False + self.__update_config() + logger.info("订阅历史清理完成") + + # 清理已处理历史 + if self._clear_already_handle: + self.del_data(key="already_handle") + + self._clear_already_handle = False + self.__update_config() + logger.info("已处理历史清理完成") + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"明星热映订阅服务启动,立即运行一次") + self._scheduler.add_job(self.__actor_subscribe, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="明星热映订阅") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.__actor_subscribe, + trigger=CronTrigger.from_crontab(self._cron), + name="明星热映订阅") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __actor_subscribe(self): + """ + 明星热映订阅 + """ + if not self._actors: + logger.warn("暂无订阅明星,停止运行") + return + + history: List[dict] = self.get_data('history') or [] + already_handle: List[dict] = self.get_data('already_handle') or [] + + medias = [] + for source in self._source: + if source.strip() == "douban_showing": + medias += self.__douban_movie_showing() + elif source.strip() == "douban_movies": + medias += self.__douban_movies() + elif source.strip() == "douban_tvs": + medias += self.__douban_tvs() + elif source.strip() == "douban_movie_top250": + medias += self.__douban_movie_top250() + elif source.strip() == "douban_tv_weekly_chinese": + medias += self.__douban_tv_weekly_chinese() + elif source.strip() == "douban_tv_weekly_global": + medias += self.__douban_tv_weekly_global() + elif source.strip() == "douban_tv_animation": + medias += self.__douban_tv_animation() + elif source.strip() == "douban_movie_hot": + medias += self.__douban_movie_hot() + elif source.strip() == "douban_tv_hot": + medias += self.__douban_tv_hot() + elif source.strip() == "tmdb_movies": + medias += self.__tmdb_movies() + elif source.strip() == "tmdb_tvs": + medias += self.__tmdb_tvs() + elif source.strip() == "tmdb_trending": + medias += self.__tmdb_trending() + else: + logger.warn(f"未知的订阅源:{source}") + + # 检查订阅 + subscribe_actors = str(self._actors).split(",") + for mediainfo in medias: + if mediainfo.title_year in already_handle: + logger.info(f"{mediainfo.type.value} {mediainfo.title_year} 已被处理,跳过") + continue + + already_handle.append(mediainfo.title_year) + logger.info(f"开始处理电影 {mediainfo.title_year}") + + mediainfo_actors = [] + if mediainfo.actors or mediainfo.directors: + mediainfo_actors = mediainfo.actors + mediainfo.directors + + # 元数据 + meta = MetaInfo(mediainfo.title) + + # 判断有无tmdbid + if not mediainfo.tmdb_id: + oldmediainfo = mediainfo + # 主要获取tmdbid + mediainfo = self.chain.recognize_media(meta=meta, mtype=mediainfo.type) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{oldmediainfo.title},豆瓣ID:{oldmediainfo.douban_id}') + continue + + oldmediainfo.tmdb_id = mediainfo.tmdb_id + mediainfo = oldmediainfo + + # 演员中文名 + if not mediainfo_actors: + # 查询豆瓣中文演员名 + mediainfo_actors += self.__get_douban_actors(mediainfo) + + if not mediainfo_actors: + logger.warn(f'未识别到演员信息,标题:{mediainfo.title},{mediainfo.tmdb_id or mediainfo.douban_id}') + continue + + logger.info(f'获取到 {mediainfo.title} 演员:{mediainfo_actors}') + + # 查询缺失的媒体信息 + exist_flag, _ = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) + if exist_flag: + logger.info(f'{mediainfo.title_year} 媒体库中已存在') + continue + + # 判断用户是否已经添加订阅 + if self.subscribechain.exists(mediainfo=mediainfo): + logger.info(f'{mediainfo.title_year} 订阅已存在') + continue + + if mediainfo_actors: + is_subscribe = False + for actor in mediainfo_actors: + # logger.info(f'正在处理 {mediainfo.title_year} 演员 {actor}') + if actor and actor in subscribe_actors: + # 开始订阅 + logger.info( + f"{mediainfo.type.value} {mediainfo.title_year} TMDBID {mediainfo.tmdb_id} DOUBANID {mediainfo.douban_id} 命中订阅演员 {actor}," + f"开始订阅。订阅规则:{self._quality} {self._resolution} {self._effect} {self._username}") + is_subscribe = True + # 添加订阅 + self.subscribechain.add(title=mediainfo.title, + year=mediainfo.year, + mtype=mediainfo.type, + tmdbid=mediainfo.tmdb_id, + doubanid=mediainfo.douban_id, + exist_ok=True, + quality=self._quality, + resolution=self._resolution, + effect=self._effect, + username=self._username) + # 存储历史记录 + history.append({ + "title": mediainfo.title, + "type": mediainfo.type.value, + "year": mediainfo.year, + "poster": mediainfo.get_poster_image(), + "overview": mediainfo.overview, + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "unique": f"actorsubscribe: {mediainfo.title} (DB:{mediainfo.tmdb_id})" + }) + + if not is_subscribe: + logger.info( + f"{mediainfo.type.value} {mediainfo.title_year} TMDBID {mediainfo.tmdb_id} DOUBANID {mediainfo.douban_id} 未命中订阅演员,跳过") + + # 保存历史记录 + self.save_data('history', history) + self.save_data('already_handle', already_handle) + logger.info(f"演员订阅任务完成") + + def __get_douban_actors(self, mediainfo: MediaInfo, season: int = None) -> List[dict]: + """ + 获取豆瓣演员信息 + """ + sleep_time = 3 + int(time.time()) % 7 + logger.debug(f"随机休眠 {sleep_time}秒 ...") + time.sleep(sleep_time) + if mediainfo.douban_id: + doubanitem = DoubanChain().douban_info(mediainfo.douban_id) or {} + else: + # 匹配豆瓣信息 + doubaninfo = DoubanChain().match_doubaninfo(name=mediainfo.title, + imdbid=mediainfo.imdb_id, + mtype=mediainfo.type, + year=mediainfo.year, + season=season) + # 豆瓣演员 + if doubaninfo: + mediainfo.douban_id = doubaninfo.get("id") + doubanitem = DoubanChain().douban_info(doubaninfo.get("id")) or {} + else: + doubanitem = None + + if doubanitem: + actors = (doubanitem.get("actors") or []) + (doubanitem.get("directors") or []) + return [actor.get("name") for actor in actors] + else: + logger.debug(f"未找到豆瓣信息:{mediainfo.title_year}") + return [] + + def __douban_movie_showing(self): + """ + 豆瓣正在热映 + """ + movies = DoubanChain().movie_showing(page=1, count=30) + if not movies: + return [] + medias = [media for media in movies] + logger.info(f"获取到豆瓣正在热映 {len(medias)} 部") + return medias + + def __douban_movies(self): + """ + 豆瓣电影 + """ + movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE, + sort="R", tags="", page=1, count=30) + if not movies: + return [] + medias = [media for media in movies] + logger.info(f"获取到豆瓣电影 {len(medias)} 部") + return medias + + def __douban_tvs(self): + """ + 豆瓣剧集 + """ + tvs = DoubanChain().douban_discover(mtype=MediaType.TV, + sort="R", tags="", page=1, count=30) + if not tvs: + return [] + medias = [media for media in tvs] + logger.info(f"获取到豆瓣剧集 {len(medias)} 部") + return medias + + def __douban_movie_top250(self): + """ + 豆瓣电影TOP250 + """ + movies = DoubanChain().movie_top250(page=1, count=30) + if not movies: + return [] + medias = [media for media in movies] + logger.info(f"获取到豆瓣电影TOP250 {len(medias)} 部") + return medias + + def __douban_tv_weekly_chinese(self): + """ + 豆瓣国产剧集周榜 + """ + tvs = DoubanChain().tv_weekly_chinese(page=1, count=30) + if not tvs: + return [] + medias = [media for media in tvs] + logger.info(f"获取到豆瓣国产剧集周榜 {len(medias)} 部") + return medias + + def __douban_tv_weekly_global(self): + """ + 全球每周剧集口碑榜 + """ + tvs = DoubanChain().tv_weekly_global(page=1, count=30) + if not tvs: + return [] + medias = [media for media in tvs] + logger.info(f"获取到全球每周剧集口碑榜 {len(medias)} 部") + return medias + + def __douban_tv_animation(self): + """ + 豆瓣动画剧集 + """ + tvs = DoubanChain().tv_animation(page=1, count=30) + if not tvs: + return [] + medias = [media for media in tvs] + logger.info(f"获取到豆瓣动画剧集 {len(medias)} 部") + return medias + + def __douban_movie_hot(self): + """ + 豆瓣热门电影 + """ + movies = DoubanChain().movie_hot(page=1, count=30) + if not movies: + return [] + medias = [media for media in movies] + logger.info(f"获取到豆瓣热门电影 {len(medias)} 部") + return medias + + def __douban_tv_hot(self): + """ + 豆瓣热门电视剧 + """ + tvs = DoubanChain().tv_hot(page=1, count=30) + if not tvs: + return [] + medias = [media for media in tvs] + logger.info(f"获取到豆瓣热门电视剧 {len(medias)} 部") + return medias + + def __tmdb_movies(self): + """ + TMDB电影 + """ + movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE, + sort_by="popularity.desc", + with_genres="", + with_original_language="", + page=1) + if not movies: + return [] + medias = [movie for movie in movies] + logger.info(f"获取到TMDB电影 {len(medias)} 部") + return medias + + def __tmdb_tvs(self): + """ + TMDB剧集 + """ + tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV, + sort_by="popularity.desc", + with_genres="", + with_original_language="", + page=1) + if not tvs: + return [] + medias = [tv for tv in tvs] + logger.info(f"获取到TMDB剧集 {len(medias)} 部") + return medias + + def __tmdb_trending(self): + """ + TMDB流行趋势 + """ + tvs = TmdbChain().tmdb_trending(page=1) + if not tvs: + return [] + medias = [tv for tv in tvs] + logger.info(f"获取到TMDB流行趋势 {len(medias)} 部") + return medias + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "actors": self._actors, + "quality": self._quality, + "resolution": self._resolution, + "effect": self._effect, + "clear": self._clear, + "clear_already_handle": self._clear_already_handle, + "source": self._source, + "username": self._username, + }) + + def delete_history(self, key: str, apikey: str): + """ + 删除同步历史记录 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + # 历史记录 + historys = self.get_data('history') + if not historys: + return schemas.Response(success=False, message="未找到历史记录") + # 删除指定记录 + historys = [h for h in historys if h.get("unique") != key] + self.save_data('history', historys) + return schemas.Response(success=True, message="删除成功") + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/delete_history", + "endpoint": self.delete_history, + "methods": ["GET"], + "summary": "删除订阅历史记录" + } + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + qualityOptions = [{"title": i, "value": self._qualityOptions.get(i)} for i in self._qualityOptions.keys()] + resolutionOptions = [{"title": i, "value": self._resolutionOptions.get(i)} for i in + self._resolutionOptions.keys()] + effectOptions = [{"title": i, "value": self._effectOptions.get(i)} for i in self._effectOptions.keys()] + + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清理订阅记录', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_already_handle', + 'label': '清理已处理记录', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 9 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'actors', + 'label': '明星', + 'placeholder': '多个英文逗号分割' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'quality', + 'label': '质量', + 'items': qualityOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'resolution', + 'label': '分辨率', + 'items': resolutionOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'effect', + 'label': '特效', + 'items': effectOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'username', + 'label': '订阅用户', + 'placeholder': '默认为`演员订阅`' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'source', + 'label': '订阅来源', + 'items': [ + {'title': '豆瓣正在热映', 'value': 'douban_showing'}, + {'title': '豆瓣电影', 'value': 'douban_movies'}, + {'title': '豆瓣剧集', 'value': 'douban_tvs'}, + {'title': '豆瓣电影TOP250', 'value': 'douban_movie_top250'}, + {'title': '豆瓣国产剧集周榜', 'value': 'douban_tv_weekly_chinese'}, + {'title': '豆瓣全球剧集周榜', 'value': 'douban_tv_weekly_global'}, + {'title': '豆瓣动画剧集', 'value': 'douban_tv_animation'}, + {'title': '豆瓣热门电影', 'value': 'douban_movie_hot'}, + {'title': '豆瓣热门电视剧', 'value': 'douban_tv_hot'}, + {'title': 'TMDB电影', 'value': 'tmdb_movies'}, + {'title': 'TMDB剧集', 'value': 'tmdb_tvs'}, + {'title': 'TMDB流行趋势', 'value': 'tmdb_trending'}, + ] + } + } + ] + }, + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + "actors": "", + "quality": "", + "resolution": "", + "effect": "", + "username": "演员订阅", + "clear": False, + "clear_already_handle": False, + "source": ["douban_showing"] + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询历史记录 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + title = history.get("title") + poster = history.get("poster") + mtype = history.get("type") + time_str = history.get("time") + tmdbid = history.get("tmdbid") + doubanid = history.get("doubanid") + contents.append( + { + 'component': 'VCard', + 'content': [ + { + "component": "VDialogCloseBtn", + "props": { + 'innerClass': 'absolute top-0 right-0', + }, + 'events': { + 'click': { + 'api': 'plugin/ActorSubscribe/delete_history', + 'method': 'get', + 'params': { + 'key': f"actorsubscribe: {title} (DB:{tmdbid})", + 'apikey': settings.API_TOKEN + } + } + }, + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': poster, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'VCardSubtitle', + 'props': { + 'class': 'pa-2 font-bold break-words whitespace-break-spaces' + }, + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"https://movie.douban.com/subject/{doubanid}", + 'target': '_blank' + }, + 'text': title + } + ] + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{mtype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{time_str}' + } + ] + } + ] + } + ] + } + ) + + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/cd2assistant/__init__.py b/plugins/cd2assistant/__init__.py new file mode 100644 index 0000000..8cc3716 --- /dev/null +++ b/plugins/cd2assistant/__init__.py @@ -0,0 +1,1440 @@ +import re +from datetime import datetime, timedelta + +import pytz +from clouddrive import CloudDriveClient, Client + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.schemas import NotificationType +from app.schemas.types import EventType + +class Cd2Assistant(_PluginBase): + # 插件名称 + plugin_name = "CloudDrive2助手" + # 插件描述 + plugin_desc = "监控上传任务,检测是否有异常,发送通知。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/clouddrive.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cd2assistant_" + # 加载顺序 + plugin_order = 5 + # 可使用的用户级别 + auth_level = 2 + + # 任务执行间隔 + _enabled = False + _onlyonce: bool = False + _cd2_restart: bool = False + _cron = None + _notify = False + _msgtype = None + _keyword = None + _cd2_url = None + _cd2_username = None + _cd2_password = None + _cd2_client = None + _client = None + + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._msgtype = config.get("msgtype") + self._onlyonce = config.get("onlyonce") + self._cd2_restart = config.get("cd2_restart") + self._cron = config.get("cron") + self._keyword = config.get("keyword") + self._cd2_url = config.get("cd2_url") + self._cd2_username = config.get("cd2_username") + self._cd2_password = config.get("cd2_password") + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce or self._cd2_restart: + if not self._cd2_url or not self._cd2_username or not self._cd2_password: + logger.error("CloudDrive2助手配置错误,请检查配置") + return + + self._cd2_client = CloudDriveClient(self._cd2_url, self._cd2_username, self._cd2_password) + if not self._cd2_client: + logger.error("CloudDrive2助手连接失败,请检查配置") + return + + self._client = Client(self._cd2_url, self._cd2_username, self._cd2_password) + if not self._client: + logger.error("CloudDrive2助手连接失败,请检查配置") + return + + # 周期运行 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._cron: + try: + self._scheduler.add_job(func=self.check, + trigger=CronTrigger.from_crontab(self._cron), + name="CloudDrive2助手定时任务") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 立即运行一次 + if self._onlyonce: + logger.info(f"CloudDrive2助手定时任务,立即运行一次") + self._scheduler.add_job(self.check, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="CloudDrive2助手定时任务") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 立即运行一次 + if self._cd2_restart: + logger.info(f"CloudDrive2重启任务,立即运行一次") + self._scheduler.add_job(self.restart_cd2(), 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="CloudDrive2重启任务") + # 关闭一次性开关 + self._cd2_restart = False + + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cd2_restart": self._cd2_restart, + "cron": self._cron, + "msgtype": self._msgtype, + "keyword": self._keyword, + "notify": self._notify, + "cd2_url": self._cd2_url, + "cd2_username": self._cd2_username, + "cd2_password": self._cd2_password, + }) + + def check(self): + """ + 检查上传任务 + """ + logger.info("开始检查CloudDrive2上传任务") + # 获取上传任务列表 + upload_tasklist = self._cd2_client.upload_tasklist.list(page=0, page_size=10, filter="") + if not upload_tasklist: + logger.info("没有发现上传任务") + return + + for task in upload_tasklist: + if task.get("status") == "FatalError" and self._keyword and re.search(self._keyword, + task.get("errorMessage")): + logger.info(f"发现异常上传任务:{task.get('errorMessage')}") + # 发送通知 + if self._notify: + self.__send_notify(task) + break + + @eventmanager.register(EventType.PluginAction) + def restart_cd2(self, event: Event = None): + """ + 重启CloudDrive2 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cd2_restart": + return + + logger.info("CloudDrive2重启成功") + if event: + self.post_message(channel=event.event_data.get("channel"), + title="CloudDrive2重启成功!", userid=event.event_data.get("user")) + + self._client.RestartService() + + + @eventmanager.register(EventType.PluginAction) + def cd2_info(self, event: Event = None): + """ + 获取CloudDrive2信息 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cd2_info": + return + + # 运行信息 + system_info = self._client.GetRunningInfo() + if system_info: + pattern = re.compile(r'(\w+): ([\d.]+)') + matches = pattern.findall(str(system_info)) + # 将匹配到的结果转换为字典 + system_info = {key: float(value) for key, value in matches} + + # 上传任务数量 + upload_count = self._client.GetUploadFileCount() + # 下载任务数量 + download_count = self._client.GetDownloadFileCount() + + system_info_dict = { + "cpuUsage": f"{system_info.get('cpuUsage'):.2f}%" if system_info.get( + "cpuUsage") else "0.00%" if system_info else None, + "memUsageKB": f"{system_info.get('memUsageKB') / 1024:.2f}MB" if system_info.get( + "memUsageKB") else "0MB" if system_info else None, + "uptime": self.convert_seconds(system_info.get('uptime')) if system_info.get( + "uptime") else "0秒" if system_info else None, + "fhTableCount": system_info.get('fhTableCount') if system_info.get( + "fhTableCount") else 0 if system_info else None, + "dirCacheCount": int(system_info.get('dirCacheCount')) if system_info.get( + "dirCacheCount") else 0 if system_info else None, + "tempFileCount": system_info.get('tempFileCount') if system_info.get( + "tempFileCount") else 0 if system_info else None, + "upload_count": str(upload_count).replace("fileCount: ", "") or 0 if upload_count and "fileCount" in str( + upload_count) else 0, + "download_count": str(download_count).replace("fileCount: ", + "") or 0 if download_count and "fileCount" in str( + download_count) else 0, + } + + logger.info(f"获取CloudDrive2系统信息:\n{system_info_dict}") + + if event: + self.post_message(channel=event.event_data.get("channel"), + title="CloudDrive2系统信息", + userid=event.event_data.get("user"), + text=f"CPU占用:{system_info_dict.get('cpuUsage')}\n" + f"内存占用:{system_info_dict.get('memUsageKB')}\n" + f"运行时间:{system_info_dict.get('uptime')}\n" + f"打开文件数量:{system_info_dict.get('fhTableCount')}\n" + f"目录缓存数量:{system_info_dict.get('dirCacheCount')}\n" + f"临时文件数量:{system_info_dict.get('tempFileCount')}\n" + f"上传任务数量:{system_info_dict.get('upload_count')}\n" + f"下载任务数量:{system_info_dict.get('download_count')}\n") + + return system_info_dict + + def __send_notify(self, task): + """ + 发送通知 + """ + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + self.post_message(title="CloudDrive2助手通知", + mtype=mtype, + text=task.get("errorMessage")) + + @staticmethod + def convert_seconds(seconds): + days, seconds = divmod(seconds, 86400) # 86400秒 = 1天 + hours, seconds = divmod(seconds, 3600) # 3600秒 = 1小时 + minutes, seconds = divmod(seconds, 60) # 60秒 = 1分钟 + parts = [] + if days > 0: + parts.append(f"{int(days)}天") + if hours > 0: + parts.append(f"{int(hours)}小时") + if minutes > 0: + parts.append(f"{int(minutes)}分钟") + if seconds > 0 or not parts: # 添加秒数或只有秒数时 + parts.append(f"{seconds:.0f}秒") + + return ''.join(parts) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/cd2_restart", + "event": EventType.PluginAction, + "desc": "CloudDrive2重启", + "category": "", + "data": { + "action": "cd2_restart" + } + }, + { + "cmd": "/cd2_info", + "event": EventType.PluginAction, + "desc": "CloudDrive2系统信息", + "category": "", + "data": { + "action": "cd2_info" + } + } + ] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'cd2_restart', + 'label': 'cd2重启一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cd2_url', + 'label': 'cd2地址', + 'placeholder': 'http://127.0.0.1:19798' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cd2_username', + 'label': 'cd2用户名' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cd2_password', + 'label': 'cd2密码' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '检测周期', + 'placeholder': '5位cron表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'keyword', + 'label': '检测关键字' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '周期检测CloudDrive2上传任务,检测是否命中检测关键词,发送通知。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "cd2_restart": False, + "cron": "*/10 * * * *", + "keyword": "账号异常", + "cd2_url": "", + "cd2_username": "", + "cd2_password": "", + "msgtype": "Manual" + } + + def get_page(self) -> List[dict]: + cd2_info = self.cd2_info() + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': 'CPU占用' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('cpuUsage') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '内存占用' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('memUsageKB') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '运行时间' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('uptime') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '打开文件数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('fhTableCount') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '缓存目录数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('dirCacheCount') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '临时文件数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('tempFileCount') + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '下载任务数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('download_count') + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '上传任务数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('upload_count') + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }] + + def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、全局配置参考: + { + "refresh": 10 // 自动刷新时间,单位秒 + } + 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + """ + # 列配置 + cols = { + "cols": 12, + "md": 8 + } + # 全局配置 + attrs = { + "refresh": 10 + } + if not self._client: + logger.warn(f"请求CloudDrive2服务失败") + elements = [ + { + 'component': 'div', + 'text': '无法连接CloudDrive2', + 'props': { + 'class': 'text-center', + } + } + ] + else: + """ + Active connections: 62 + server accepts handled requests + 468843 468843 1368256 + Reading: 0 Writing: 1 Waiting: 61 + """ + cd2_info = self.cd2_info() + elements = [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': 'CPU占用' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('cpuUsage') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '内存占用' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('memUsageKB') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '运行时间' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('uptime') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '打开文件数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('fhTableCount') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '缓存目录数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('dirCacheCount') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '临时文件数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('tempFileCount') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '下载任务数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('download_count') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '上传任务数' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': cd2_info.get('upload_count') + } + ] + } + ] + } + ] + } + ] + }, + ] + } + ] + }] + + return cols, attrs, elements + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/cd2assistant/requirements.txt b/plugins/cd2assistant/requirements.txt new file mode 100644 index 0000000..765fe4c --- /dev/null +++ b/plugins/cd2assistant/requirements.txt @@ -0,0 +1 @@ +clouddrive \ No newline at end of file diff --git a/plugins/cloudlinkmonitor/__init__.py b/plugins/cloudlinkmonitor/__init__.py new file mode 100644 index 0000000..73da69e --- /dev/null +++ b/plugins/cloudlinkmonitor/__init__.py @@ -0,0 +1,1008 @@ +import datetime +import re +import shutil +import threading +import traceback +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver + +from app import schemas +from app.chain.tmdb import TmdbChain +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.core.metainfo import MetaInfoPath +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.log import logger +from app.modules.filetransfer import FileTransferModule +from app.plugins import _PluginBase +from app.schemas import Notification, NotificationType, TransferInfo +from app.schemas.types import EventType, MediaType, SystemConfigKey +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.event_handler(event=event, text="创建", + mon_path=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.sync.event_handler(event=event, text="移动", + mon_path=self._watch_path, event_path=event.dest_path) + + +class CloudLinkMonitor(_PluginBase): + # 插件名称 + plugin_name = "云盘实时监控" + # 插件描述 + plugin_desc = "监控云盘目录文件变化,自动转移链接。" + # 插件图标 + plugin_icon = "Linkease_A.png" + # 插件版本 + plugin_version = "2.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudlinkmonitor_" + # 加载顺序 + plugin_order = 4 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + transferhis = None + downloadhis = None + transferchian = None + tmdbchain = None + _observer = [] + _enabled = False + _notify = False + _onlyonce = False + _cron = None + filetransfer = None + _size = 0 + # 模式 compatibility/fast + _mode = "compatibility" + # 转移方式 + _transfer_type = settings.TRANSFER_TYPE + _monitor_dirs = "" + _exclude_keywords = "" + _interval: int = 10 + # 存储源目录与目的目录关系 + _dirconf: Dict[str, Optional[Path]] = {} + # 存储源目录转移方式 + _transferconf: Dict[str, Optional[str]] = {} + _scraperconf: Dict[str, Optional[bool]] = {} + _historyconf: Dict[str, Optional[bool]] = {} + _categoryconf: Dict[str, Optional[bool]] = {} + _medias = {} + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + self.transferhis = TransferHistoryOper() + self.downloadhis = DownloadHistoryOper() + self.transferchian = TransferChain() + self.tmdbchain = TmdbChain() + self.filetransfer = FileTransferModule() + # 清空配置 + self._dirconf = {} + self._transferconf = {} + self._scraperconf = {} + self._historyconf = {} + self._categoryconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._mode = config.get("mode") + self._transfer_type = config.get("transfer_type") + self._monitor_dirs = config.get("monitor_dirs") or "" + self._exclude_keywords = config.get("exclude_keywords") or "" + self._interval = config.get("interval") or 10 + self._cron = config.get("cron") + self._size = config.get("size") or 0 + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + + # 读取目录配置 + monitor_dirs = self._monitor_dirs.split("\n") + if not monitor_dirs: + return + for mon_path in monitor_dirs: + # 格式源目录:目的目录 + if not mon_path: + continue + + # 是否添加一级二级分类 + _category = True + if mon_path.count("@") == 1: + _category = mon_path.split("@")[1] + _category = True if _category == "True" else False + mon_path = mon_path.split("@")[0] + + # 是否存储历史记录 + _history = True + if mon_path.count("%") == 1: + _history = mon_path.split("%")[1] + _history = True if _history == "True" else False + mon_path = mon_path.split("%")[0] + + # 是否刮削 + _scraper_type = False + if mon_path.count("$") == 1: + _scraper_type = mon_path.split("$")[1] + _scraper_type = True if _scraper_type == "True" else False + mon_path = mon_path.split("$")[0] + + # 自定义转移方式 + _transfer_type = self._transfer_type + if mon_path.count("#") == 1: + _transfer_type = mon_path.split("#")[1] + mon_path = mon_path.split("#")[0] + + # 存储目的目录 + if SystemUtils.is_windows(): + if mon_path.count(":") > 1: + paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], + mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] + else: + paths = [mon_path] + else: + paths = mon_path.split(":") + + # 目的目录 + target_path = None + if len(paths) > 1: + mon_path = paths[0] + target_path = Path(paths[1]) + self._dirconf[mon_path] = target_path + else: + self._dirconf[mon_path] = None + + # 是否二级分类 + self._categoryconf[mon_path] = _category + + # 是否存历史 + self._historyconf[mon_path] = _history + + # 是否刮削 + self._scraperconf[mon_path] = _scraper_type + + # 转移方式 + self._transferconf[mon_path] = _transfer_type + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and target_path.is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if self._mode == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{mon_path} 的目录监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") + self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}") + + # 运行一次定时服务 + if self._onlyonce: + logger.info("云盘实时监控服务启动,立即运行一次") + self._scheduler.add_job(name="云盘实时监控", + func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "mode": self._mode, + "transfer_type": self._transfer_type, + "monitor_dirs": self._monitor_dirs, + "exclude_keywords": self._exclude_keywords, + "interval": self._interval, + "cron": self._cron, + "size": self._size + }) + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程全量同步 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cloud_link_sync": + return + self.post_message(channel=event.event_data.get("channel"), + title="开始同步监控目录 ...", + userid=event.event_data.get("user")) + self.sync_all() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="监控目录同步完成!", userid=event.event_data.get("user")) + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步监控目录 ...") + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): + self.__handle_file(event_path=str(file_path), mon_path=mon_path) + logger.info("全量同步监控目录完成!") + + def event_handler(self, event, mon_path: str, text: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param mon_path: 监控目录 + :param text: 事件描述 + :param event_path: 事件文件路径 + """ + if not event.is_directory: + # 文件发生变化 + logger.debug("文件%s:%s" % (text, event_path)) + self.__handle_file(event_path=event_path, mon_path=mon_path) + + def __handle_file(self, event_path: str, mon_path: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param mon_path: 监控目录 + """ + file_path = Path(event_path) + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + transfer_history = self.transferhis.get_by_src(event_path) + if transfer_history: + logger.debug("文件已处理过:%s" % event_path) + return + + # 回收站及隐藏的文件不处理 + if event_path.find('/@Recycle/') != -1 \ + or event_path.find('/#recycle/') != -1 \ + or event_path.find('/.') != -1 \ + or event_path.find('/@eaDir') != -1: + logger.debug(f"{event_path} 是回收站或隐藏的文件") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 整理屏蔽词不处理 + transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) + if transfer_exclude_words: + for keyword in transfer_exclude_words: + if not keyword: + continue + if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): + logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") + return + + # 不是媒体文件不处理 + if file_path.suffix not in settings.RMT_MEDIAEXT: + logger.debug(f"{event_path} 不是媒体文件") + return + + # 判断是不是蓝光目录 + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + # 查询历史记录,已转移的不处理 + if self.transferhis.get_by_src(str(file_path)): + logger.info(f"{file_path} 已整理过") + return + + # 元数据 + file_meta = MetaInfoPath(file_path) + if not file_meta.name: + logger.error(f"{file_path.name} 无法识别有效信息") + return + + # 判断文件大小 + if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: + logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") + return + + # 查询转移目的目录 + target: Path = self._dirconf.get(mon_path) + # 查询转移方式 + transfer_type = self._transferconf.get(mon_path) + # 是否刮削 + scraper_type = self._scraperconf.get(mon_path) + # 是否存历史 + history_type = self._historyconf.get(mon_path) + # 是否添加二级分类 + category_type = self._categoryconf.get(mon_path) + + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') + # 新增转移成功历史记录 + his = self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + meta=file_meta + ) + if self._notify: + self.post_message( + mtype=NotificationType.Manual, + title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" + f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" + ) + return + + # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title + if not settings.SCRAP_FOLLOW_TMDB: + transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, + mtype=mediainfo.type.value) + if transfer_history: + mediainfo.title = transfer_history.title + logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") + + # 获取集数据 + if mediainfo.type == MediaType.TV: + episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, + season=1 if file_meta.begin_season is None else file_meta.begin_season) + else: + episodes_info = None + + if category_type: + # 转移 + transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, + path=file_path, + transfer_type=transfer_type, + target=target, + meta=file_meta, + episodes_info=episodes_info) + else: + # 转移 + transferinfo: TransferInfo = self.filetransfer.transfer_media(in_path=file_path, + in_meta=file_meta, + mediainfo=mediainfo, + transfer_type=transfer_type, + target_dir=target, + episodes_info=episodes_info) + if not transferinfo: + logger.error("文件转移模块运行失败") + return + + if not transferinfo.success: + # 转移失败 + logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") + + if history_type: + # 新增转移失败历史记录 + self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + if self._notify: + self.post_message( + mtype=NotificationType.Manual, + title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", + text=f"原因:{transferinfo.message or '未知'}", + image=mediainfo.get_message_image() + ) + return + + if history_type: + # 新增转移成功历史记录 + self.transferhis.add_success( + src_path=file_path, + mode=transfer_type, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + + # 刮削 + if scraper_type: + # 更新媒体图片 + self.chain.obtain_images(mediainfo=mediainfo) + + # 刮削单个文件 + if settings.SCRAP_METADATA: + self.chain.scrape_metadata(path=transferinfo.target_path, + mediainfo=mediainfo, + transfer_type=transfer_type) + """ + { + "title_year season": { + "files": [ + { + "path":, + "mediainfo":, + "file_meta":, + "transferinfo": + } + ], + "time": "2023-08-24 23:23:23.332" + } + } + """ + # 发送消息汇总 + media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} + if media_list: + media_files = media_list.get("files") or [] + if media_files: + file_exists = False + for file in media_files: + if str(file_path) == file.get("path"): + file_exists = True + break + if not file_exists: + media_files.append({ + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + }) + else: + media_files = [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ] + media_list = { + "files": media_files, + "time": datetime.datetime.now() + } + else: + media_list = { + "files": [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ], + "time": datetime.datetime.now() + } + self._medias[mediainfo.title_year + " " + file_meta.season] = media_list + + # 广播事件 + self.eventmanager.send_event(EventType.TransferComplete, { + 'meta': file_meta, + 'mediainfo': mediainfo, + 'transferinfo': transferinfo + }) + + # 移动模式删除空目录 + if transfer_type == "move": + for file_dir in file_path.parents: + if len(str(file_dir)) <= len(str(Path(mon_path))): + # 重要,删除到监控目录为止 + break + files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT) + if not files: + logger.warn(f"移动模式,删除空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + except Exception as e: + logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def send_msg(self): + """ + 定时检查是否有媒体处理完,发送统一消息 + """ + if not self._medias or not self._medias.keys(): + return + + # 遍历检查是否已刮削完,发送消息 + for medis_title_year_season in list(self._medias.keys()): + media_list = self._medias.get(medis_title_year_season) + logger.info(f"开始处理媒体 {medis_title_year_season} 消息") + + if not media_list: + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + media_files = media_list.get("files") + if not last_update_time or not media_files: + continue + + transferinfo = media_files[0].get("transferinfo") + file_meta = media_files[0].get("file_meta") + mediainfo = media_files[0].get("mediainfo") + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ + or mediainfo.type == MediaType.MOVIE: + # 发送通知 + if self._notify: + + # 汇总处理文件总大小 + total_size = 0 + file_count = 0 + + # 剧集汇总 + episodes = [] + for file in media_files: + transferinfo = file.get("transferinfo") + total_size += transferinfo.total_size + file_count += 1 + + file_meta = file.get("file_meta") + if file_meta and file_meta.begin_episode: + episodes.append(file_meta.begin_episode) + + transferinfo.total_size = total_size + # 汇总处理文件数量 + transferinfo.file_count = file_count + + # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 + season_episode = None + # 处理文件多,说明是剧集,显示季入库消息 + if mediainfo.type == MediaType.TV: + # 季集文本 + season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" + # 发送消息 + self.transferchian.send_transfer_message(meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode=season_episode) + # 发送完消息,移出key + del self._medias[medis_title_year_season] + continue + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/cloud_link_sync", + "event": EventType.PluginAction, + "desc": "云盘实时监控同步", + "category": "", + "data": { + "action": "cloud_link_sync" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + return [{ + "path": "/cloud_link_sync", + "endpoint": self.sync, + "methods": ["GET"], + "summary": "云盘实时监控同步", + "description": "云盘实时监控同步", + }] + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "CloudLinkMonitor", + "name": "云盘实时监控全量同步服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self) -> schemas.Response: + """ + API调用目录同步 + """ + self.sync_all() + return schemas.Response(success=True) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '监控模式', + 'items': [ + {'title': '兼容模式', 'value': 'compatibility'}, + {'title': '性能模式', 'value': 'fast'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'transfer_type', + 'label': '转移方式', + 'items': [ + {'title': '移动', 'value': 'move'}, + {'title': '复制', 'value': 'copy'}, + {'title': '硬链接', 'value': 'link'}, + {'title': '软链接', 'value': 'filesoftlink'}, + {'title': 'Rclone复制', 'value': 'rclone_copy'}, + {'title': 'Rclone移动', 'value': 'rclone_move'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '监控文件大小(GB)', + 'placeholder': '0' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_dirs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' + '监控目录:转移目的目录\n' + '监控目录:转移目的目录$是否刮削(True/False)\n' + '监控目录:转移目的目录#转移方式\n' + '监控目录:转移目的目录#转移方式$是否刮削(True/False)\n' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控目录增加`@False/True`,默认True,拼接一级二级目录,False则不拼接一级二级目录。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '入库消息延迟默认10s,如网络较慢可酌情调大,有助于发送统一入库消息。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "mode": "fast", + "transfer_type": settings.TRANSFER_TYPE, + "monitor_dirs": "", + "exclude_keywords": "", + "interval": 10, + "cron": "", + "size": 0 + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None diff --git a/plugins/cloudstrm/__init__.py b/plugins/cloudstrm/__init__.py new file mode 100644 index 0000000..3a3b94c --- /dev/null +++ b/plugins/cloudstrm/__init__.py @@ -0,0 +1,735 @@ +import json +import os +import shutil +import urllib.parse +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from typing import Any, List, Dict, Tuple, Optional + +from app.core.event import eventmanager, Event +from app.schemas.types import EventType +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings + + +class CloudStrm(_PluginBase): + # 插件名称 + plugin_name = "云盘Strm生成" + # 插件描述 + plugin_desc = "定时扫描云盘文件,生成Strm文件。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png" + # 插件版本 + plugin_version = "4.4" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudstrm_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _cron = None + _rebuild_cron = None + _monitor_confs = None + _onlyonce = False + _copy_files = False + _rebuild = False + _https = False + _observer = [] + __cloud_files_json = "cloud_files.json" + + _dirconf = {} + _libraryconf = {} + _cloudtypeconf = {} + _cloudurlconf = {} + _cloudpathconf = {} + __cloud_files = [] + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._libraryconf = {} + self._cloudtypeconf = {} + self._cloudurlconf = {} + self._cloudpathconf = {} + self.__cloud_files_json = os.path.join(self.get_data_path(), self.__cloud_files_json) + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._rebuild_cron = config.get("rebuild_cron") + self._onlyonce = config.get("onlyonce") + self._rebuild = config.get("rebuild") + self._https = config.get("https") + self._copy_files = config.get("copy_files") + self._monitor_confs = config.get("monitor_confs") + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 源目录:目的目录:媒体库内网盘路径:监控模式 + if not monitor_conf: + continue + # 注释 + if str(monitor_conf).startswith("#"): + continue + if str(monitor_conf).count("#") == 2: + source_dir = str(monitor_conf).split("#")[0] + target_dir = str(monitor_conf).split("#")[1] + library_dir = str(monitor_conf).split("#")[2] + self._libraryconf[source_dir] = library_dir + elif str(monitor_conf).count("#") == 4: + source_dir = str(monitor_conf).split("#")[0] + target_dir = str(monitor_conf).split("#")[1] + cloud_type = str(monitor_conf).split("#")[2] + cloud_path = str(monitor_conf).split("#")[3] + cloud_url = str(monitor_conf).split("#")[4] + self._cloudtypeconf[source_dir] = cloud_type + self._cloudpathconf[source_dir] = cloud_path + self._cloudurlconf[source_dir] = cloud_url + else: + logger.error(f"{monitor_conf} 格式错误") + continue + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + # 运行一次定时服务 + if self._onlyonce: + logger.info("云盘监控全量执行服务启动,立即运行一次") + self._scheduler.add_job(func=self.scan, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="云盘监控全量执行") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.scan, + trigger=CronTrigger.from_crontab(self._cron), + name="云盘监控生成") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 周期运行 + if self._rebuild_cron: + try: + self._scheduler.add_job(func=self.__init_cloud_files_json, + trigger=CronTrigger.from_crontab(self._rebuild_cron), + name="云盘监控重建索引") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + @eventmanager.register(EventType.PluginAction) + def scan(self, event: Event = None): + """ + 扫描 + """ + if not self._enabled: + logger.error("插件未开启") + return + if not self._dirconf or not self._dirconf.keys(): + logger.error("未获取到可用目录监控配置,请检查") + return + + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cloud_strm": + return + logger.info("收到命令,开始云盘strm生成 ...") + self.post_message(channel=event.event_data.get("channel"), + title="开始云盘strm生成 ...", + userid=event.event_data.get("user")) + + logger.info("云盘strm生成任务开始") + # 首次扫描或者重建索引 + __init_flag = False + if self._rebuild or not Path(self.__cloud_files_json).exists(): + logger.info("正在重建索引或初始化运行") + self.__init_cloud_files_json() + self._rebuild = False + self.__update_config() + __init_flag = True + else: + logger.info("尝试加载本地缓存") + # 尝试加载本地 + with open(self.__cloud_files_json, 'r') as file: + content = file.read() + if content: + self.__cloud_files = json.loads(content) + + # 本地没加载到则重建索引 + if not self.__cloud_files: + logger.error("尝试加载本地缓存,开始重建索引") + self.__init_cloud_files_json() + self._rebuild = False + self.__update_config() + __init_flag = True + + # 不是首次索引,则重新扫描、判断是否有新文件 + if not __init_flag: + __save_flag = False + for source_dir in self._dirconf.keys(): + logger.info(f"正在处理监控文件 {source_dir}") + for root, dirs, files in os.walk(source_dir): + # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 + if "extrafanart" in dirs: + dirs.remove("extrafanart") + + # 处理文件 + for file in files: + source_file = os.path.join(root, file) + # 回收站及隐藏的文件不处理 + if (source_file.find("/@Recycle") != -1 + or source_file.find("/#recycle") != -1 + or source_file.find("/.") != -1 + or source_file.find("/@eaDir") != -1): + logger.info(f"{source_file} 是回收站或隐藏的文件,跳过处理") + continue + + # 不复制非媒体文件时直接过滤掉非媒体文件 + if not self._copy_files and Path(file).suffix.lower() not in settings.RMT_MEDIAEXT: + continue + + if source_file not in self.__cloud_files: + logger.info(f"扫描到新文件 {source_file},正在开始处理") + # 云盘文件json新增 + self.__cloud_files.append(source_file) + # 扫描云盘文件,判断是否有对应strm + self.__strm(source_file) + __save_flag = True + else: + logger.debug(f"{source_file} 已在缓存中!跳过处理") + + # 重新保存json文件 + if __save_flag: + self.__sava_json() + + logger.info("云盘strm生成任务完成") + if event: + self.post_message(channel=event.event_data.get("channel"), + title="云盘strm生成任务完成!", + userid=event.event_data.get("user")) + + def __init_cloud_files_json(self): + """ + 初始化云盘文件json + """ + # init + for source_dir in self._dirconf.keys(): + logger.info(f"正在处理监控文件 {source_dir}") + for root, dirs, files in os.walk(source_dir): + # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 + if "extrafanart" in dirs: + dirs.remove("extrafanart") + + # 处理文件 + for file in files: + source_file = os.path.join(root, file) + # 回收站及隐藏的文件不处理 + if (source_file.find("/@Recycle") != -1 + or source_file.find("/#recycle") != -1 + or source_file.find("/.") != -1 + or source_file.find("/@eaDir") != -1): + logger.info(f"{source_file} 是回收站或隐藏的文件,跳过处理") + continue + + # 不复制非媒体文件时直接过滤掉非媒体文件 + if not self._copy_files and Path(file).suffix.lower() not in settings.RMT_MEDIAEXT: + continue + + logger.info(f"扫描到新文件 {source_file},正在开始处理") + # 云盘文件json新增 + self.__cloud_files.append(source_file) + # 扫描云盘文件,判断是否有对应strm + self.__strm(source_file) + + # 写入本地文件 + if self.__cloud_files: + self.__sava_json() + else: + logger.warning(f"未获取到文件列表") + + def __sava_json(self): + """ + 保存json文件 + """ + logger.info(f"开始写入本地文件 {self.__cloud_files_json}") + file = open(self.__cloud_files_json, 'w') + file.write(json.dumps(self.__cloud_files)) + file.close() + + def __strm(self, source_file): + """ + 判断文件是否有对应strm + """ + try: + # 获取文件的转移路径 + for source_dir in self._dirconf.keys(): + if str(source_file).startswith(source_dir): + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + + # 转移后文件 + dest_file = source_file.replace(source_dir, dest_dir) + # 如果是文件夹 + if Path(dest_file).is_dir(): + if not Path(dest_file).exists(): + logger.info(f"创建目标文件夹 {dest_file}") + os.makedirs(dest_file) + continue + else: + # 非媒体文件 + if Path(dest_file).exists(): + logger.info(f"目标文件 {dest_file} 已存在") + continue + + # 文件 + if not Path(dest_file).parent.exists(): + logger.info(f"创建目标文件夹 {Path(dest_file).parent}") + os.makedirs(Path(dest_file).parent) + + # 视频文件创建.strm文件 + if Path(dest_file).suffix.lower() in settings.RMT_MEDIAEXT: + # 创建.strm文件 + self.__create_strm_file(scheme="https" if self._https else "http", + dest_file=dest_file, + dest_dir=dest_dir, + source_file=source_file, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + if self._copy_files: + # 其他nfo、jpg等复制文件 + shutil.copy2(source_file, dest_file) + logger.info(f"复制其他文件 {source_file} 到 {dest_file}") + except Exception as e: + logger.error(f"create strm file error: {e}") + print(str(e)) + + @staticmethod + def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None, + cloud_type: str = None, cloud_path: str = None, cloud_url: str = None, + scheme: str = None): + """ + 生成strm文件 + :param library_dir: + :param dest_dir: + :param dest_file: + """ + try: + # 获取视频文件名和目录 + video_name = Path(dest_file).name + # 获取视频目录 + dest_path = Path(dest_file).parent + + if not dest_path.exists(): + logger.info(f"创建目标文件夹 {dest_path}") + os.makedirs(str(dest_path)) + + # 构造.strm文件路径 + strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") + # strm已存在跳过处理 + if Path(strm_path).exists(): + logger.info(f"strm文件已存在 {strm_path}") + return + + logger.info(f"替换前本地路径:::{dest_file}") + + # 云盘模式 + if cloud_type: + # 替换路径中的\为/ + dest_file = source_file.replace("\\", "/") + dest_file = dest_file.replace(cloud_path, "") + # 对盘符之后的所有内容进行url转码 + dest_file = urllib.parse.quote(dest_file, safe='') + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + dest_file = f"{scheme}://{cloud_url}/static/{scheme}/{cloud_url}/False/{dest_file}" + logger.info(f"替换后cd2路径:::{dest_file}") + elif str(cloud_type) == "alist": + dest_file = f"{scheme}://{cloud_url}/d/{dest_file}" + logger.info(f"替换后alist路径:::{dest_file}") + else: + logger.error(f"云盘类型 {cloud_type} 错误") + return + else: + # 本地挂载路径转为emby路径 + dest_file = dest_file.replace(dest_dir, library_dir) + logger.info(f"替换后emby容器内路径:::{dest_file}") + + # 写入.strm文件 + with open(strm_path, 'w') as f: + f.write(dest_file) + + logger.info(f"创建strm文件 {strm_path}") + except Exception as e: + logger.error(f"创建strm文件失败") + print(str(e)) + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "rebuild": self._rebuild, + "copy_files": self._copy_files, + "https": self._https, + "cron": self._cron, + "monitor_confs": self._monitor_confs, + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/cloud_strm", + "event": EventType.PluginAction, + "desc": "云盘strm文件生成", + "category": "", + "data": { + "action": "cloud_strm" + } + }] + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "CloudStrm", + "name": "云盘strm文件生成服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.scan, + "kwargs": {} + }] + return [] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '全量运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'rebuild', + 'label': '重建索引', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '生成周期', + 'placeholder': '0 0 * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'rebuild_cron', + 'label': '重建索引周期', + 'placeholder': '0 1 * * *' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'copy_files', + 'label': '复制非媒体文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'https', + 'label': '启用https', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '目录监控格式:' + '1.监控目录#目的目录#媒体服务器内源文件路径;' + '2.监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;' + '3.监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "", + "rebuild_cron": "", + "onlyonce": False, + "rebuild": False, + "copy_files": False, + "https": False, + "monitor_confs": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/cloudstrmapi/__init__.py b/plugins/cloudstrmapi/__init__.py new file mode 100644 index 0000000..5428d0f --- /dev/null +++ b/plugins/cloudstrmapi/__init__.py @@ -0,0 +1,728 @@ +import os +import shutil +import urllib.parse +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from typing import Any, List, Dict, Tuple, Optional + +from apscheduler.schedulers.background import BackgroundScheduler +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, watching_path: str, file_change: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = watching_path + self.file_change = file_change + + # def on_any_event(self, event): + # logger.info(f"目录监控event_type {event.event_type} 路径 {event.src_path}") + + def on_created(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path) + + +class CloudStrmApi(_PluginBase): + # 插件名称 + plugin_name = "云盘Strm生成(API直链版)" + # 插件描述 + plugin_desc = "监控文件创建,生成Strm文件。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudstrm_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _monitor_confs = None + _onlyonce = False + _relay = 3 + _observer = [] + _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts') + + _dirconf = {} + _modeconf = {} + _libraryconf = {} + _cloudtypeconf = {} + _cloudurlconf = {} + _cloudpathconf = {} + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._modeconf = {} + self._libraryconf = {} + self._cloudtypeconf = {} + self._cloudurlconf = {} + self._cloudpathconf = {} + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._monitor_confs = config.get("monitor_confs") + self._relay = config.get("relay") or 3 + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 源目录:目的目录:媒体库内网盘路径:监控模式 + if not monitor_conf: + continue + if str(monitor_conf).count("#") == 3: + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + library_dir = str(monitor_conf).split("#")[3] + self._libraryconf[source_dir] = library_dir + elif str(monitor_conf).count("#") == 5: + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + cloud_type = str(monitor_conf).split("#")[3] + cloud_path = str(monitor_conf).split("#")[4] + cloud_url = str(monitor_conf).split("#")[5] + self._cloudtypeconf[source_dir] = cloud_type + self._cloudpathconf[source_dir] = cloud_path + self._cloudurlconf[source_dir] = cloud_url + else: + logger.error(f"{monitor_conf} 格式错误") + continue + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + self._modeconf[source_dir] = mode + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + # 异步开启云盘监控 + logger.info(f"异步开启云盘监控 {source_dir} {mode}") + self._scheduler.add_job(func=self.start_monitor, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta( + seconds=int(self._relay)), + name=f"云盘监控 {source_dir}", + kwargs={ + "mode": mode, + "source_dir": source_dir + }) + # 运行一次定时服务 + if self._onlyonce: + logger.info("云盘监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="云盘监控全量执行") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def start_monitor(self, mode: str, source_dir: str): + """ + 异步开启云盘监控 + """ + try: + if str(mode) == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{source_dir} 的云盘监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}") + self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}") + + def event_handler(self, event, source_dir: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param source_dir: 监控目录 + :param event_path: 事件文件路径 + """ + # 回收站及隐藏的文件不处理 + if (event_path.find("/@Recycle") != -1 + or event_path.find("/#recycle") != -1 + or event_path.find("/.") != -1 + or event_path.find("/@eaDir") != -1): + logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理") + return + + # 文件发生变化 + logger.info(f"变动类型 {event.event_type} 变动路径 {event_path}") + self.__handle_file(event=event, event_path=event_path, source_dir=source_dir) + + def __handle_file(self, event, event_path: str, source_dir: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param source_dir: 监控目录 + """ + try: + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + # 文件夹同步创建 + if event.is_directory: + target_path = event_path.replace(source_dir, dest_dir) + # 目标文件夹不存在则创建 + if not Path(target_path).exists(): + logger.info(f"创建目标文件夹 {target_path}") + os.makedirs(target_path) + else: + # 文件:nfo、图片、视频文件 + dest_file = event_path.replace(source_dir, dest_dir) + if Path(dest_file).exists(): + logger.debug(f"目标文件 {dest_file} 已存在") + return + + # 目标文件夹不存在则创建 + if not Path(dest_file).parent.exists(): + logger.info(f"创建目标文件夹 {Path(dest_file).parent}") + os.makedirs(Path(dest_file).parent) + + # 视频文件创建.strm文件 + if event_path.lower().endswith(self._video_formats): + # 如果视频文件小于1MB,则直接复制,不创建.strm文件 + if os.path.getsize(event_path) < 1024 * 1024: + shutil.copy2(event_path, dest_file) + logger.info(f"复制视频文件 {event_path} 到 {dest_file}") + else: + # 创建.strm文件 + self.__create_strm_file(dest_file=dest_file, + dest_dir=dest_dir, + source_file=event_path, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + # 其他nfo、jpg等复制文件 + shutil.copy2(event_path, dest_file) + logger.info(f"复制其他文件 {event_path} 到 {dest_file}") + + except Exception as e: + logger.error(f"event_handler_created error: {e}") + print(str(e)) + + def sync_all(self): + """ + 同步所有文件 + """ + if not self._dirconf or not self._dirconf.keys(): + logger.error("未获取到可用目录监控配置,请检查") + return + for source_dir in self._dirconf.keys(): + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + + logger.info(f"开始初始化生成strm文件 {source_dir}") + self.__handle_all(source_dir=source_dir, + dest_dir=dest_dir, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + logger.info(f"{source_dir} 初始化生成strm文件完成") + + def __handle_all(self, source_dir, dest_dir, library_dir, cloud_type=None, cloud_path=None, cloud_url=None): + """ + 遍历生成所有文件的strm + """ + if not os.path.exists(dest_dir): + os.makedirs(dest_dir) + + for root, dirs, files in os.walk(source_dir): + # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 + if "extrafanart" in dirs: + dirs.remove("extrafanart") + + for file in files: + source_file = os.path.join(root, file) + logger.info(f"处理源文件::: {source_file}") + + dest_file = os.path.join(dest_dir, os.path.relpath(source_file, source_dir)) + if Path(dest_file).exists(): + logger.debug(f"目标文件 {dest_file} 已存在") + return + logger.info(f"开始生成目标文件::: {dest_file}") + + # 创建目标目录中缺少的文件夹 + if not os.path.exists(Path(dest_file).parent): + os.makedirs(Path(dest_file).parent) + + # 如果目标文件已存在,跳过处理 + if os.path.exists(dest_file): + logger.warn(f"文件已存在,跳过处理::: {dest_file}") + continue + + if file.lower().endswith(self._video_formats): + # 如果视频文件小于1MB,则直接复制,不创建.strm文件 + if os.path.getsize(source_file) < 1024 * 1024: + logger.info(f"视频文件小于1MB的视频文件到:::{dest_file}") + shutil.copy2(source_file, dest_file) + else: + # 创建.strm文件 + self.__create_strm_file(dest_file=dest_file, + dest_dir=dest_dir, + source_file=source_file, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + # 复制文件 + logger.info(f"复制其他文件到:::{dest_file}") + shutil.copy2(source_file, dest_file) + + @staticmethod + def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None, + cloud_type: str = None, cloud_path: str = None, cloud_url: str = None): + """ + 生成strm文件 + :param library_dir: + :param dest_dir: + :param dest_file: + """ + try: + # 获取视频文件名和目录 + video_name = Path(dest_file).name + # 获取视频目录 + dest_path = Path(dest_file).parent + + if not dest_path.exists(): + logger.info(f"创建目标文件夹 {dest_path}") + os.makedirs(str(dest_path)) + + # 构造.strm文件路径 + strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") + logger.info(f"替换前本地路径:::{dest_file}") + + # 云盘模式 + if cloud_type: + # 替换路径中的\为/ + dest_file = source_file.replace("\\", "/") + dest_file = dest_file.replace(cloud_path, "") + # 对盘符之后的所有内容进行url转码 + dest_file = urllib.parse.quote(dest_file, safe='') + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}" + logger.info(f"替换后cd2路径:::{dest_file}") + elif str(cloud_type) == "alist": + dest_file = f"http://{cloud_url}/d/{dest_file}" + logger.info(f"替换后alist路径:::{dest_file}") + else: + logger.error(f"云盘类型 {cloud_type} 错误") + return + else: + # 本地挂载路径转为emby路径 + dest_file = dest_file.replace(dest_dir, library_dir) + logger.info(f"替换后emby容器内路径:::{dest_file}") + + # 写入.strm文件 + with open(strm_path, 'w') as f: + f.write(dest_file) + + logger.info(f"创建strm文件 {strm_path}") + except Exception as e: + logger.error(f"创建strm文件失败") + print(str(e)) + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "relay": self._relay, + "monitor_confs": self._monitor_confs + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'relay', + 'label': '监控延迟', + 'placeholder': '3' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'straight_chain', + 'label': '直链API', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'straight_confs', + 'label': '直链配置', + 'rows': 5, + 'placeholder': '媒体服务器内源文件路径#cd2#cd2挂载本地跟路径#cd2服务地址' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '目录监控格式:' + '1.监控方式#监控目录#目的目录#媒体服务器内源文件路径;' + '2.监控方式#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;' + '3.监控方式#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '媒体服务器内源文件路径:' + '源文件目录即云盘挂载到媒体服务器的路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控方式:' + 'fast:性能模式(快);' + 'compatibility:兼容模式(稳,推荐)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '立即运行一次:' + '全量运行一次。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '由于unraid开启云盘监控很慢,所以采取异步方式开启磁盘监控,' + '具体开启情况可稍等3-5分钟查看日志。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "relay": 3, + "onlyonce": False, + "monitor_confs": "", + "straight_chain": False + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] diff --git a/plugins/cloudstrmincrement/__init__.py b/plugins/cloudstrmincrement/__init__.py new file mode 100644 index 0000000..1dff14c --- /dev/null +++ b/plugins/cloudstrmincrement/__init__.py @@ -0,0 +1,746 @@ +import os +import shutil +import urllib.parse +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from typing import Any, List, Dict, Tuple, Optional + +from app.core.event import eventmanager, Event +from app.schemas.types import EventType +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings +from app.utils.system import SystemUtils + + +class CloudStrmIncrement(_PluginBase): + # 插件名称 + plugin_name = "云盘Strm生成(增量版)" + # 插件描述 + plugin_desc = "定时扫描云盘文件,生成Strm文件(增量版)。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudstrm_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _cron = None + _monitor_confs = None + _onlyonce = False + _copy_files = False + _https = False + _no_del_dirs = None + _rmt_mediaext = ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + _observer = [] + + # 公开属性 + _increment_dir = {} + _dirconf = {} + _libraryconf = {} + _cloudtypeconf = {} + _cloudurlconf = {} + _cloudpathconf = {} + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._libraryconf = {} + self._cloudtypeconf = {} + self._cloudurlconf = {} + self._cloudpathconf = {} + self._increment_dir = {} + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._https = config.get("https") + self._copy_files = config.get("copy_files") + self._monitor_confs = config.get("monitor_confs") + self._no_del_dirs = config.get("no_del_dirs") + self._rmt_mediaext = config.get( + "rmt_mediaext") or ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 源目录:目的目录:媒体库内网盘路径:监控模式 + if not monitor_conf: + continue + # 注释 + if str(monitor_conf).startswith("#"): + continue + + if str(monitor_conf).count("#") == 3: + increment_dir = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + library_dir = str(monitor_conf).split("#")[3] + self._libraryconf[source_dir] = library_dir + elif str(monitor_conf).count("#") == 5: + increment_dir = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + cloud_type = str(monitor_conf).split("#")[3] + cloud_path = str(monitor_conf).split("#")[4] + cloud_url = str(monitor_conf).split("#")[5] + self._cloudtypeconf[source_dir] = cloud_type + self._cloudpathconf[source_dir] = cloud_path + self._cloudurlconf[source_dir] = cloud_url + else: + logger.error(f"{monitor_conf} 格式错误") + continue + + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + + # 增量配置 + self._increment_dir[increment_dir] = source_dir + + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + # 运行一次定时服务 + if self._onlyonce: + logger.info("云盘增量监控执行服务启动,立即运行一次") + self._scheduler.add_job(func=self.scan, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="云盘增量监控") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.scan, + trigger=CronTrigger.from_crontab(self._cron), + name="云盘增量监控") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + @eventmanager.register(EventType.PluginAction) + def scan(self, event: Event = None): + """ + 扫描 + """ + if not self._enabled: + logger.error("插件未开启") + return + if not self._dirconf or not self._dirconf.keys(): + logger.error("未获取到可用目录监控配置,请检查") + return + + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cloud_strm_increment": + return + logger.info("收到命令,开始云盘strm生成 ...") + self.post_message(channel=event.event_data.get("channel"), + title="开始云盘strm生成 ...", + userid=event.event_data.get("user")) + + logger.info("云盘strm生成任务开始") + for increment_dir in self._increment_dir.keys(): + logger.info(f"正在扫描增量目录 {increment_dir}") + for root, dirs, files in os.walk(increment_dir): + # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 + if "extrafanart" in dirs: + dirs.remove("extrafanart") + + # 处理文件 + for file in files: + increment_file = os.path.join(root, file) + # 回收站及隐藏的文件不处理 + if (increment_file.find("/@Recycle") != -1 + or increment_file.find("/#recycle") != -1 + or increment_file.find("/.") != -1 + or increment_file.find("/@eaDir") != -1): + logger.info(f"{increment_file} 是回收站或隐藏的文件,跳过处理") + continue + + # 不复制非媒体文件时直接过滤掉非媒体文件 + if not self._copy_files and Path(file).suffix not in [ext.strip() for ext in + self._rmt_mediaext.split(",")]: + continue + + logger.info(f"扫描到增量文件 {increment_file},正在开始处理") + + # 移动到目标目录 + source_dir = self._increment_dir.get(increment_dir) + # 移动后文件 + source_file = increment_file.replace(increment_dir, source_dir) + + # 判断目标文件是否存在 + if not Path(source_file).parent.exists(): + Path(source_file).parent.mkdir(parents=True, exist_ok=True) + + shutil.move(increment_file, source_file, copy_function=shutil.copy2) + logger.info(f"移动增量文件 {increment_file} 到 {source_file}") + + # 扫描云盘文件,判断是否有对应strm + self.__strm(source_file) + logger.info(f"增量文件 {increment_file} 处理完成") + + # 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级 + if not SystemUtils.exits_files(Path(increment_file).parent, + [ext.strip() for ext in self._rmt_mediaext.split(",")]): + # 判断父目录是否为空, 为空则删除 + for parent_path in Path(increment_file).parents: + if parent_path.name in self._no_del_dirs: + break + if str(parent_path.name) == str(increment_dir): + break + if str(parent_path.parent) != str(Path(increment_file).root): + # 父目录非根目录,才删除父目录 + if not SystemUtils.exits_files(parent_path, + [ext.strip() for ext in self._rmt_mediaext.split(",")]): + # 当前路径下没有媒体文件则删除 + shutil.rmtree(parent_path) + logger.warn(f"增量非保留目录 {parent_path} 已删除") + + logger.info("云盘strm生成任务完成") + if event: + self.post_message(channel=event.event_data.get("channel"), + title="云盘strm生成任务完成!", + userid=event.event_data.get("user")) + + # def move_file(self, + # file_path: Path, + # dest_path: Path, + # is_check_disk_space: bool = True, + # min_free_space: int = 300, + # wait_time: int = 300, + # check_paths: Optional[List[Path]] = None, + # ) -> bool: + # """ + # 移动文件,如果父文件夹为空,则删除空父文件夹 + # """ + # # 在目标路径存在时,会尝试覆盖它 + # if not file_path.exists(): + # logger.debug(f"move文件不存在,跳过处理: {file_path}") + # + # if is_check_disk_space: + # if not check_paths: + # check_paths = [dest_path.parent] + # check_paths.append(data_path) + # + # for check_path in check_paths: + # while check_disk_space(check_path, min_free_space): + # logger.warning( + # f"文件 {check_path} 空间不足,等待 {wait_time}s再处理:" + # f" {file_path}" + # ) + # sleep(wait_time) + # + # logger.debug(f"移动文件: {file_path} -> {dest_path}") + # + # # # 改用copy2,避免移动文件夹时,程序中断导致文件丢失 + # # is_copyed = copy(file_path, dest_path) + # # # 复制成功才继续执行 + # # if not is_copyed: + # # logger.warning(f"移动文件失败: {file_path} -> {dest_path}") + # # return False + # + # # # 复制后再删除文件 + # # logger.debug(f"已复制文件:{file_path}, 正在删除文件: {file_path}") + # + # try: + # if not dest_path.parent.exists(): + # dest_path.parent.mkdir(parents=True, exist_ok=True) + # + # cloud_str = "/mnt/cloud" + # if str(file_path).startswith(cloud_str) and str(dest_path).startswith( + # cloud_str + # ): + # # 如果是云盘路径,则使用重命名 + # file_path.rename(dest_path) + # else: + # shutil.move(file_path, dest_path, copy_function=shutil.copy2) + + def __strm(self, source_file): + """ + 判断文件是否有对应strm + """ + try: + # 获取文件的转移路径 + for source_dir in self._dirconf.keys(): + if str(source_file).startswith(source_dir): + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + + # 转移后文件 + dest_file = source_file.replace(source_dir, dest_dir) + # 如果是文件夹 + if Path(dest_file).is_dir(): + if not Path(dest_file).exists(): + logger.info(f"创建目标文件夹 {dest_file}") + os.makedirs(dest_file) + continue + else: + # 非媒体文件 + if Path(dest_file).exists(): + logger.info(f"目标文件 {dest_file} 已存在") + continue + + # 文件 + if not Path(dest_file).parent.exists(): + logger.info(f"创建目标文件夹 {Path(dest_file).parent}") + os.makedirs(Path(dest_file).parent) + + # 视频文件创建.strm文件 + if Path(dest_file).suffix in [ext.strip() for ext in self._rmt_mediaext.split(",")]: + # 创建.strm文件 + self.__create_strm_file(scheme="https" if self._https else "http", + dest_file=dest_file, + dest_dir=dest_dir, + source_file=source_file, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + if self._copy_files: + # 其他nfo、jpg等复制文件 + shutil.copy2(source_file, dest_file) + logger.info(f"复制其他文件 {source_file} 到 {dest_file}") + except Exception as e: + logger.error(f"create strm file error: {e}") + print(str(e)) + + @staticmethod + def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None, + cloud_type: str = None, cloud_path: str = None, cloud_url: str = None, + scheme: str = None): + """ + 生成strm文件 + :param library_dir: + :param dest_dir: + :param dest_file: + """ + try: + # 获取视频文件名和目录 + video_name = Path(dest_file).name + # 获取视频目录 + dest_path = Path(dest_file).parent + + if not dest_path.exists(): + logger.info(f"创建目标文件夹 {dest_path}") + os.makedirs(str(dest_path)) + + # 构造.strm文件路径 + strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") + # strm已存在跳过处理 + if Path(strm_path).exists(): + logger.info(f"strm文件已存在 {strm_path}") + return + + logger.info(f"替换前本地路径:::{dest_file}") + + # 云盘模式 + if cloud_type: + # 替换路径中的\为/ + dest_file = source_file.replace("\\", "/") + dest_file = dest_file.replace(cloud_path, "") + # 对盘符之后的所有内容进行url转码 + dest_file = urllib.parse.quote(dest_file, safe='') + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + dest_file = f"{scheme}://{cloud_url}/static/{scheme}/{cloud_url}/False/{dest_file}" + logger.info(f"替换后cd2路径:::{dest_file}") + elif str(cloud_type) == "alist": + dest_file = f"{scheme}://{cloud_url}/d/{dest_file}" + logger.info(f"替换后alist路径:::{dest_file}") + else: + logger.error(f"云盘类型 {cloud_type} 错误") + return + else: + # 本地挂载路径转为emby路径 + dest_file = dest_file.replace(dest_dir, library_dir) + logger.info(f"替换后emby容器内路径:::{dest_file}") + + # 写入.strm文件 + with open(strm_path, 'w') as f: + f.write(dest_file) + + logger.info(f"创建strm文件 {strm_path}") + except Exception as e: + logger.error(f"创建strm文件失败") + print(str(e)) + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "copy_files": self._copy_files, + "https": self._https, + "cron": self._cron, + "monitor_confs": self._monitor_confs, + "no_del_dirs": self._no_del_dirs, + "rmt_mediaext": self._rmt_mediaext + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/cloud_strm_increment", + "event": EventType.PluginAction, + "desc": "云盘strm文件生成(增量版)", + "category": "", + "data": { + "action": "cloud_strm_increment" + } + }] + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "CloudStrm", + "name": "云盘strm文件生成服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.scan, + "kwargs": {} + }] + return [] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'copy_files', + 'label': '复制非媒体文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'https', + 'label': '启用https', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '生成周期', + 'placeholder': '0 0 * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'no_del_dirs', + 'label': '保留路径', + 'placeholder': 'series、movies、downloads、others' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '增量目录#监控目录#目的目录#媒体服务器内源文件路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'rmt_mediaext', + 'label': '视频格式', + 'rows': 2, + 'placeholder': ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '目录监控格式:' + '1.增量目录#监控目录#目的目录#媒体服务器内源文件路径;' + '2.增量目录#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;' + '3.增量目录#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '配置教程请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md', + 'target': '_blank' + }, + 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md' + } + ] + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + "cron": "", + "onlyonce": False, + "copy_files": False, + "https": False, + "monitor_confs": "", + "no_del_dirs": "", + "rmt_mediaext": ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/cloudstrmlocal/__init__.py b/plugins/cloudstrmlocal/__init__.py new file mode 100644 index 0000000..ae1ea97 --- /dev/null +++ b/plugins/cloudstrmlocal/__init__.py @@ -0,0 +1,728 @@ +import os +import shutil +import urllib.parse +from datetime import datetime, timedelta +from pathlib import Path + +import pytz +from typing import Any, List, Dict, Tuple, Optional + +from apscheduler.schedulers.background import BackgroundScheduler +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, watching_path: str, file_change: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = watching_path + self.file_change = file_change + + # def on_any_event(self, event): + # logger.info(f"目录监控event_type {event.event_type} 路径 {event.src_path}") + + def on_created(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path) + + +class CloudStrmLocal(_PluginBase): + # 插件名称 + plugin_name = "云盘Strm生成(本地直链版)" + # 插件描述 + plugin_desc = "监控文件创建,生成Strm文件。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/create.png" + # 插件版本 + plugin_version = "2.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudstrm_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _monitor_confs = None + _onlyonce = False + _relay = 3 + _observer = [] + _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts') + + _dirconf = {} + _modeconf = {} + _libraryconf = {} + _cloudtypeconf = {} + _cloudurlconf = {} + _cloudpathconf = {} + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._modeconf = {} + self._libraryconf = {} + self._cloudtypeconf = {} + self._cloudurlconf = {} + self._cloudpathconf = {} + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._monitor_confs = config.get("monitor_confs") + self._relay = config.get("relay") or 3 + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 源目录:目的目录:媒体库内网盘路径:监控模式 + if not monitor_conf: + continue + if str(monitor_conf).count("#") == 3: + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + library_dir = str(monitor_conf).split("#")[3] + self._libraryconf[source_dir] = library_dir + elif str(monitor_conf).count("#") == 5: + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + cloud_type = str(monitor_conf).split("#")[3] + cloud_path = str(monitor_conf).split("#")[4] + cloud_url = str(monitor_conf).split("#")[5] + self._cloudtypeconf[source_dir] = cloud_type + self._cloudpathconf[source_dir] = cloud_path + self._cloudurlconf[source_dir] = cloud_url + else: + logger.error(f"{monitor_conf} 格式错误") + continue + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + self._modeconf[source_dir] = mode + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + # 异步开启云盘监控 + logger.info(f"异步开启云盘监控 {source_dir} {mode}") + self._scheduler.add_job(func=self.start_monitor, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta( + seconds=int(self._relay)), + name=f"云盘监控 {source_dir}", + kwargs={ + "mode": mode, + "source_dir": source_dir + }) + # 运行一次定时服务 + if self._onlyonce: + logger.info("云盘监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="云盘监控全量执行") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def start_monitor(self, mode: str, source_dir: str): + """ + 异步开启云盘监控 + """ + try: + if str(mode) == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{source_dir} 的云盘监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}") + self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}") + + def event_handler(self, event, source_dir: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param source_dir: 监控目录 + :param event_path: 事件文件路径 + """ + # 回收站及隐藏的文件不处理 + if (event_path.find("/@Recycle") != -1 + or event_path.find("/#recycle") != -1 + or event_path.find("/.") != -1 + or event_path.find("/@eaDir") != -1): + logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理") + return + + # 文件发生变化 + logger.info(f"变动类型 {event.event_type} 变动路径 {event_path}") + self.__handle_file(event=event, event_path=event_path, source_dir=source_dir) + + def __handle_file(self, event, event_path: str, source_dir: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param source_dir: 监控目录 + """ + try: + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + # 文件夹同步创建 + if event.is_directory: + target_path = event_path.replace(source_dir, dest_dir) + # 目标文件夹不存在则创建 + if not Path(target_path).exists(): + logger.info(f"创建目标文件夹 {target_path}") + os.makedirs(target_path) + else: + # 文件:nfo、图片、视频文件 + dest_file = event_path.replace(source_dir, dest_dir) + if Path(dest_file).exists(): + logger.debug(f"目标文件 {dest_file} 已存在") + return + + # 目标文件夹不存在则创建 + if not Path(dest_file).parent.exists(): + logger.info(f"创建目标文件夹 {Path(dest_file).parent}") + os.makedirs(Path(dest_file).parent) + + # 视频文件创建.strm文件 + if event_path.lower().endswith(self._video_formats): + # 如果视频文件小于1MB,则直接复制,不创建.strm文件 + if os.path.getsize(event_path) < 1024 * 1024: + shutil.copy2(event_path, dest_file) + logger.info(f"复制视频文件 {event_path} 到 {dest_file}") + else: + # 创建.strm文件 + self.__create_strm_file(dest_file=dest_file, + dest_dir=dest_dir, + source_file=event_path, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + # 其他nfo、jpg等复制文件 + shutil.copy2(event_path, dest_file) + logger.info(f"复制其他文件 {event_path} 到 {dest_file}") + + except Exception as e: + logger.error(f"event_handler_created error: {e}") + print(str(e)) + + def sync_all(self): + """ + 同步所有文件 + """ + if not self._dirconf or not self._dirconf.keys(): + logger.error("未获取到可用目录监控配置,请检查") + return + for source_dir in self._dirconf.keys(): + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 媒体库容器内挂载路径 + library_dir = self._libraryconf.get(source_dir) + # 云服务类型 + cloud_type = self._cloudtypeconf.get(source_dir) + # 云服务挂载本地跟路径 + cloud_path = self._cloudpathconf.get(source_dir) + # 云服务地址 + cloud_url = self._cloudurlconf.get(source_dir) + + logger.info(f"开始初始化生成strm文件 {source_dir}") + self.__handle_all(source_dir=source_dir, + dest_dir=dest_dir, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + logger.info(f"{source_dir} 初始化生成strm文件完成") + + def __handle_all(self, source_dir, dest_dir, library_dir, cloud_type=None, cloud_path=None, cloud_url=None): + """ + 遍历生成所有文件的strm + """ + if not os.path.exists(dest_dir): + os.makedirs(dest_dir) + + for root, dirs, files in os.walk(source_dir): + # 如果遇到名为'extrafanart'的文件夹,则跳过处理该文件夹,继续处理其他文件夹 + if "extrafanart" in dirs: + dirs.remove("extrafanart") + + for file in files: + source_file = os.path.join(root, file) + logger.info(f"处理源文件::: {source_file}") + + dest_file = os.path.join(dest_dir, os.path.relpath(source_file, source_dir)) + if Path(dest_file).exists(): + logger.debug(f"目标文件 {dest_file} 已存在") + return + logger.info(f"开始生成目标文件::: {dest_file}") + + # 创建目标目录中缺少的文件夹 + if not os.path.exists(Path(dest_file).parent): + os.makedirs(Path(dest_file).parent) + + # 如果目标文件已存在,跳过处理 + if os.path.exists(dest_file): + logger.warn(f"文件已存在,跳过处理::: {dest_file}") + continue + + if file.lower().endswith(self._video_formats): + # 如果视频文件小于1MB,则直接复制,不创建.strm文件 + if os.path.getsize(source_file) < 1024 * 1024: + logger.info(f"视频文件小于1MB的视频文件到:::{dest_file}") + shutil.copy2(source_file, dest_file) + else: + # 创建.strm文件 + self.__create_strm_file(dest_file=dest_file, + dest_dir=dest_dir, + source_file=source_file, + library_dir=library_dir, + cloud_type=cloud_type, + cloud_path=cloud_path, + cloud_url=cloud_url) + else: + # 复制文件 + logger.info(f"复制其他文件到:::{dest_file}") + shutil.copy2(source_file, dest_file) + + @staticmethod + def __create_strm_file(dest_file: str, dest_dir: str, source_file: str, library_dir: str = None, + cloud_type: str = None, cloud_path: str = None, cloud_url: str = None): + """ + 生成strm文件 + :param library_dir: + :param dest_dir: + :param dest_file: + """ + try: + # 获取视频文件名和目录 + video_name = Path(dest_file).name + # 获取视频目录 + dest_path = Path(dest_file).parent + + if not dest_path.exists(): + logger.info(f"创建目标文件夹 {dest_path}") + os.makedirs(str(dest_path)) + + # 构造.strm文件路径 + strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") + logger.info(f"替换前本地路径:::{dest_file}") + + # 云盘模式 + if cloud_type: + # 替换路径中的\为/ + dest_file = source_file.replace("\\", "/") + dest_file = dest_file.replace(cloud_path, "") + # 对盘符之后的所有内容进行url转码 + dest_file = urllib.parse.quote(dest_file, safe='') + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + dest_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{dest_file}" + logger.info(f"替换后cd2路径:::{dest_file}") + elif str(cloud_type) == "alist": + dest_file = f"http://{cloud_url}/d/{dest_file}" + logger.info(f"替换后alist路径:::{dest_file}") + else: + logger.error(f"云盘类型 {cloud_type} 错误") + return + else: + # 本地挂载路径转为emby路径 + dest_file = dest_file.replace(dest_dir, library_dir) + logger.info(f"替换后emby容器内路径:::{dest_file}") + + # 写入.strm文件 + with open(strm_path, 'w') as f: + f.write(dest_file) + + logger.info(f"创建strm文件 {strm_path}") + except Exception as e: + logger.error(f"创建strm文件失败") + print(str(e)) + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "relay": self._relay, + "monitor_confs": self._monitor_confs + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'relay', + 'label': '监控延迟', + 'placeholder': '3' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控方式#监控目录#目的目录#媒体服务器内源文件路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'straight_chain', + 'label': '直链API', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'straight_confs', + 'label': '直链配置', + 'rows': 5, + 'placeholder': '媒体服务器内源文件路径#cd2#cd2挂载本地跟路径#cd2服务地址' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '目录监控格式:' + '1.监控方式#监控目录#目的目录#媒体服务器内源文件路径;' + '2.监控方式#监控目录#目的目录#cd2#cd2挂载本地跟路径#cd2服务地址;' + '3.监控方式#监控目录#目的目录#alist#alist挂载本地跟路径#alist服务地址。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '媒体服务器内源文件路径:' + '源文件目录即云盘挂载到媒体服务器的路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控方式:' + 'fast:性能模式(快);' + 'compatibility:兼容模式(稳,推荐)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '立即运行一次:' + '全量运行一次。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '由于unraid开启云盘监控很慢,所以采取异步方式开启磁盘监控,' + '具体开启情况可稍等3-5分钟查看日志。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "relay": 3, + "onlyonce": False, + "monitor_confs": "", + "straight_chain": False + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] diff --git a/plugins/commandexecute/__init__.py b/plugins/commandexecute/__init__.py new file mode 100644 index 0000000..8d16d91 --- /dev/null +++ b/plugins/commandexecute/__init__.py @@ -0,0 +1,242 @@ +import subprocess + +from app.core.event import eventmanager, Event +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.schemas.types import EventType, MessageChannel + + +class CommandExecute(_PluginBase): + # 插件名称 + plugin_name = "命令执行器" + # 插件描述 + plugin_desc = "自定义容器命令执行。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/command.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "commandexecute_" + # 加载顺序 + plugin_order = 99 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _onlyonce = None + _command = None + + def init_plugin(self, config: dict = None): + if config: + self._onlyonce = config.get("onlyonce") + self._command = config.get("command") + + if self._onlyonce and self._command: + # 执行SQL语句 + try: + for command in self._command.split("\n"): + logger.info(f"开始执行命令 {command}") + ouptut = self.execute_command(command) + # logger.info('\n'.join(ouptut)) + except Exception as e: + logger.error(f"命令执行失败 {str(e)}") + return + finally: + self._onlyonce = False + self.update_config({ + "onlyonce": self._onlyonce, + "command": self._command + }) + + @staticmethod + def execute_command(command: str): + """ + 执行命令 + :param command: 命令 + """ + result = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ouptut = [] + while True: + error = result.stderr.readline().decode("utf-8") + if error == '' and result.poll() is not None: + break + if error: + logger.info(error.strip()) + ouptut.append(error.strip()) + while True: + output = result.stdout.readline().decode("utf-8") + if output == '' and result.poll() is not None: + break + if output: + logger.info(output.strip()) + ouptut.append(output.strip()) + + return ouptut + + @eventmanager.register(EventType.PluginAction) + def execute(self, event: Event = None): + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "command_execute": + return + logger.info(f"收到命令执行事件 ...{event_data}") + args = event_data.get("args") + if not args: + return + + logger.info(f"收到命令,开始执行命令 ...{args}") + ouptut = self.execute_command(args) + result = '\n'.join(ouptut) + + if event.event_data.get("channel") == MessageChannel.Telegram: + result = f"```plaintext\n{result}\n```" + self.post_message(channel=event.event_data.get("channel"), + title="命令执行结果", + text=result, + userid=event.event_data.get("user")) + + def get_state(self) -> bool: + return True + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/cmd", + "event": EventType.PluginAction, + "desc": "自定义命令执行", + "category": "", + "data": { + "action": "command_execute" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '执行命令' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'command', + 'rows': '2', + 'label': 'command命令', + 'placeholder': '一行一条' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '执行日志将会输出到控制台,请谨慎操作。' + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '可使用交互命令/cmd ls' + } + ] + } + ] + } + ] + } + ] + } + ], { + "onlyonce": False, + "command": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/customcommand/__init__.py b/plugins/customcommand/__init__.py new file mode 100644 index 0000000..fa80495 --- /dev/null +++ b/plugins/customcommand/__init__.py @@ -0,0 +1,534 @@ +import random +import re +import subprocess +import time +from datetime import datetime, timedelta +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType + + +class CustomCommand(_PluginBase): + # 插件名称 + plugin_name = "自定义命令" + # 插件描述 + plugin_desc = "自定义执行周期执行命令并推送结果。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/code.png" + # 插件版本 + plugin_version = "1.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "customcommand_" + # 加载顺序 + plugin_order = 39 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _notify: bool = False + _clear: bool = False + _msgtype: str = None + _time_confs = None + _history_days = None + _notify_keywords = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._msgtype = config.get("msgtype") + self._clear = config.get("clear") + self._history_days = config.get("history_days") or 30 + self._notify_keywords = config.get("notify_keywords") + self._time_confs = config.get("time_confs") + + # 清除历史 + if self._clear: + self.del_data('history') + self._clear = False + self.__update_config() + + if (self._enabled or self._onlyonce) and self._time_confs: + # 周期运行 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 分别执行命令,输入结果 + for time_conf in self._time_confs.split("\n"): + if time_conf: + if str(time_conf).startswith("#"): + logger.info(f"已被注释,跳过 {time_conf}") + continue + if str(time_conf).count("#") == 2 or str(time_conf).count("#") == 3: + name = str(time_conf).split("#")[0] + cron = str(time_conf).split("#")[1] + command = str(time_conf).split("#")[2] + random_delay = None + if str(time_conf).count("#") == 3: + random_delay = str(time_conf).split("#")[3] + + if self._onlyonce: + # 立即运行一次 + logger.info(f"{name}服务启动,立即运行一次") + self._scheduler.add_job(self.__execute_command, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name=name, + args=[name, command]) + else: + try: + self._scheduler.add_job(func=self.__execute_command, + trigger=CronTrigger.from_crontab(str(cron)), + name=name + ( + f"随机延时{random_delay}秒" if random_delay else ""), + args=[name, command, random_delay]) + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + else: + logger.error(f"{time_conf} 配置错误,跳过处理") + + if self._onlyonce: + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __execute_command(self, name, command, random_delay=None): + """ + 执行命令 + """ + if random_delay: + random_delay = random.randint(int(str(random_delay).split("-")[0]), int(str(random_delay).split("-")[1])) + logger.info(f"随机延时 {random_delay} 秒") + time.sleep(random_delay) + + result = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + last_output = None + last_error = None + while True: + error = result.stderr.readline().decode("utf-8") + if error == '' and result.poll() is not None: + break + if error: + logger.info(error.strip()) + last_error = error.strip() + while True: + output = result.stdout.readline().decode("utf-8") + if output == '' and result.poll() is not None: + break + if output: + logger.info(output.strip()) + last_output = output.strip() + + logger.info( + f"执行命令:{command} {'成功' if result.returncode == 0 else '失败'} 返回值:{last_output if last_output else last_error}") + + # 读取历史记录 + history = self.get_data('history') or [] + + history.append({ + "name": name, + "command": command, + "result": last_output if last_output else last_error, + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + + thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60 + history = [record for record in history if + datetime.strptime(record["time"], + '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago] + # 保存历史 + self.save_data(key="history", value=history) + + if self._notify and self._msgtype: + if self._notify_keywords and not re.search(self._notify_keywords, + last_output if last_output else last_error): + logger.info(f"通知关键词 {self._notify_keywords} 不匹配,跳过通知") + return + + # 发送通知 + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + + self.post_message(title=name, + mtype=mtype, + text=last_output if last_output else last_error) + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "notify": self._notify, + "msgtype": self._msgtype, + "time_confs": self._time_confs, + "history_days": self._history_days, + "notify_keywords": self._notify_keywords, + "clear": self._clear + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清除历史记录', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'history_days', + 'label': '保留历史天数' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'notify_keywords', + 'label': '通知关键词', + 'placeholder': '支持正则表达式,未配置时所有通知均推送' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'time_confs', + 'label': '执行命令', + 'rows': 2, + 'placeholder': '命令名#0 9 * * *#python main.py\n' + '命令名#0 9 * * *#python main.py#1-600' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '命令名#cron表达式#命令' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '命令名#cron表达式#命令#随机延时(单位秒)' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "clear": False, + "time_confs": "", + "history_days": 30, + "notify_keywords": "", + "msgtype": "" + } + + def get_page(self) -> List[dict]: + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + if not isinstance(historys, list): + historys = [historys] + + # 按照签到时间倒序 + historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True) + + # 签到消息 + sign_msgs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': history.get("time") + }, + { + 'component': 'td', + 'text': history.get("name") + }, + { + 'component': 'td', + 'text': history.get("result") + } + ] + } for history in historys + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行时间' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '命令名称' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行结果' + }, + ] + }, + { + 'component': 'tbody', + 'content': sign_msgs + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/dirmonitorenhanced/__init__.py b/plugins/dirmonitorenhanced/__init__.py new file mode 100644 index 0000000..6df0af4 --- /dev/null +++ b/plugins/dirmonitorenhanced/__init__.py @@ -0,0 +1,1063 @@ +import datetime +import re +import shutil +import threading +import traceback +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver + +from app import schemas +from app.chain.tmdb import TmdbChain +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.core.metainfo import MetaInfoPath +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType, TransferInfo +from app.schemas.types import EventType, MediaType, SystemConfigKey +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.event_handler(event=event, text="创建", + mon_path=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.sync.event_handler(event=event, text="移动", + mon_path=self._watch_path, event_path=event.dest_path) + + +class DirMonitorEnhanced(_PluginBase): + # 插件名称 + plugin_name = "目录监控" + # 插件描述 + plugin_desc = "监控目录文件发生变化时实时整理到媒体库。(统一入库消息增强版)" + # 插件图标 + plugin_icon = "directory.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "dirmonitorenhanced_" + # 加载顺序 + plugin_order = 4 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + transferhis = None + downloadhis = None + transferchian = None + tmdbchain = None + _observer = [] + _enabled = False + _notify = False + _onlyonce = False + _cron = None + _size = 0 + _scrape = True + # 模式 compatibility/fast + _mode = "fast" + # 转移方式 + _transfer_type = "link" + _monitor_dirs = "" + _exclude_keywords = "" + _interval: int = 10 + # 存储源目录与目的目录关系 + _dirconf: Dict[str, Optional[Path]] = {} + # 存储源目录转移方式 + _transferconf: Dict[str, Optional[str]] = {} + _medias = {} + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + self.transferhis = TransferHistoryOper() + self.downloadhis = DownloadHistoryOper() + self.transferchian = TransferChain() + self.tmdbchain = TmdbChain() + # 清空配置 + self._dirconf = {} + self._transferconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._mode = config.get("mode") + self._transfer_type = config.get("transfer_type") + self._monitor_dirs = config.get("monitor_dirs") or "" + self._exclude_keywords = config.get("exclude_keywords") or "" + self._interval = config.get("interval") or 10 + self._cron = config.get("cron") + self._size = config.get("size") or 0 + self._scrape = config.get("scrape") or False + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + + # 读取目录配置 + monitor_dirs = self._monitor_dirs.split("\n") + if not monitor_dirs: + return + for mon_path in monitor_dirs: + # 格式源目录:目的目录 + if not mon_path: + continue + + # 自定义转移方式 + _transfer_type = self._transfer_type + if mon_path.count("#") == 1: + _transfer_type = mon_path.split("#")[1] + mon_path = mon_path.split("#")[0] + + # 存储目的目录 + if SystemUtils.is_windows(): + if mon_path.count(":") > 1: + paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], + mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] + else: + paths = [mon_path] + else: + paths = mon_path.split(":") + + # 目的目录 + target_path = None + if len(paths) > 1: + mon_path = paths[0] + target_path = Path(paths[1]) + self._dirconf[mon_path] = target_path + else: + self._dirconf[mon_path] = None + + # 转移方式 + self._transferconf[mon_path] = _transfer_type + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and target_path.is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", + title="目录监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if self._mode == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{mon_path} 的目录监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") + self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="目录监控") + + # 运行一次定时服务 + if self._onlyonce: + logger.info("目录监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "mode": self._mode, + "transfer_type": self._transfer_type, + "monitor_dirs": self._monitor_dirs, + "exclude_keywords": self._exclude_keywords, + "interval": self._interval, + "cron": self._cron, + "size": self._size, + "scrape": self._scrape + }) + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程全量同步 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "enhanced_directory_sync": + return + self.post_message(channel=event.event_data.get("channel"), + title="开始同步监控目录 ...", + userid=event.event_data.get("user")) + self.sync_all() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="监控目录同步完成!", userid=event.event_data.get("user")) + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步监控目录 ...") + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): + self.__handle_file(event_path=str(file_path), mon_path=mon_path) + logger.info("全量同步监控目录完成!") + + def event_handler(self, event, mon_path: str, text: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param mon_path: 监控目录 + :param text: 事件描述 + :param event_path: 事件文件路径 + """ + if not event.is_directory: + # 文件发生变化 + logger.debug("文件%s:%s" % (text, event_path)) + self.__handle_file(event_path=event_path, mon_path=mon_path) + + def __handle_file(self, event_path: str, mon_path: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param mon_path: 监控目录 + """ + file_path = Path(event_path) + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + transfer_history = self.transferhis.get_by_src(event_path) + if transfer_history: + logger.debug("文件已处理过:%s" % event_path) + return + + # 回收站及隐藏的文件不处理 + if event_path.find('/@Recycle/') != -1 \ + or event_path.find('/#recycle/') != -1 \ + or event_path.find('/.') != -1 \ + or event_path.find('/@eaDir') != -1: + logger.debug(f"{event_path} 是回收站或隐藏的文件") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 整理屏蔽词不处理 + transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) + if transfer_exclude_words: + for keyword in transfer_exclude_words: + if not keyword: + continue + if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): + logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") + return + + # 不是媒体文件不处理 + if file_path.suffix not in settings.RMT_MEDIAEXT: + logger.debug(f"{event_path} 不是媒体文件") + return + + # 判断是不是蓝光目录 + bluray_flag = False + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + bluray_flag = True + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + + # 查询历史记录,已转移的不处理 + if self.transferhis.get_by_src(str(file_path)): + logger.info(f"{file_path} 已整理过") + return + + # 元数据 + file_meta = MetaInfoPath(file_path) + if not file_meta.name: + logger.error(f"{file_path.name} 无法识别有效信息") + return + + # 判断文件大小 + if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: + logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") + return + + # 查询转移目的目录 + target: Path = self._dirconf.get(mon_path) + # 查询转移方式 + transfer_type = self._transferconf.get(mon_path) + + # 根据父路径获取下载历史 + download_history = None + if bluray_flag: + # 蓝光原盘,按目录名查询 + # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 + download_history = self.downloadhis.get_by_path(file_path.name) + else: + # 按文件全路径查询 + download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) + if download_file: + download_history = self.downloadhis.get_by_hash(download_file.download_hash) + + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta, + mtype=MediaType( + download_history.type) if download_history else None, + tmdbid=download_history.tmdbid if download_history else None) + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') + # 新增转移成功历史记录 + his = self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + meta=file_meta + ) + if self._notify: + self.post_message( + mtype=NotificationType.Manual, + title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" + f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" + ) + return + + # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title + if not settings.SCRAP_FOLLOW_TMDB: + transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, + mtype=mediainfo.type.value) + if transfer_history: + mediainfo.title = transfer_history.title + logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") + + # 更新媒体图片 + self.chain.obtain_images(mediainfo=mediainfo) + + # 获取集数据 + if mediainfo.type == MediaType.TV: + episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, + season=file_meta.begin_season or 1) + else: + episodes_info = None + + # 获取下载Hash + download_hash = None + if download_history: + download_hash = download_history.download_hash + + # 转移 + transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, + path=file_path, + transfer_type=transfer_type, + target=target, + meta=file_meta, + episodes_info=episodes_info) + + if not transferinfo: + logger.error("文件转移模块运行失败") + return + + if not transferinfo.success: + # 判断是否转移后文件已存在,补充转移成功历史记录 + if transferinfo.target_path and transferinfo.target_path.exists(): + logger.info(f"{file_path.name} 目标文件已存在,补充转移成功历史记录") + # 补充转移成功历史记录 + self.transferhis.add_success( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + return + + # 转移失败 + logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") + # 新增转移失败历史记录 + self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + if self._notify: + self.post_message( + mtype=NotificationType.Manual, + title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", + text=f"原因:{transferinfo.message or '未知'}", + image=mediainfo.get_message_image() + ) + return + + # 新增转移成功历史记录 + self.transferhis.add_success( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + + # 刮削单个文件 + if self._scrape: + self.chain.scrape_metadata(path=transferinfo.target_path, + mediainfo=mediainfo, + transfer_type=transfer_type) + + """ + { + "title_year season": { + "files": [ + { + "path":, + "mediainfo":, + "file_meta":, + "transferinfo": + } + ], + "time": "2023-08-24 23:23:23.332", + "all_files_cnt": 20 + } + } + """ + # 发送消息汇总 + media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} + if media_list: + media_files = media_list.get("files") or [] + if media_files: + file_exists = False + for file in media_files: + if str(file_path) == file.get("path"): + file_exists = True + break + if not file_exists: + media_files.append({ + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + }) + else: + media_files = [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ] + media_list = { + "files": media_files, + "time": datetime.datetime.now(), + "all_files_cnt": media_list.get("all_files_cnt") + } + else: + # 获取当前媒体本次下载的文件数 + recent_download_files_cnt = self.__get_recent_download_files_cnt(download_hash=download_hash) + + media_list = { + "files": [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ], + "time": datetime.datetime.now(), + "all_files_cnt": recent_download_files_cnt + } + self._medias[mediainfo.title_year + " " + file_meta.season] = media_list + + # 广播事件 + self.eventmanager.send_event(EventType.TransferComplete, { + 'meta': file_meta, + 'mediainfo': mediainfo, + 'transferinfo': transferinfo + }) + + # 移动模式删除空目录 + if transfer_type == "move": + for file_dir in file_path.parents: + if len(str(file_dir)) <= len(str(Path(mon_path))): + # 重要,删除到监控目录为止 + break + files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT) + if not files: + logger.warn(f"移动模式,删除空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + except Exception as e: + logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def __get_recent_download_files_cnt(self, download_hash: str): + """ + 1。根据download_hash查询下载历史 + 2。查询该下载历史记录创建时间前1分钟及以后的所有的该type和tmdbid下的下载历史(订阅批量下载的话,下载时间间隔应该不会超过一分钟吧。) + 3。根据查询到的下载历史列表遍历查询对应的下载文件记录 + 4。根据统计的下载文件记录数目,等待入库消息统一发送。 + 5。统一入库消息(如果当前入库的媒体数据 < 本次批量下载的文件数量,暂不处理,等待一会(容错:最大retry 5次)) + """ + # 根据download_hash查询下载记录 + recent_download_files = 0 + try: + download_history = self.downloadhis.get_by_hash(download_hash=download_hash) + if download_history: + # 根据下载历史查询 下载时间前一分钟及以后的下载记录 + # 将时间字符串转换为datetime对象 - 减去一分钟 + new_dt = datetime.datetime.strptime(download_history.date, "%Y-%m-%d %H:%M:%S") - datetime.timedelta( + minutes=1) + download_historys = self.downloadhis.list_by_date(date=new_dt.strftime("%Y-%m-%d %H:%M:%S"), + type=download_history.type, + tmdbid=str(download_history.tmdbid), + seasons=download_history.seasons) + if download_historys: + for download_his in download_historys: + # 根据download_hash获取下载文件列表 + download_files = self.downloadhis.get_files_by_hash( + download_hash=download_his.download_hash, + state=1) + if download_files: + recent_download_files += len(download_files) + except Exception as e: + print(str(e)) + + return recent_download_files + + def send_msg(self): + """ + 定时检查是否有媒体处理完,发送统一消息 + """ + if not self._medias or not self._medias.keys(): + return + + # 遍历检查是否已刮削完,发送消息 + for medis_title_year_season in list(self._medias.keys()): + media_list = self._medias.get(medis_title_year_season) + logger.info(f"开始处理媒体 {medis_title_year_season} 消息") + + if not media_list: + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + media_files = media_list.get("files") + if not last_update_time or not media_files: + continue + + all_files_cnt = media_list.get("all_files_cnt") or 0 + retry_cnt = media_list.get("retry_cnt") or 0 + transferinfo = media_files[0].get("transferinfo") + file_meta = media_files[0].get("file_meta") + mediainfo = media_files[0].get("mediainfo") + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ + or mediainfo.type == MediaType.MOVIE: + + # 如果当前入库的媒体数据 < 本次批量下载的文件数量,暂不处理,等待一会(容错:最大retry 5次) + if all_files_cnt > 0 and len(media_files) < all_files_cnt and retry_cnt < 5: + # 更新重试次数 + media_list['retry_cnt'] = retry_cnt + 1 + self._medias[medis_title_year_season] = media_list + logger.info( + f"本次批量下载任务{all_files_cnt}个文件,已转移文件{len(media_files)}个,未完全转移,等待{int(self._interval)}秒开始重试第{retry_cnt + 1}次,最大重试5次") + continue + + # 发送通知 + if self._notify: + # 汇总处理文件总大小 + total_size = 0 + file_count = 0 + + # 剧集汇总 + episodes = [] + for file in media_files: + transferinfo = file.get("transferinfo") + total_size += transferinfo.total_size + file_count += 1 + + file_meta = file.get("file_meta") + if file_meta and file_meta.begin_episode: + episodes.append(file_meta.begin_episode) + + transferinfo.total_size = total_size + # 汇总处理文件数量 + transferinfo.file_count = file_count + + # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 + season_episode = None + # 处理文件多,说明是剧集,显示季入库消息 + if mediainfo.type == MediaType.TV: + # 季集文本 + season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" + # 发送消息 + self.transferchian.send_transfer_message(meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode=season_episode) + # 发送完消息,移出key + del self._medias[medis_title_year_season] + continue + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/enhanced_directory_sync", + "event": EventType.PluginAction, + "desc": "目录监控同步(统一入库消息增强版)", + "category": "管理", + "data": { + "action": "enhanced_directory_sync" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + return [{ + "path": "/enhanced_directory_sync", + "endpoint": self.sync, + "methods": ["GET"], + "summary": "目录监控(统一入库消息增强版)", + "description": "目录监控(统一入库消息增强版)", + }] + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "DirMonitorEnhanced", + "name": "目录监控(统一入库消息增强版)全量同步服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self, apikey: str) -> schemas.Response: + """ + API调用目录同步 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + self.sync_all() + return schemas.Response(success=True) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '监控模式', + 'items': [ + {'title': '兼容模式', 'value': 'compatibility'}, + {'title': '性能模式', 'value': 'fast'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'transfer_type', + 'label': '整理方式', + 'items': [ + {'title': '移动', 'value': 'move'}, + {'title': '复制', 'value': 'copy'}, + {'title': '硬链接', 'value': 'link'}, + {'title': '软链接', 'value': 'softlink'}, + {'title': 'Rclone复制', 'value': 'rclone_copy'}, + {'title': 'Rclone移动', 'value': 'rclone_move'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '监控文件大小(GB)', + 'placeholder': '0' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'scrape', + 'label': '刮削元数据', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_dirs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' + '监控目录\n' + '监控目录#整理方式\n' + '监控目录:整理目的目录\n' + '监控目录:整理目的目录#转移方式' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '支持4种配置方式:1、监控目录,2、监控目录#整理方式,3、监控目录:整理目的目录,4、监控目录:整理目的目录#转移方式。监控目录不指定目的目录时,将按媒体库目录设置整理到媒体库目录,并根据目录的分类设置自动创建一二级分类目录;监控目录指定了目的目录时,会尝试在媒体库目录设定中查找对应路径的目录配置,如存在则以目录设定的分类选项创建子目录,否则直接整理到该目的目录下。建议不设置目的目录,由系统根据目录设定自动分类整理。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '入库消息延迟默认10s,如网络较慢可酌情调大,有助于发送统一入库消息。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "mode": "fast", + "transfer_type": "link", + "monitor_dirs": "", + "exclude_keywords": "", + "interval": 10, + "cron": "", + "size": 0, + "scrape": True + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None diff --git a/plugins/dockermanager/__init__.py b/plugins/dockermanager/__init__.py new file mode 100644 index 0000000..fb3063f --- /dev/null +++ b/plugins/dockermanager/__init__.py @@ -0,0 +1,510 @@ +import docker +import time +from datetime import datetime, timedelta +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType + + +class DockerManager(_PluginBase): + # 插件名称 + plugin_name = "docker自定义任务" + # 插件描述 + plugin_desc = "管理宿主机docker,自定义容器定时任务。" + # 插件图标 + plugin_icon = "Docker_F.png" + # 插件版本 + plugin_version = "1.3" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "dockermanager_" + # 加载顺序 + plugin_order = 39 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _notify: bool = False + _clear: bool = False + _msgtype: str = None + _time_confs = None + _docker_client = None + _history_days = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._msgtype = config.get("msgtype") + self._clear = config.get("clear") + self._time_confs = config.get("time_confs") + self._history_days = config.get("history_days") or 30 + + # 清除历史 + if self._clear: + self.del_data('history') + self._clear = False + self.__update_config() + + if (self._enabled or self._onlyonce) and self._time_confs: + # 创建 Docker 客户端 + self._docker_client = docker.DockerClient(base_url='tcp://127.0.0.1:38379') + # 周期运行 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 分别执行命令,输入结果 + for time_conf in self._time_confs.split("\n"): + if time_conf: + if str(time_conf).startswith("#"): + logger.info(f"已被注释,跳过 {time_conf}") + continue + if str(time_conf).count("#") == 2: + name = str(time_conf).split("#")[0] + cron = str(time_conf).split("#")[1] + command = str(time_conf).split("#")[2] + if self._onlyonce: + # 立即运行一次 + logger.info(f"容器 {name} 立即执行 {command}") + self._scheduler.add_job(self.__execute_command, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name=f"{name} {command}", + args=[name, command]) + else: + try: + self._scheduler.add_job(func=self.__execute_command, + trigger=CronTrigger.from_crontab(str(cron)), + name=f"{name} {command}", + args=[name, command]) + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + else: + logger.error(f"{time_conf} 配置错误,跳过处理") + + if self._onlyonce: + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __execute_command(self, name, command): + """ + 执行命令 + """ + # 获取所有容器列表 + containers = self._docker_client.containers.list(all=True) + + container_names = str(name).split(",") + + container_icon = None + log_text = "" + # 遍历容器列表,找到对应名称的容器ID + for container in containers: + for env in container.attrs['Config']['Env']: + if str(env.split("=")[0]) == "HOST_CONTAINERNAME": + if str(env.split('=')[1]) in container_names: + container_id = container.id + # 执行命令 + log_text += f"容器:{env.split('=')[1]} {command}" + + try: + state = True + if str(command) == "restart": + self._docker_client.containers.get(container_id).restart() + elif str(command) == "start": + self._docker_client.containers.get(container_id).start() + elif str(command) == "stop": + self._docker_client.containers.get(container_id).stop() + elif str(command) == "pause": + self._docker_client.containers.get(container_id).pause() + elif str(command) == "unpause": + self._docker_client.containers.get(container_id).unpause() + elif str(command) == "update": + self._docker_client.containers.get(container_id).update() + else: + logger.error(f"不支持的命令:{command}") + break + except Exception as e: + print(str(e)) + state = False + + if state: + log_text += " success\n" + logger.info(log_text) + else: + log_text += " fail\n" + logger.error(log_text) + + # 读取历史记录 + history = self.get_data('history') or [] + + history.append({ + "name": env.split('=')[1], + "command": command, + "result": 'success' if state else 'fail', + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + + thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60 + history = [record for record in history if + datetime.strptime(record["time"], + '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago] + # 保存历史 + self.save_data(key="history", value=history) + + container_icon = container.attrs['Config']['Labels']['net.unraid.docker.icon'] + + if self._notify and self._msgtype: + # 发送通知 + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + + self.post_message(title="docker任务通知", + mtype=mtype, + text=log_text, + image=container_icon if len(container_names) == 1 and container_icon and str( + container_icon).startswith("http") else None) + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "notify": self._notify, + "msgtype": self._msgtype, + "time_confs": self._time_confs, + "history_days": self._history_days, + "clear": self._clear + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清除历史记录', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'history_days', + 'label': '保留历史天数' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'time_confs', + 'label': '执行命令', + 'rows': 2, + 'placeholder': '容器名#cron表达式#restart/start/stop/unpause/update' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '容器名(多个容器名,拼接)#cron表达式#restart/start/stop/pause/unpause/update' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "clear": False, + "time_confs": "", + "history_days": 30, + "msgtype": "" + } + + def get_page(self) -> List[dict]: + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + if not isinstance(historys, list): + historys = [historys] + + historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True) + + msgs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'text': history.get("time") + }, + { + 'component': 'td', + 'text': history.get("name") + }, + { + 'component': 'td', + 'text': history.get("command") + }, + { + 'component': 'td', + 'text': history.get("result") + } + ] + } for history in historys + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行时间' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '容器名称' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '命令' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行结果' + }, + ] + }, + { + 'component': 'tbody', + 'content': msgs + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/downloadtorrent/__init__.py b/plugins/downloadtorrent/__init__.py new file mode 100644 index 0000000..1056aad --- /dev/null +++ b/plugins/downloadtorrent/__init__.py @@ -0,0 +1,227 @@ +from app.db.site_oper import SiteOper +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.utils.string import StringUtils + + +class DownloadTorrent(_PluginBase): + # 插件名称 + plugin_name = "添加种子下载" + # 插件描述 + plugin_desc = "选择下载器,添加种子任务。" + # 插件图标 + plugin_icon = "download.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "downloadtorrent_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _downloader = None + _is_paused = False + _save_path = None + _torrent_urls = None + qb = None + tr = None + site = None + + def init_plugin(self, config: dict = None): + self.qb = Qbittorrent() + self.tr = Transmission() + self.site = SiteOper() + + if config: + self._downloader = config.get("downloader") + self._is_paused = config.get("is_paused") + self._save_path = config.get("save_path") + self._torrent_urls = config.get("torrent_urls") + + # 下载种子 + if self._torrent_urls: + for torrent_url in str(self._torrent_urls).split("\n"): + # 获取种子对应站点cookie + domain = StringUtils.get_url_domain(torrent_url) + if not domain: + logger.error(f"种子 {torrent_url} 获取站点域名失败,跳过处理") + continue + + # 查询站点 + site = self.site.get_by_domain(domain) + if not site or not site.cookie: + logger.error(f"种子 {torrent_url} 获取站点cookie失败,跳过处理") + continue + + # 添加下载 + if str(self._downloader) == "qb": + torrent = self.qb.add_torrent(content=torrent_url, + is_paused=self._is_paused, + download_dir=self._save_path, + cookie=site.cookie) + else: + torrent = self.tr.add_torrent(content=torrent_url, + is_paused=self._is_paused, + download_dir=self._save_path, + cookie=site.cookie) + + if torrent: + logger.info(f"种子添加下载成功 {torrent_url} 保存位置 {self._save_path}") + else: + logger.error(f"种子添加下载失败 {torrent_url} 保存位置 {self._save_path}") + + self.update_config({ + "downloader": self._downloader, + "save_path": self._save_path, + "is_paused": self._is_paused + }) + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader', + 'label': '下载器', + 'items': [ + {'title': 'qb', 'value': 'qb'}, + {'title': 'tr', 'value': 'tr'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'is_paused', + 'label': '暂停种子', + 'items': [ + {'title': '开启', 'value': True}, + {'title': '不开启', 'value': False} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存路径' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'torrent_urls', + 'rows': '3', + 'label': '种子链接', + 'placeholder': '种子链接,一行一个' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '保存路径为下载器保存路径,种子链接一行一个。' + '添加的种子链接需站点已在站点管理维护或公共站点。' + } + } + ] + } + ] + } + ] + } + ], { + "downloader": "qb", + "is_paused": False, + "save_path": "", + "torrent_urls": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/embymetarefresh/__init__.py b/plugins/embymetarefresh/__init__.py new file mode 100644 index 0000000..627a330 --- /dev/null +++ b/plugins/embymetarefresh/__init__.py @@ -0,0 +1,416 @@ +from datetime import datetime, timedelta +from typing import Optional, Any, List, Dict, Tuple + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.event import eventmanager, Event +from app.db.transferhistory_oper import TransferHistoryOper +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase +from app.modules.emby import Emby +from app.schemas.types import EventType +from app.utils.http import RequestUtils + + +class EmbyMetaRefresh(_PluginBase): + # 插件名称 + plugin_name = "Emby元数据刷新" + # 插件描述 + plugin_desc = "定时刷新Emby媒体库元数据。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/emby-icon.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "embymetarefresh_" + # 加载顺序 + plugin_order = 15 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _onlyonce = False + _cron = None + _days = None + _EMBY_HOST = settings.EMBY_HOST + _EMBY_APIKEY = settings.EMBY_API_KEY + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._days = config.get("days") or 5 + + if self._EMBY_HOST: + if not self._EMBY_HOST.endswith("/"): + self._EMBY_HOST += "/" + if not self._EMBY_HOST.startswith("http"): + self._EMBY_HOST = "http://" + self._EMBY_HOST + + # 加载模块 + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"媒体库元数据刷新服务启动,立即运行一次") + self._scheduler.add_job(self.refresh, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="媒体库元数据") + + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.refresh, + trigger=CronTrigger.from_crontab(self._cron), + name="媒体库元数据") + except Exception as err: + logger.error(f"定时任务配置错误:{str(err)}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def __update_config(self): + self.update_config( + { + "onlyonce": self._onlyonce, + "cron": self._cron, + "enabled": self._enabled, + "days": self._days + } + ) + + def refresh(self): + """ + 刷新媒体库元数据 + """ + if "emby" not in settings.MEDIASERVER: + logger.error("未配置Emby媒体服务器") + return + + # 获取days内入库的媒体 + current_date = datetime.now() + # 计算几天前的日期 + target_date = current_date - timedelta(days=int(self._days)) + transferhistorys = TransferHistoryOper().list_by_date(target_date.strftime('%Y-%m-%d')) + if not transferhistorys: + logger.error(f"{self._days}天内没有媒体库入库记录") + return + + logger.info(f"开始刷新媒体库元数据,最近{self._days}天内入库媒体:{len(transferhistorys)}个") + # 刷新媒体库 + for transferinfo in transferhistorys: + self.__refresh_emby(transferinfo) + logger.info(f"刷新媒体库元数据完成") + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程刷新媒体库 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "emby_meta_refresh": + return + self.post_message(channel=event.event_data.get("channel"), + title="开始刷新Emby元数据 ...", + userid=event.event_data.get("user")) + self.refresh() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="刷新Emby元数据完成!", userid=event.event_data.get("user")) + + def __refresh_emby(self, transferinfo): + """ + 刷新emby + """ + if transferinfo.type == "电影": + movies = Emby().get_movies(title=transferinfo.title, year=transferinfo.year) + if not movies: + logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})") + return + for movie in movies: + self.__refresh_emby_library_by_id(item_id=movie.item_id) + logger.info(f"已通知刷新Emby电影:{movie.title} ({movie.year}) item_id:{movie.item_id}") + else: + item_id = self.__get_emby_series_id_by_name(name=transferinfo.title, year=transferinfo.year) + if not item_id or item_id is None: + logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})") + return + + # 验证tmdbid是否相同 + item_info = Emby().get_iteminfo(item_id) + if item_info: + if transferinfo.tmdbid and item_info.tmdbid: + if str(transferinfo.tmdbid) != str(item_info.tmdbid): + logger.error(f"Emby中{transferinfo.title} ({transferinfo.year})的tmdbId与入库记录不一致") + return + + # 查询集的item_id + season = int(transferinfo.seasons.replace("S", "")) + episode = int(transferinfo.episodes.replace("E", "")) + episode_item_id = self.__get_emby_episode_item_id(item_id=item_id, season=season, episode=episode) + if not episode_item_id or episode_item_id is None: + logger.error( + f"Emby中没有找到{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes}") + return + + self.__refresh_emby_library_by_id(item_id=episode_item_id) + logger.info( + f"已通知刷新Emby电视剧:{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes} item_id:{episode_item_id}") + + def __get_emby_episode_item_id(self, item_id: str, season: int, episode: int) -> Optional[str]: + """ + 根据剧集信息查询Emby中集的item_id + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % ( + self._EMBY_HOST, item_id, season, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res_json: + if res_json: + tv_item = res_json.json() + res_items = tv_item.get("Items") + for res_item in res_items: + season_index = res_item.get("ParentIndexNumber") + if not season_index: + continue + if season and season != season_index: + continue + episode_index = res_item.get("IndexNumber") + if not episode_index: + continue + if episode and episode != episode_index: + continue + episode_item_id = res_item.get("Id") + return episode_item_id + except Exception as e: + logger.error(f"连接Shows/Id/Episodes出错:" + str(e)) + return None + return None + + def __refresh_emby_library_by_id(self, item_id: str) -> bool: + """ + 通知Emby刷新一个项目的媒体库 + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return False + req_url = "%semby/Items/%s/Refresh?MetadataRefreshMode=FullRefresh" \ + "&ImageRefreshMode=FullRefresh&ReplaceAllMetadata=true&ReplaceAllImages=true&api_key=%s" % ( + self._EMBY_HOST, item_id, self._EMBY_APIKEY) + try: + with RequestUtils().post_res(req_url) as res: + if res: + return True + else: + logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!") + except Exception as e: + logger.error(f"连接Items/Id/Refresh出错:" + str(e)) + return False + return False + + def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]: + """ + 根据名称查询Emby中剧集的SeriesId + :param name: 标题 + :param year: 年份 + :return: None 表示连不通,""表示未找到,找到返回ID + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = ("%semby/Items?" + "IncludeItemTypes=Series" + "&Fields=ProductionYear" + "&StartIndex=0" + "&Recursive=true" + "&SearchTerm=%s" + "&Limit=10" + "&IncludeSearchTypes=false" + "&api_key=%s") % ( + self._EMBY_HOST, name, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res: + if res: + res_items = res.json().get("Items") + if res_items: + for res_item in res_items: + if res_item.get('Name') == name and ( + not year or str(res_item.get('ProductionYear')) == str(year)): + return res_item.get('Id') + except Exception as e: + logger.error(f"连接Items出错:" + str(e)) + return None + return "" + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [{ + "cmd": "/emby_meta_refresh", + "event": EventType.PluginAction, + "desc": "Emby媒体库刷新", + "category": "", + "data": { + "action": "emby_meta_refresh" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + "component": "VForm", + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + ] + }, + { + "component": "VRow", + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'days', + 'label': '最新入库天数' + } + } + ] + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '查询入库记录,周期请求媒体服务器元数据刷新接口。注:只支持Emby。' + } + } + ] + } + ] + } + ], + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + "days": 5 + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/embymetatag/__init__.py b/plugins/embymetatag/__init__.py new file mode 100644 index 0000000..ff5d36e --- /dev/null +++ b/plugins/embymetatag/__init__.py @@ -0,0 +1,462 @@ +import json +from datetime import datetime, timedelta +from typing import Optional, Any, List, Dict, Tuple + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.modules.emby import Emby +from app.schemas.types import EventType +from app.utils.http import RequestUtils + + +class EmbyMetaTag(_PluginBase): + # 插件名称 + plugin_name = "Emby媒体标签" + # 插件描述 + plugin_desc = "自动给媒体库媒体添加标签。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/tag.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "embymetatag_" + # 加载顺序 + plugin_order = 16 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _onlyonce = False + _cron = None + _tag_confs = None + _name_tag_confs = None + _EMBY_HOST = settings.EMBY_HOST + _EMBY_APIKEY = settings.EMBY_API_KEY + _EMBY_USER = Emby().get_user() + _scheduler: Optional[BackgroundScheduler] = None + + _tags = {} + _media_tags = {} + _media_type = {} + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._tag_confs = config.get("tag_confs") + self._name_tag_confs = config.get("name_tag_confs") + + if self._EMBY_HOST: + if not self._EMBY_HOST.endswith("/"): + self._EMBY_HOST += "/" + if not self._EMBY_HOST.startswith("http"): + self._EMBY_HOST = "http://" + self._EMBY_HOST + + _tags = {} + if self._tag_confs: + tag_confs = self._tag_confs.split("\n") + for tag_conf in tag_confs: + if tag_conf: + tag_conf = tag_conf.split("#") + if len(tag_conf) == 2: + librarys = tag_conf[0].split(',') + for library in librarys: + library_tags = self._tags.get(library) or [] + self._tags[library] = library_tags + tag_conf[1].split(',') + + _media_tags = {} + _media_type = {} + if self._name_tag_confs: + name_tag_confs = self._name_tag_confs.split("\n") + for name_tag_conf in name_tag_confs: + if name_tag_conf: + name_tag_conf = name_tag_conf.split("#") + if len(name_tag_conf) == 3: + media_names = name_tag_conf[0].split(',') + for media_name in media_names: + self._media_type[media_name] = name_tag_conf[1].split(',') + media_tags = self._media_tags.get(media_name) or [] + self._media_tags[media_name] = media_tags + name_tag_conf[2].split(',') + + # 加载模块 + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"Emby媒体标签服务启动,立即运行一次") + self._scheduler.add_job(self.auto_tag, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="Emby媒体标签") + + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.auto_tag, + trigger=CronTrigger.from_crontab(self._cron), + name="Emby媒体标签") + except Exception as err: + logger.error(f"定时任务配置错误:{str(err)}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + def __update_config(self): + self.update_config( + { + "onlyonce": self._onlyonce, + "cron": self._cron, + "enabled": self._enabled, + "tag_confs": self._tag_confs, + "name_tag_confs": self._name_tag_confs, + } + ) + + def auto_tag(self): + """ + 给设定媒体库打标签 + """ + if "emby" not in settings.MEDIASERVER: + logger.error("未配置Emby媒体服务器") + return + + if (not self._tags or len(self._tags.keys()) == 0) and ( + not self._media_tags or len(self._media_tags.keys()) == 0): + logger.error("未配置Emby媒体标签") + return + + # 媒体库标签 + if self._tags and len(self._tags.keys()) > 0: + # 获取emby 媒体库 + librarys = Emby().get_librarys() + if not librarys: + logger.error("获取媒体库失败") + return + + # 遍历媒体库,获取媒体库媒体 + for library in librarys: + # 获取媒体库标签 + library_tags = self._tags.get(library.name) + if not library_tags: + continue + + # 获取媒体库媒体 + library_items = Emby().get_items(library.id) + if not library_items: + continue + + for library_item in library_items: + if not library_item: + continue + # 获取item的tag + item_tags = self.__get_item_tags(library_item.item_id) or [] + + # 获取缺少的tag + add_tags = [] + for library_tag in library_tags: + if not item_tags or library_tag not in item_tags: + add_tags.append(library_tag) + + # 添加标签 + if add_tags: + tags = [{"Name": str(add_tag)} for add_tag in add_tags] + tags = {"Tags": tags} + add_flag = self.__add_tag(library_item.item_id, tags) + logger.info(f"{library.name} 添加标签成功:{library_item.title} {tags} {add_flag}") + + # 特殊媒体名标签 + if self._media_tags and len(self._media_tags.keys()) > 0: + for media_name, media_tags in self._media_tags.items(): + + match_medias = [] + # 根据Series/Movie搜索媒体 + for media_type in self._media_type.get(media_name): + match_medias += self.__get_medias_by_name(media_name, media_type) + + # 遍历媒体 补充缺失tag + for media in match_medias: + if not media: + continue + + # 获取item的tag + item_tags = self.__get_item_tags(media.get("Id")) or [] + + # 获取缺少的tag + add_tags = [] + for media_tag in media_tags: + if not item_tags or media_tag not in item_tags: + add_tags.append(media_tag) + + # 添加标签 + if add_tags: + tags = [{"Name": str(add_tag)} for add_tag in add_tags] + tags = {"Tags": tags} + add_flag = self.__add_tag(media.get("Id"), tags) + logger.info(f"特殊媒体添加标签成功:{media.get('Name')} {tags} {add_flag}") + + logger.info("Emby媒体标签任务完成") + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程添加媒体标签 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "emby_meta_tag": + return + self.post_message(channel=event.event_data.get("channel"), + title="开始添加媒体标签 ...", + userid=event.event_data.get("user")) + self.auto_tag() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="添加媒体标签完成!", userid=event.event_data.get("user")) + + def __add_tag(self, itemid: str, tags: dict): + req_url = "%semby/Items/%s/Tags/Add?api_key=%s" % (self._EMBY_HOST, itemid, self._EMBY_APIKEY) + try: + with RequestUtils(content_type="application/json").post_res(url=req_url, json=tags) as res: + if res and res.status_code == 204: + return True + except Exception as e: + logger.error(f"连接Items/Id/Tags/Add出错:" + str(e)) + return False + + def __get_item_tags(self, itemid: str): + """ + 获取单个项目详情 + """ + if not itemid: + return None + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._EMBY_HOST, self._EMBY_USER, itemid, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res: + if res and res.status_code == 200: + item = res.json() + return [tag.get('Name') for tag in item.get("TagItems")] + except Exception as e: + logger.error(f"连接Items/Id出错:" + str(e)) + return [] + + def __get_medias_by_name(self, media_name: str, media_type: str): + """ + 搜索媒体名 + """ + if not media_name: + return None + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = ("%semby/Users/%s/Items?IncludeItemTypes=%s&Recursive=true&SearchTerm=%s&api_key=%s") % ( + self._EMBY_HOST, self._EMBY_USER, media_type, media_name, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res: + if res and res.status_code == 200: + item = res.json() + return item.get("Items") + except Exception as e: + logger.error(f"连接Items/Id出错:" + str(e)) + return [] + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [{ + "cmd": "/emby_meta_tag", + "event": EventType.PluginAction, + "desc": "Emby媒体标签", + "category": "", + "data": { + "action": "emby_meta_tag" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + "component": "VForm", + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + ] + }, + { + "component": "VRow", + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'tag_confs', + 'label': '媒体库标签配置', + 'rows': 3, + 'placeholder': '媒体库名,媒体库名#标签名,标签名' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'name_tag_confs', + 'label': '媒体名标签配置', + 'rows': 3, + 'placeholder': '媒体名称,媒体名称#Series,Movie#标签名,标签名' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '定时刷新Emby媒体库媒体,添加媒体库、媒体名(模糊匹配)自定义标签。' + } + } + ] + } + ] + } + ], + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + "tag_confs": "", + "name_tag_confs": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/embyreporter/__init__.py b/plugins/embyreporter/__init__.py new file mode 100644 index 0000000..1a7dec0 --- /dev/null +++ b/plugins/embyreporter/__init__.py @@ -0,0 +1,785 @@ +import os + +from app.core.config import settings +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.schemas import NotificationType +from pathlib import Path + +import random +from io import BytesIO +from PIL import Image +from PIL import ImageFont +from PIL import ImageDraw +import pytz +from cacheout import Cache +from datetime import datetime, timedelta + +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + +cache = Cache() + + +class EmbyReporter(_PluginBase): + # 插件名称 + plugin_name = "Emby观影报告" + # 插件描述 + plugin_desc = "推送Emby观影报告,需Emby安装Playback Report 插件。" + # 插件图标 + plugin_icon = "Pydiocells_A.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "embyreporter_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _res_dir = None + _cron = None + _days = None + _type = None + _cnt = None + _mp_host = None + _emby_host = None + _emby_api_key = None + _text_url = None + show_time = True + _scheduler: Optional[BackgroundScheduler] = None + + PLAYBACK_REPORTING_TYPE_MOVIE = "ItemName" + PLAYBACK_REPORTING_TYPE_TVSHOWS = "substr(ItemName,0, instr(ItemName, ' - '))" + host = None + api_key = None + + def init_plugin(self, config: dict = None): + self.host = f"http://{settings.EMBY_HOST}" if not str(settings.EMBY_HOST).startswith( + "http") else settings.EMBY_HOST + self.api_key = settings.EMBY_API_KEY + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._res_dir = config.get("res_dir") + self._days = config.get("days") or 7 + self._cnt = config.get("cnt") or 10 + self._type = config.get("type") or "tg" + self._mp_host = config.get("mp_host") + self.show_time = config.get("show_time") + self._text_url = config.get("text_url") + self._emby_host = config.get("emby_host") + self._emby_api_key = config.get("emby_api_key") + if self._emby_host and self._emby_api_key: + self.host = f"http://{self._emby_host}" if not str(self._emby_host).startswith( + "http") else self._emby_host + self.api_key = self._emby_api_key + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"Emby观影报告服务启动,立即运行一次") + self._scheduler.add_job(self.__report, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="Emby观影报告") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.__report, + trigger=CronTrigger.from_crontab(self._cron), + name="Emby观影报告") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __report(self): + """ + 发送Emby观影报告 + """ + # 本地路径转为url + if not self._mp_host: + return + + if not self._type: + return + + # 获取数据 + success, movies = self.get_report(types=self.PLAYBACK_REPORTING_TYPE_MOVIE, days=int(self._days), + limit=int(self._cnt)) + if not success: + exit(movies) + logger.info(f"获取到电影 {movies}") + success, tvshows = self.get_report(types=self.PLAYBACK_REPORTING_TYPE_TVSHOWS, days=int(self._days), + limit=int(self._cnt)) + if not success: + exit(tvshows) + logger.info(f"获取到电视剧 {tvshows}") + + # 绘制海报 + report_path = self.draw(res_path=self._res_dir, + movies=movies, + tvshows=tvshows, + show_time=self.show_time) + + if not report_path: + logger.error("生成海报失败") + return + + # 发送海报 + report_title = f"🌟*过去{self._days}日观影排行*" + + report_url = self._mp_host + report_path.replace("/public", "") + mtype = NotificationType.MediaServer + if self._type: + mtype = NotificationType.__getitem__(str(self._type)) or NotificationType.MediaServer + + # 每日一言 + report_text = None + if self._text_url: + try: + resp = RequestUtils().get_res(url=self._text_url) + if resp.status_code == 200: + report_text = resp.text + + if report_text: + report_text = str(report_text).replace("

", "").replace("

", "") + except Exception as e: + print(e) + self.post_message(title=report_title, + mtype=mtype, + text=report_text, + image=report_url) + logger.info(f"Emby观影记录推送成功 {report_url}") + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "days": self._days, + "cnt": self._cnt, + "type": self._type, + "mp_host": self._mp_host, + "text_url": self._text_url, + "show_time": self.show_time, + "emby_host": self._emby_host, + "emby_api_key": self._emby_api_key, + "res_dir": self._res_dir + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + # 编历 NotificationType 枚举,生成消息类型选项 + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'res_dir', + 'label': '素材路径', + 'placeholder': '本地素材路径' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'days', + 'label': '报告天数', + 'placeholder': '向前获取数据的天数' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cnt', + 'label': '观影记录数量', + 'placeholder': '获取观影数据数量,默认10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'mp_host', + 'label': 'MoviePilot域名', + 'placeholder': '必填,末尾不带/' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'type', + 'label': '推送方式', + 'items': MsgTypeOptions + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'show_time', + 'label': '是否显示观看时长', + 'items': [ + {'title': '是', 'value': True}, + {'title': '否', 'value': False} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'text_url', + 'label': '每日一言api', + 'placeholder': '空则不发送' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'emby_host', + 'label': '自定义emby host', + 'placeholder': 'IP:PORT' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'emby_api_key', + 'label': '自定义emby apiKey' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '如生成观影报告有空白记录,可酌情调大观影记录数量。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '如未设置自定义emby配置,则读取环境变量emby配置。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + "res_dir": "", + "days": 7, + "cnt": 10, + "emby_host": "", + "emby_api_key": "", + "mp_host": "", + "show_time": True, + "text_url": "", + "type": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def draw(self, res_path, movies, tvshows, show_time=True): + # 默认路径 默认图 + if not res_path: + res_path = os.path.join(Path(__file__).parent, "res") + # 绘图文件路径初始化 + bg_path = os.path.join(res_path, "bg") + mask_path = os.path.join(res_path, "cover-ranks-mask-2.png") + font_path = os.path.join(res_path, "PingFang Bold.ttf") + # 随机调取背景, 路径: res/ranks/bg/... + bg_list = os.listdir(bg_path) + bg_path = os.path.join(bg_path, bg_list[random.randint(0, len(bg_list) - 1)]) + # 初始绘图对象 + bg = Image.open(bg_path) + mask = Image.open(mask_path) + bg.paste(mask, (0, 0), mask) + font = ImageFont.truetype(font_path, 18) + font_small = ImageFont.truetype(font_path, 14) + font_count = ImageFont.truetype(font_path, 8) + + exists_movies = [] + for i in movies: + try: + # 榜单项数据 + user_id, item_id, item_type, name, count, duration = tuple(i) + print(item_type, item_id, name, count, StringUtils.str_secends(int(duration))) + # 封面图像获取 + success, data = self.primary(item_id) + if not success: + continue + exists_movies.append(i) + except Exception: + continue + + logger.info(f"过滤后未删除电影 {len(exists_movies)} 部") + # 合并绘制 + if len(exists_movies) < 5: + for i in range(5 - len(exists_movies) + 1): + exists_movies.append({"item_id": i}) + if len(exists_movies) > 5: + exists_movies = exists_movies[:5] + + exists_tvs = [] + for i in tvshows: + try: + # 榜单项数据 + user_id, item_id, item_type, name, count, duration = tuple(i) + print(item_type, item_id, name, count, StringUtils.str_secends(int(duration))) + # 图片获取,剧集主封面获取 + # 获取剧ID + success, data = self.items(user_id, item_id) + if not success: + continue + item_id = data["SeriesId"] + # 封面图像获取 + success, data = self.primary(item_id) + if not success: + continue + exists_tvs.append(i) + except Exception as e: + print(str(e)) + continue + logger.info(f"过滤后未删除电视剧 {len(exists_tvs)} 部") + if len(exists_tvs) > 5: + exists_tvs = exists_tvs[:5] + + all_ranks = exists_movies + exists_tvs + index, offset_y = (-1, 0) + for i in all_ranks: + index += 1 + try: + # 榜单项数据 + user_id, item_id, item_type, name, count, duration = tuple(i) + # 图片获取,剧集主封面获取 + if item_type != "Movie": + # 获取剧ID + success, data = self.items(user_id, item_id) + if not success: + index -= 1 + continue + item_id = data["SeriesId"] + # 封面图像获取 + success, data = self.primary(item_id) + if not success: + if item_type != "Movie": + index -= 1 + continue + # 剧集Y偏移 + if index >= 5: + index = 0 + offset_y = 331 + # 名称显示偏移 + font_offset_y = 0 + temp_font = font + # 名称超出长度缩小省略 + if font.getlength(name) > 110: + temp_font = font_small + font_offset_y = 4 + for i in range(len(name)): + name = name[:len(name) - 1] + if font.getlength(name) <= 110: + break + name += ".." + # 绘制封面 + cover = Image.open(BytesIO(data)) + cover = cover.resize((108, 159)) + bg.paste(cover, (73 + 145 * index, 379 + offset_y)) + # 绘制 播放次数、影片名称 + text = ImageDraw.Draw(bg) + if show_time: + self.draw_text_psd_style(text, + (177 + 145 * index - font_count.getlength( + StringUtils.str_secends(int(duration))), + 355 + offset_y), + StringUtils.str_secends(int(duration)), font_count, 126) + self.draw_text_psd_style(text, (74 + 145 * index, 542 + font_offset_y + offset_y), name, temp_font, 126) + except Exception: + continue + + if index > 0: + save_path = "/public/report.jpg" + if Path(save_path).exists(): + Path.unlink(Path(save_path)) + bg.save(save_path) + return save_path + return None + + @staticmethod + def draw_text_psd_style(draw, xy, text, font, tracking=0, leading=None, **kwargs): + """ + usage: draw_text_psd_style(draw, (0, 0), "Test", + tracking=-0.1, leading=32, fill="Blue") + + Leading is measured from the baseline of one line of text to the + baseline of the line above it. Baseline is the invisible line on which most + letters—that is, those without descenders—sit. The default auto-leading + option sets the leading at 120% of the type size (for example, 12‑point + leading for 10‑point type). + + Tracking is measured in 1/1000 em, a unit of measure that is relative to + the current type size. In a 6 point font, 1 em equals 6 points; + in a 10 point font, 1 em equals 10 points. Tracking + is strictly proportional to the current type size. + """ + + def stutter_chunk(lst, size, overlap=0, default=None): + for i in range(0, len(lst), size - overlap): + r = list(lst[i:i + size]) + while len(r) < size: + r.append(default) + yield r + + x, y = xy + font_size = font.size + lines = text.splitlines() + if leading is None: + leading = font.size * 1.2 + for line in lines: + for a, b in stutter_chunk(line, 2, 1, ' '): + w = font.getlength(a + b) - font.getlength(b) + draw.text((x, y), a, font=font, **kwargs) + x += w + (tracking / 1000) * font_size + y += leading + x = xy[0] + + @cache.memoize(ttl=600) + def primary(self, item_id, width=720, height=1440, quality=90, ret_url=False): + try: + url = self.host + f"/emby/Items/{item_id}/Images/Primary?maxHeight={height}&maxWidth={width}&quality={quality}" + if ret_url: + return url + resp = RequestUtils().get_res(url=url) + + if resp.status_code != 204 and resp.status_code != 200: + return False, "🤕Emby 服务器连接失败!" + return True, resp.content + except Exception: + return False, "🤕Emby 服务器连接失败!" + + @cache.memoize(ttl=600) + def backdrop(self, item_id, width=1920, quality=70, ret_url=False): + try: + url = self.host + f"/emby/Items/{item_id}/Images/Backdrop/0?&maxWidth={width}&quality={quality}" + if ret_url: + return url + resp = RequestUtils().get_res(url=url) + + if resp.status_code != 204 and resp.status_code != 200: + return False, "🤕Emby 服务器连接失败!" + return True, resp.content + except Exception: + return False, "🤕Emby 服务器连接失败!" + + @cache.memoize(ttl=600) + def logo(self, item_id, quality=70, ret_url=False): + url = self.host + f"/emby/Items/{item_id}/Images/Logo?quality={quality}" + if ret_url: + return url + resp = RequestUtils().get_res(url=url) + + if resp.status_code != 204 and resp.status_code != 200: + return False, "🤕Emby 服务器连接失败!" + return True, resp.content + + @cache.memoize(ttl=300) + def items(self, user_id, item_id): + try: + url = f"{self.host}/emby/Users/{user_id}/Items/{item_id}?api_key={self.api_key}" + resp = RequestUtils().get_res(url=url) + + if resp.status_code != 204 and resp.status_code != 200: + return False, "🤕Emby 服务器连接失败!" + return True, resp.json() + except Exception: + return False, "🤕Emby 服务器连接失败!" + + def get_report(self, days, types=None, user_id=None, end_date=datetime.now(pytz.timezone("Asia/Shanghai")), + limit=10): + if not types: + types = self.PLAYBACK_REPORTING_TYPE_MOVIE + sub_date = end_date - timedelta(days=int(days)) + start_time = sub_date.strftime("%Y-%m-%d 00:00:00") + end_time = end_date.strftime("%Y-%m-%d 23:59:59") + sql = "SELECT UserId, ItemId, ItemType, " + sql += types + " AS name, " + sql += "COUNT(1) AS play_count, " + sql += "SUM(PlayDuration - PauseDuration) AS total_duration " + sql += "FROM PlaybackActivity " + sql += f"WHERE ItemType = '{'Movie' if types == self.PLAYBACK_REPORTING_TYPE_MOVIE else 'Episode'}' " + sql += f"AND DateCreated >= '{start_time}' AND DateCreated <= '{end_time}' " + sql += "AND UserId not IN (select UserId from UserList) " + if user_id: + sql += f"AND UserId = '{user_id}' " + sql += "GROUP BY name " + sql += "ORDER BY total_duration DESC " + sql += "LIMIT " + str(limit) + + url = f"{self.host}/emby/user_usage_stats/submit_custom_query?api_key={self.api_key}" + + data = { + "CustomQueryString": sql, + "ReplaceUserId": False + } + resp = RequestUtils().post_res(url=url, data=data) + if resp.status_code != 204 and resp.status_code != 200: + return False, "🤕Emby 服务器连接失败!" + ret = resp.json() + if len(ret["colums"]) == 0: + return False, ret["message"] + return True, ret["results"] diff --git a/plugins/filesoftlink/__init__.py b/plugins/filesoftlink/__init__.py new file mode 100644 index 0000000..e75a942 --- /dev/null +++ b/plugins/filesoftlink/__init__.py @@ -0,0 +1,647 @@ +import datetime +import os +import re +import shutil +import threading +import traceback +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver + +from app import schemas +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType, SystemConfigKey +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.event_handler(event=event, text="创建", + mon_path=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.sync.event_handler(event=event, text="移动", + mon_path=self._watch_path, event_path=event.dest_path) + + +class FileSoftLink(_PluginBase): + # 插件名称 + plugin_name = "实时软连接" + # 插件描述 + plugin_desc = "监控目录文件变化,媒体文件软连接,其他文件可选复制。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png" + # 插件版本 + plugin_version = "1.8" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "filesoftlink_" + # 加载顺序 + plugin_order = 10 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + _observer = [] + _enabled = False + _onlyonce = False + _copy_files = False + _cron = None + _size = 0 + # 模式 compatibility/fast + _mode = "compatibility" + _monitor_dirs = "" + _exclude_keywords = "" + # 存储源目录与目的目录关系 + _dirconf: Dict[str, Optional[Path]] = {} + _medias = {} + + _rmt_mediaext = ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._copy_files = config.get("copy_files") + self._mode = config.get("mode") + self._monitor_dirs = config.get("monitor_dirs") or "" + self._exclude_keywords = config.get("exclude_keywords") or "" + self._cron = config.get("cron") + self._size = config.get("size") or 0 + self._rmt_mediaext = config.get( + "rmt_mediaext") or ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_dirs = self._monitor_dirs.split("\n") + if not monitor_dirs: + return + for mon_path in monitor_dirs: + # 格式源目录:目的目录 + if not mon_path: + continue + + # 存储目的目录 + if SystemUtils.is_windows(): + if mon_path.count(":") > 1: + paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], + mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] + else: + paths = [mon_path] + else: + paths = mon_path.split(":") + + # 目的目录 + target_path = None + if len(paths) > 1: + mon_path = paths[0] + target_path = Path(paths[1]) + self._dirconf[mon_path] = target_path + else: + self._dirconf[mon_path] = None + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and target_path.is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + # 异步开启云盘监控 + logger.info(f"异步开启实时硬链接 {mon_path} {self._mode},延迟5s启动") + self._scheduler.add_job(func=self.start_monitor, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=5), + name=f"实时硬链接 {mon_path}", + kwargs={ + "source_dir": mon_path + }) + + # 运行一次定时服务 + if self._onlyonce: + logger.info("实时软连接服务启动,立即运行一次") + self._scheduler.add_job(name="实时软连接", func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def start_monitor(self, source_dir: str): + """ + 异步开启实时软链接 + """ + try: + if str(self._mode) == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{source_dir} 的实时软链接服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"云盘监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}") + self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}") + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "copy_files": self._copy_files, + "mode": self._mode, + "monitor_dirs": self._monitor_dirs, + "exclude_keywords": self._exclude_keywords, + "cron": self._cron, + "size": self._size, + "rmt_mediaext": self._rmt_mediaext + }) + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程全量同步 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "softlink_sync": + return + self.post_message(channel=event.event_data.get("channel"), + title="开始同步监控目录 ...", + userid=event.event_data.get("user")) + self.sync_all() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="监控目录同步完成!", userid=event.event_data.get("user")) + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步监控目录 ...") + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for root, dirs, files in os.walk(mon_path): + for name in dirs + files: + path = os.path.join(root, name) + if Path(path).is_file(): + self.__handle_file(event_path=str(path), mon_path=mon_path) + logger.info("全量同步监控目录完成!") + + def event_handler(self, event, mon_path: str, text: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param mon_path: 监控目录 + :param text: 事件描述 + :param event_path: 事件文件路径 + """ + if not event.is_directory: + # 文件发生变化 + logger.debug("文件%s:%s" % (text, event_path)) + self.__handle_file(event_path=event_path, mon_path=mon_path) + + def __handle_file(self, event_path: str, mon_path: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param mon_path: 监控目录 + """ + file_path = Path(event_path) + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + # 回收站及隐藏的文件不处理 + if event_path.find('/@Recycle/') != -1 \ + or event_path.find('/#recycle/') != -1 \ + or event_path.find('/.') != -1 \ + or event_path.find('/@eaDir') != -1: + logger.debug(f"{event_path} 是回收站或隐藏的文件") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 整理屏蔽词不处理 + transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) + if transfer_exclude_words: + for keyword in transfer_exclude_words: + if not keyword: + continue + if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): + logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") + return + + # 判断是不是蓝光目录 + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + + # 判断文件大小 + if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: + logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") + return + + # 查询转移目的目录 + target: Path = self._dirconf.get(mon_path) + target_file = str(file_path).replace(str(mon_path), str(target)) + + # 如果是文件夹 + if Path(target_file).is_dir(): + if not Path(target_file).exists(): + logger.info(f"创建目标文件夹 {target_file}") + os.makedirs(target_file) + return + else: + # 文件 + if Path(target_file).exists(): + logger.info(f"目标文件 {target_file} 已存在") + return + + if not Path(target_file).parent.exists(): + logger.info(f"创建目标文件夹 {Path(target_file).parent}") + os.makedirs(Path(target_file).parent) + + # 媒体文件软连接 + if Path(target_file).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]: + retcode, retmsg = SystemUtils.softlink(file_path, Path(target_file)) + logger.info(f"创建媒体文件软连接 {str(file_path)} 到 {target_file} {retcode} {retmsg}") + else: + if self._copy_files: + # 其他nfo、jpg等复制文件 + shutil.copy2(str(file_path), target_file) + logger.info(f"复制其他文件 {str(file_path)} 到 {target_file}") + except Exception as e: + logger.error("软连接发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/softlink_sync", + "event": EventType.PluginAction, + "desc": "文件软连接同步", + "category": "", + "data": { + "action": "softlink_sync" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + return [{ + "path": "/softlink_sync", + "endpoint": self.sync, + "methods": ["GET"], + "summary": "实时软连接同步", + "description": "实时软连接同步", + }] + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "FileSoftLink", + "name": "实时软连接全量同步服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self) -> schemas.Response: + """ + API调用目录同步 + """ + self.sync_all() + return schemas.Response(success=True) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'copy_files', + 'label': '复制非媒体文件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '监控模式', + 'items': [ + {'title': '兼容模式', 'value': 'compatibility'}, + {'title': '性能模式', 'value': 'fast'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '监控文件大小(GB)', + 'placeholder': '0' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_dirs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控目录:转移目的目录' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'rmt_mediaext', + 'label': '视频格式', + 'rows': 2, + 'placeholder': ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '监控文件大小:单位GB,0为不开启,低于监控文件大小的文件不会被监控转移。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "copy_files": True, + "mode": "compatibility", + "monitor_dirs": "", + "exclude_keywords": "", + "cron": "", + "size": 0, + "rmt_mediaext": ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None diff --git a/plugins/homepage/__init__.py b/plugins/homepage/__init__.py new file mode 100644 index 0000000..8343abe --- /dev/null +++ b/plugins/homepage/__init__.py @@ -0,0 +1,648 @@ +from pathlib import Path + +from app.chain.dashboard import DashboardChain +from app.core.config import settings +from app.db.subscribe_oper import SubscribeOper +from app.helper.directory import DirectoryHelper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.schemas import NotificationType +from app import schemas +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + + +class HomePage(_PluginBase): + # 插件名称 + plugin_name = "HomePage" + # 插件描述 + plugin_desc = "HomePage自定义API。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/homepage.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "homepage_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 1 + + # 任务执行间隔 + _enabled = False + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + + def get_state(self) -> bool: + return self._enabled + + def statistic(self, apikey: str) -> Any: + """ + 订阅、剩余空间等信息 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + + # 媒体统计 + movie_count = 0 + tv_count = 0 + episode_count = 0 + user_count = 0 + media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic() + if media_statistics: + # 汇总各媒体库统计信息 + for media_statistic in media_statistics: + movie_count += media_statistic.movie_count + tv_count += media_statistic.tv_count + episode_count += media_statistic.episode_count + user_count += media_statistic.user_count + + # 磁盘统计 + library_dirs = DirectoryHelper().get_library_dirs() + total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path]) + + # 订阅统计 + movie_subscribes = 0 + tv_subscribes = 0 + subscribes = SubscribeOper().list() + for subscribe in subscribes: + if str(subscribe.type) == '电影': + movie_subscribes += 1 + else: + tv_subscribes += 1 + return { + 'movie_count': movie_count, + 'tv_count': tv_count, + 'episode_count': episode_count, + 'user_count': user_count, + 'total_storage': StringUtils.str_filesize(total_storage), + 'free_storage': StringUtils.str_filesize(free_storage), + 'used_storage': StringUtils.str_filesize(total_storage - free_storage), + 'movie_subscribes': movie_subscribes, + 'tv_subscribes': tv_subscribes, + } + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + return [{ + "path": "/statistic", + "endpoint": self.statistic, + "methods": ["GET"], + "summary": "数据统计", + "description": "订阅数量等统计数量", + }] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '配置教程请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/HomePage.md', + 'target': '_blank' + }, + 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/HomePage.md' + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '如安装完启用插件后,HomePage提示404,重启MoviePilot即可。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + } + + def get_page(self) -> List[dict]: + dict = self.statistic(settings.API_TOKEN) + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '电影订阅' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('movie_subscribes') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '电视剧订阅' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('tv_subscribes') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总空间' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('total_storage') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '剩余空间' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('free_storage') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '电影数量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('movie_count') + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '电视剧数量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('tv_count') + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '电影剧集数量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('episode_count') + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '用户数量' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': dict.get('user_count') + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }] + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/linktosrc/__init__.py b/plugins/linktosrc/__init__.py new file mode 100644 index 0000000..d3f96e6 --- /dev/null +++ b/plugins/linktosrc/__init__.py @@ -0,0 +1,204 @@ +import sqlite3 +from pathlib import Path +from typing import List, Tuple, Dict, Any + +from app.core.config import Settings +from app.log import logger +from app.plugins import _PluginBase + + +class LinkToSrc(_PluginBase): + # 插件名称 + plugin_name = "源文件恢复" + # 插件描述 + plugin_desc = "根据MoviePilot的转移记录中的硬链文件恢复源文件" + # 插件图标 + plugin_icon = "Time_machine_A.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "linktosrc_" + # 加载顺序 + plugin_order = 32 + # 可使用的用户级别 + auth_level = 1 + + _onlyonce: bool = False + _link_dirs: str = None + + def init_plugin(self, config: dict = None): + if config: + self._onlyonce = config.get("onlyonce") + self._link_dirs = config.get("link_dirs") + + if self._onlyonce: + # 执行替换 + self._task() + self._onlyonce = False + self.__update_config() + + def _task(self): + db_path = Settings().CONFIG_PATH / 'user.db' + try: + gradedb = sqlite3.connect(db_path) + except Exception as e: + logger.error(f"无法打开数据库文件 {db_path},请检查路径是否正确:{str(e)}") + return + + transfer_history = [] + # 创建游标cursor来执行executeSQL语句 + cursor = gradedb.cursor() + if self._link_dirs: + link_dirs = self._link_dirs.split("\n") + for link_dir in link_dirs: + sql = f''' + SELECT + src, + dest + FROM + transferhistory + WHERE + src IS NOT NULL and dest IS NOT NULL and dest like '{link_dir}%'; + ''' + cursor.execute(sql) + transfer_history += cursor.fetchall() + else: + sql = ''' + SELECT + src, + dest + FROM + transferhistory + WHERE + src IS NOT NULL and dest IS NOT NULL; + ''' + cursor.execute(sql) + transfer_history = cursor.fetchall() + logger.info(f"查询到历史记录{len(transfer_history)}条") + cursor.close() + + if not transfer_history: + logger.error("未获取到历史记录,停止处理") + return + + for history in transfer_history: + src = history[0] + dest = history[1] + # 判断源文件是否存在 + if Path(src).exists(): + logger.warn(f"源文件{src}已存在,跳过处理") + continue + # 源文件不存在,目标文件也不存在,跳过 + if not Path(dest).exists(): + logger.warn(f"源文件{src}不存在且硬链文件{dest}不存在,跳过处理") + continue + # 创建源文件目录,防止目录不存在无法执行 + Path(src).parent.mkdir(parents=True, exist_ok=True) + # 目标文件硬链回源文件 + Path(src).hardlink_to(dest) + logger.info(f"硬链文件{dest}重新链接回源文件{src}") + + logger.info("全部处理完成") + + def __update_config(self): + self.update_config({ + "onlyonce": self._onlyonce, + "link_dirs": self._link_dirs + }) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'link_dirs', + 'label': '需要恢复的硬链接目录', + 'rows': 5, + 'placeholder': '硬链接目录 (一行一个)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '根据转移记录中的硬链接恢复源文件', + 'style': 'white-space: pre-line;' + } + } + ] + } + ] + } + ] + } + ], { + "onlyonce": False, + "link_dirs": "" + } + + def get_page(self) -> List[dict]: + pass + + def get_state(self) -> bool: + return self._onlyonce + + def stop_service(self): + pass diff --git a/plugins/pluginautoupdate/__init__.py b/plugins/pluginautoupdate/__init__.py new file mode 100644 index 0000000..403b198 --- /dev/null +++ b/plugins/pluginautoupdate/__init__.py @@ -0,0 +1,574 @@ +from datetime import datetime, timedelta + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from fastapi import APIRouter + +from app.core.config import settings +from app.core.plugin import PluginManager +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.plugin import PluginHelper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from app.schemas.types import SystemConfigKey +from app.schemas import NotificationType +from app.scheduler import Scheduler +from app.schemas.types import EventType +from app.core.event import eventmanager, Event +from app.utils.string import StringUtils + +router = APIRouter() + + +class PluginAutoUpdate(_PluginBase): + # 插件名称 + plugin_name = "插件更新管理" + # 插件描述 + plugin_desc = "监测已安装插件,推送更新提醒,可配置自动更新。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/pluginupdate.png" + # 插件版本 + plugin_version = "1.9" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "pluginautoupdate_" + # 加载顺序 + plugin_order = 97 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + # 任务执行间隔 + _cron = None + _onlyonce = False + _update = False + _notify = False + _msgtype = None + _update_ids = [] + _exclude_ids = [] + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + _plugin_version = {} + + def init_plugin(self, config: dict = None): + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._cron = config.get("cron") + self._onlyonce = config.get("onlyonce") + self._update = config.get("update") + self._notify = config.get("notify") + self._msgtype = config.get("msgtype") + self._update_ids = config.get("update_ids") + self._exclude_ids = config.get("exclude_ids") + + if self._enabled: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._cron: + try: + self._scheduler.add_job(func=self.plugin_update, + trigger=CronTrigger.from_crontab(self._cron), + name="插件自动更新") + except Exception as err: + logger.error(f"定时任务配置错误:{str(err)}") + + if self._onlyonce: + logger.info(f"插件自动更新服务启动,立即运行一次") + # 关闭一次性开关 + self._onlyonce = False + self.update_config({ + "onlyonce": self._onlyonce, + "cron": self._cron, + "enabled": self._enabled, + "update": self._update, + "notify": self._notify, + "msgtype": self._msgtype, + "update_ids": self._update_ids, + "exclude_ids": self._exclude_ids, + }) + + self._scheduler.add_job(func=self.plugin_update, trigger='date', + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=1), + name="插件自动更新") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + @eventmanager.register(EventType.PluginAction) + def plugin_update(self, event: Event = None): + """ + 插件自动更新 + """ + if not self._enabled: + logger.error("插件未开启") + return + + update_forced: bool = False + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "plugin_update": + return + logger.info("收到命令,开始插件更新 ...") + update_forced = True + self.post_message(channel=event.event_data.get("channel"), + title="开始插件更新 ...", + userid=event.event_data.get("user")) + + logger.info("插件更新任务开始") + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + + # 在线插件 + online_plugins = PluginManager().get_online_plugins() + if not online_plugins: + logger.error("未获取到在线插件,停止运行") + return + + # 使用字典来存储每个插件的最大版本号 + max_versions = {} + for plugin in online_plugins: + if plugin.id not in max_versions or plugin.plugin_version > max_versions[plugin.id]: + max_versions[plugin.id] = plugin.plugin_version + # 根据最大版本号来筛选数据 + online_plugins = [plugin for plugin in online_plugins if + plugin.plugin_version == max_versions[plugin.id]] + + # 已安装插件版本 + self.__get_install_plugin_version() + + # 系统运行的服务 + schedulers = Scheduler().list() + running_scheduler = [] + for scheduler in schedulers: + if scheduler.status == "正在运行": + running_scheduler.append(scheduler.id) + + title = None + # 支持更新的插件自动更新 + for plugin in online_plugins: + # 只处理已安装的插件 + if str(plugin.id) in install_plugins: + # 有更新 或者 本地未安装的 + if plugin.has_update or not plugin.installed: + # 已安装插件版本 + install_plugin_version = self._plugin_version.get(str(plugin.id)) + version_text = f"更新版本:v{install_plugin_version} -> v{plugin.plugin_version}" + + # 自动更新 + if self._update or update_forced: + # 判断是否是排除插件 + if self._exclude_ids and str(plugin.id) in self._exclude_ids: + logger.info(f"插件 {plugin.plugin_name} 已被排除自动更新,跳过") + continue + # 判断是否是已选择插件 + if self._update_ids and str(plugin.id) not in self._update_ids: + logger.info(f"插件 {plugin.plugin_name} 不在自动更新列表中,跳过") + continue + # 判断当前要升级的插件是否正在运行,正则运行则暂不更新 + if plugin.id in running_scheduler: + msg = f"插件 {plugin.plugin_name} 正在运行,跳过自动升级,最新版本 v{plugin.plugin_version}" + logger.info(msg) + title = msg + continue + else: + # 下载安装 + state, msg = PluginHelper().install(pid=plugin.id, + repo_url=plugin.repo_url) + # 安装失败 + if not state: + title = f"插件 {plugin.plugin_name} 更新失败" + logger.error(f"{title} {version_text}") + else: + title = f"插件 {plugin.plugin_name} 更新成功" + logger.info(f"{title} {version_text}") + + # 加载插件到内存 + PluginManager().reload_plugin(plugin.id) + # 注册插件服务 + Scheduler().update_plugin_job(plugin.id) + # 注册插件API + self.register_plugin_api(plugin.id) + else: + title = f"插件 {plugin.plugin_name} 有更新啦" + logger.info(f"{title} {version_text}") + + # 发送通知 + if self._notify and self._msgtype: + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + + plugin_icon = plugin.plugin_icon + if not str(plugin_icon).startswith("http"): + plugin_icon = f"https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/icons/{plugin_icon}" + if plugin.history: + for verison in plugin.history.keys(): + if str(verison).replace("v", "") == str(plugin.plugin_version).replace("v", ""): + version_text += f"\n更新记录:{plugin.history[verison]}" + self.post_message(title=title, + mtype=mtype, + text=version_text, + image=plugin_icon) + + # 重载插件管理器 + if not title: + logger.info("所有插件已是最新版本") + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "plugin_update": + return + self.post_message(channel=event.event_data.get("channel"), + title="所有插件已是最新版本", + userid=event.event_data.get("user")) + + else: + if '正在运行,跳过自动升级' in title: + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "plugin_update": + return + self.post_message(channel=event.event_data.get("channel"), + title=title, + userid=event.event_data.get("user")) + + def __get_install_plugin_version(self): + """ + 获取已安装插件版本 + """ + # 本地插件 + local_plugins = PluginManager().get_local_plugins() + for plugin in local_plugins: + self._plugin_version[plugin.id] = plugin.plugin_version + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [{ + "cmd": "/plugin_update", + "event": EventType.PluginAction, + "desc": "插件更新", + "category": "", + "data": { + "action": "plugin_update" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + @staticmethod + def register_plugin_api(plugin_id: str = None): + """ + 注册插件API(先删除后新增) + """ + apis: List[Dict[str, Any]] = [] + for api in PluginManager().get_plugin_apis(): + if plugin_id in api.get("path"): + apis.append(api) + + for api in apis: + for r in router.routes: + if r.path == api.get("path"): + router.routes.remove(r) + break + router.add_api_route(**api) + + @staticmethod + def get_local_plugins(): + """ + 获取本地插件 + """ + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + + local_plugins = {} + # 线上插件列表 + markets = settings.PLUGIN_MARKET.split(",") + for market in markets: + online_plugins = PluginHelper().get_plugins(market) or {} + for pid, plugin in online_plugins.items(): + if pid in install_plugins: + local_plugin = local_plugins.get(pid) + if local_plugin: + if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + else: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + + return local_plugins + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + + # 已安装插件 + local_plugins = self.get_local_plugins() + # 编历 local_plugins,生成插件类型选项 + pluginOptions = [] + + for plugin_id in list(local_plugins.keys()): + local_plugin = local_plugins.get(plugin_id) + pluginOptions.append({ + "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}", + "value": local_plugin.get("id") + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'update', + 'label': '自动更新', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '监测周期', + 'placeholder': '5位cron表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'update_ids', + 'label': '更新插件', + 'items': pluginOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'exclude_ids', + 'label': '排除插件', + 'items': pluginOptions + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '已安装的插件自动更新最新版本。' + '如未开启自动更新则发送更新通知。' + '如更新插件正在运行,则本次跳过更新。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '所有已安装插件均会检查更新,发送通知。' + '更新插件/排除插件仅针对于自动更新场景。' + '如未选择更新插件,则默认为自动更新所有。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "update": False, + "notify": False, + "cron": "", + "msgtype": "", + "update_ids": [], + "exclude_ids": [], + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + pass + # logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/pluginreinstall/__init__.py b/plugins/pluginreinstall/__init__.py new file mode 100644 index 0000000..a6103d8 --- /dev/null +++ b/plugins/pluginreinstall/__init__.py @@ -0,0 +1,330 @@ +import re + +from fastapi import APIRouter + +from app.core.config import settings +from app.core.plugin import PluginManager +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.plugin import PluginHelper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.schemas.types import SystemConfigKey +from app.utils.string import StringUtils +from app.scheduler import Scheduler + +router = APIRouter() + + +class PluginReInstall(_PluginBase): + # 插件名称 + plugin_name = "插件强制重装" + # 插件描述 + plugin_desc = "卸载当前插件,强制重装。" + # 插件图标 + plugin_icon = "refresh.png" + # 插件版本 + plugin_version = "1.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "pluginreinstall_" + # 加载顺序 + plugin_order = 98 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _reload = False + _plugin_ids = [] + _plugin_url = [] + _base_url = "https://raw.githubusercontent.com/%s/%s/main/" + + def init_plugin(self, config: dict = None): + if config: + self._reload = config.get("reload") + self._plugin_ids = config.get("plugin_ids") or [] + if not self._plugin_ids: + return + self._plugin_url = config.get("plugin_url") + + # 仅重载插件 + if self._reload: + for plugin_id in self._plugin_ids: + self.__reload_plugin(plugin_id) + logger.info(f"插件 {plugin_id} 热重载成功") + self.__update_conifg() + else: + # 校验插件仓库格式 + plugin_url = None + if self._plugin_url: + pattern = "https://github.com/(.*?)/(.*?)/" + matches = re.findall(pattern, str(self._plugin_url)) + if not matches: + logger.warn(f"指定插件仓库地址 {self._plugin_url} 错误,将使用插件默认地址重装") + self._plugin_url = "" + + user, repo = PluginHelper().get_repo_info(self._plugin_url) + plugin_url = self._base_url % (user, repo) + + self.__update_conifg() + + # 本地插件 + local_plugins = self.get_local_plugins() + + # 开始重载插件 + for plugin_id in list(local_plugins.keys()): + local_plugin = local_plugins.get(plugin_id) + if plugin_id in self._plugin_ids: + logger.info( + f"开始重载插件 {local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}") + + # 开始安装线上插件 + state, msg = PluginHelper().install(pid=plugin_id, + repo_url=plugin_url or local_plugin.get("repo_url")) + # 安装失败 + if not state: + logger.error( + f"插件 {local_plugin.get('plugin_name')} 重装失败,当前版本 v{local_plugin.get('plugin_version')}") + continue + + logger.info( + f"插件 {local_plugin.get('plugin_name')} 重装成功,当前版本 v{local_plugin.get('plugin_version')}") + + self.__reload_plugin(plugin_id) + + def __update_conifg(self): + self.update_config({ + "reload": self._reload, + "plugin_url": self._plugin_url, + }) + + def __reload_plugin(self, plugin_id): + """ + 重载插件 + """ + # 加载插件到内存 + PluginManager().reload_plugin(plugin_id) + # 注册插件服务 + Scheduler().update_plugin_job(plugin_id) + # 注册插件API + self.register_plugin_api(plugin_id) + + @staticmethod + def register_plugin_api(plugin_id: str = None): + """ + 注册插件API(先删除后新增) + """ + for api in PluginManager().get_plugin_apis(plugin_id): + for r in router.routes: + if r.path == api.get("path"): + router.routes.remove(r) + break + router.add_api_route(**api) + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 已安装插件 + local_plugins = self.get_local_plugins() + # 编历 local_plugins,生成插件类型选项 + pluginOptions = [] + + for plugin_id in list(local_plugins.keys()): + local_plugin = local_plugins.get(plugin_id) + pluginOptions.append({ + "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}", + "value": local_plugin.get("id") + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'reload', + 'label': '仅重载', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'plugin_ids', + 'label': '重装插件', + 'items': pluginOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 8 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'plugin_url', + 'label': '仓库地址', + 'placeholder': 'https://github.com/%s/%s/' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '选择已安装的本地插件,强制安装插件市场最新版本。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '支持指定插件仓库地址(https://github.com/%s/%s/)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '仅重载:不会获取最新代码,而是基于本地代码重新加载插件。' + } + } + ] + } + ] + }, + ] + } + ], { + "reload": False, + "plugin_ids": [], + "plugin_url": "", + } + + @staticmethod + def get_local_plugins(): + """ + 获取本地插件 + """ + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + + local_plugins = {} + # 线上插件列表 + markets = settings.PLUGIN_MARKET.split(",") + for market in markets: + online_plugins = PluginHelper().get_plugins(market) or {} + for pid, plugin in online_plugins.items(): + if pid in install_plugins: + local_plugin = local_plugins.get(pid) + if local_plugin: + if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + else: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + + return local_plugins + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/pluginuninstall/__init__.py b/plugins/pluginuninstall/__init__.py new file mode 100644 index 0000000..689b2ea --- /dev/null +++ b/plugins/pluginuninstall/__init__.py @@ -0,0 +1,184 @@ +import shutil +from pathlib import Path + +from app.core.config import settings +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.plugin import PluginHelper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.schemas.types import SystemConfigKey +from app.utils.string import StringUtils + + +class PluginUnInstall(_PluginBase): + # 插件名称 + plugin_name = "插件彻底卸载" + # 插件描述 + plugin_desc = "删除数据库中已安装插件记录、清理插件文件。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "pluginuninstall_" + # 加载顺序 + plugin_order = 98 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _plugin_ids = [] + + def init_plugin(self, config: dict = None): + if config: + self._plugin_ids = config.get("plugin_ids") or [] + if not self._plugin_ids: + return + + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + + new_install_plugins = [] + for install_plugin in install_plugins: + if install_plugin in self._plugin_ids: + # 删除插件文件 + plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / install_plugin.lower() + if plugin_dir.exists(): + shutil.rmtree(plugin_dir, ignore_errors=True) + logger.info(f"插件 {install_plugin} 已卸载") + else: + new_install_plugins.append(install_plugin) + + # 保存已安装插件 + SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, new_install_plugins) + + self.update_config({ + "plugin_ids": "" + }) + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 已安装插件 + local_plugins = self.get_local_plugins() + # 编历 local_plugins,生成插件类型选项 + pluginOptions = [] + + for plugin_id in list(local_plugins.keys()): + local_plugin = local_plugins.get(plugin_id) + pluginOptions.append({ + "title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}", + "value": local_plugin.get("id") + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'plugin_ids', + 'label': '卸载插件', + 'items': pluginOptions + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '删除数据库中已安装插件记录、清理插件文件。' + } + } + ] + } + ] + }, + ] + } + ], { + "plugin_ids": [] + } + + @staticmethod + def get_local_plugins(): + """ + 获取本地插件 + """ + # 已安装插件 + install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + + local_plugins = {} + # 线上插件列表 + markets = settings.PLUGIN_MARKET.split(",") + for market in markets: + online_plugins = PluginHelper().get_plugins(market) or {} + for pid, plugin in online_plugins.items(): + if pid in install_plugins: + local_plugin = local_plugins.get(pid) + if local_plugin: + if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + else: + local_plugins[pid] = { + "id": pid, + "plugin_name": plugin.get("name"), + "repo_url": market, + "plugin_version": plugin.get("version") + } + + return local_plugins + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/popularsubscribe/__init__.py b/plugins/popularsubscribe/__init__.py new file mode 100644 index 0000000..58ba1b9 --- /dev/null +++ b/plugins/popularsubscribe/__init__.py @@ -0,0 +1,953 @@ +from datetime import datetime, timedelta + +import pytz +import cn2an + +from app import schemas +from app.chain.download import DownloadChain +from app.chain.subscribe import SubscribeChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.helper.subscribe import SubscribeHelper +from app.schemas import MediaType +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.modules.themoviedb.tmdbapi import TmdbApi + + +class PopularSubscribe(_PluginBase): + # 插件名称 + plugin_name = "热门媒体订阅" + # 插件描述 + plugin_desc = "自定添加热门电影、电视剧、动漫到订阅。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/popular.png" + # 插件版本 + plugin_version = "1.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "popularsubscribe_" + # 加载顺序 + plugin_order = 25 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _movie_enabled: bool = False + _tv_enabled: bool = False + _anime_enabled: bool = False + # 一页多少条数据 + _movie_page_cnt: int = 30 + _tv_page_cnt: int = 30 + _anime_page_cnt: int = 30 + # 流行度最低多少 + _movie_popular_cnt: int = 0 + _tv_popular_cnt: int = 0 + _anime_popular_cnt: int = 0 + _movie_cron: str = "" + _tv_cron: str = "" + _anime_cron: str = "" + _onlyonce: bool = False + _clear = False + _clear_already_handle = False + _username = None + + downloadchain = None + subscribechain = None + tmdb = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.downloadchain = DownloadChain() + self.subscribechain = SubscribeChain() + self.tmdb = TmdbApi() + # 停止现有任务 + self.stop_service() + + if config: + self._movie_enabled = config.get("movie_enabled") + self._tv_enabled = config.get("tv_enabled") + self._anime_enabled = config.get("anime_enabled") + self._movie_cron = config.get("movie_cron") + self._tv_cron = config.get("tv_cron") + self._anime_cron = config.get("anime_cron") + self._movie_page_cnt = config.get("movie_page_cnt") + self._tv_page_cnt = config.get("tv_page_cnt") + self._anime_page_cnt = config.get("anime_page_cnt") + self._movie_popular_cnt = config.get("movie_popular_cnt") + self._tv_popular_cnt = config.get("tv_popular_cnt") + self._anime_popular_cnt = config.get("anime_popular_cnt") + self._clear = config.get("clear") + self._clear_already_handle = config.get("clear_already_handle") + self._username = config.get("username") or '热门订阅' + _onlyonce2 = config.get("onlyonce") + + # 清理插件订阅历史 + if self._clear: + self.del_data(key="history") + + self._clear = False + self.__update_config() + logger.info("订阅历史清理完成") + + # 清理已处理历史 + if self._clear_already_handle: + self.del_data(key="already_handle") + + self._clear_already_handle = False + self.__update_config() + logger.info("已处理历史清理完成") + + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._movie_enabled and (self._movie_cron or _onlyonce2): + if self._movie_cron: + try: + self._scheduler.add_job(func=self.__popular_subscribe, + trigger=CronTrigger.from_crontab(self._movie_cron), + name="电影热门订阅", + args=['电影', self._movie_page_cnt, self._movie_popular_cnt]) + except Exception as err: + logger.error(f"电影热门订阅定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"电影热门订阅执行周期配置错误:{err}") + + if _onlyonce2: + logger.info(f"电影热门订阅服务启动,立即运行一次") + self._scheduler.add_job(self.__popular_subscribe, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="电影热门订阅", + args=['电影', self._movie_page_cnt, self._movie_popular_cnt]) + self._onlyonce = False + self.__update_config() + + if self._tv_enabled and (self._tv_cron or _onlyonce2): + if self._tv_cron: + try: + self._scheduler.add_job(func=self.__popular_subscribe, + trigger=CronTrigger.from_crontab(self._tv_cron), + name="电视剧热门订阅", + args=['电视剧', self._tv_page_cnt, self._tv_popular_cnt]) + except Exception as err: + logger.error(f"电视剧热门订阅定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"电视剧热门订阅执行周期配置错误:{err}") + + if _onlyonce2: + logger.info(f"电视剧热门订阅服务启动,立即运行一次") + self._scheduler.add_job(self.__popular_subscribe, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="电视剧热门订阅", + args=['电视剧', self._tv_page_cnt, self._tv_popular_cnt]) + self._onlyonce = False + self.__update_config() + + if self._anime_enabled and (self._anime_cron or _onlyonce2): + if self._anime_cron: + try: + self._scheduler.add_job(func=self.__popular_subscribe, + trigger=CronTrigger.from_crontab(self._anime_cron), + name="动漫热门订阅", + args=['动漫', self._anime_page_cnt, self._anime_popular_cnt]) + except Exception as err: + logger.error(f"动漫热门订阅定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"动漫热门订阅执行周期配置错误:{err}") + + if _onlyonce2: + logger.info(f"动漫热门订阅服务启动,立即运行一次") + self._scheduler.add_job(self.__popular_subscribe, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="动漫热门订阅", + args=['动漫', self._anime_page_cnt, self._anime_popular_cnt]) + self._onlyonce = False + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + self.update_config({ + "movie_enabled": self._movie_enabled, + "tv_enabled": self._tv_enabled, + "anime_enabled": self._anime_enabled, + "movie_cron": self._movie_cron, + "tv_cron": self._tv_cron, + "anime_cron": self._anime_cron, + "movie_page_cnt": self._movie_page_cnt, + "tv_page_cnt": self._tv_page_cnt, + "anime_page_cnt": self._anime_page_cnt, + "movie_popular_cnt": self._movie_popular_cnt, + "tv_popular_cnt": self._tv_popular_cnt, + "anime_popular_cnt": self._anime_popular_cnt, + "clear": self._clear, + "clear_already_handle": self._clear_already_handle, + "onlyonce": self._onlyonce, + "username": self._username + }) + + def __popular_subscribe(self, stype, page_cnt, popular_cnt): + """ + 热门订阅 + """ + true_type = stype + true_cnt = page_cnt + if str(stype) == '动漫': + stype = "电视剧" + # 动漫|电视剧 公用一组数据,取所需数据的20倍应该ok吧 + page_cnt = int(page_cnt) * 20 + + subscribes = SubscribeHelper().get_statistic(stype=stype, page=1, count=page_cnt) + if not subscribes: + logger.error(f"没有获取到{true_type}热门订阅") + return + + history: List[dict] = self.get_data('history') or [] + already_handle: List[dict] = self.get_data('already_handle') or [] + + # 遍历热门订阅检查流行度是否达到要求 + tv_anime_cnt = 0 + for sub in subscribes: + if popular_cnt and sub.get("count") and int(popular_cnt) > int(sub.get("count")): + logger.info( + f"{sub.get('name')} 订阅人数:{sub.get('count')} 小于 设定人数:{popular_cnt},跳过") + continue + + media = MediaInfo() + media.tmdb_id = sub.get("tmdbid") + media.type = MediaType(sub.get("type")) + media.title = sub.get("name") + media.year = sub.get("year") + media.douban_id = sub.get("doubanid") + media.bangumi_id = sub.get("bangumiid") + media.tvdb_id = sub.get("tvdbid") + media.imdb_id = sub.get("imdbid") + media.season = sub.get("season") + media.poster_path = sub.get("poster") + + # 元数据 + meta = MetaInfo(media.title) + + # 电视剧特殊处理:动漫|电视剧 + if str(stype) == "电视剧": + # 动漫|电视剧所需请求数量以达到 + if int(tv_anime_cnt) >= int(true_cnt): + break + + # 根据tmdbid获取媒体信息 + tmdb_info = self.tmdb.get_info(mtype=media.type, tmdbid=media.tmdb_id) + if not tmdb_info: + logger.warn(f'未识别到媒体信息,标题:{media.title},tmdbid:{media.tmdb_id}') + continue + + # 获取媒体类型 + genre_ids = tmdb_info.get("genre_ids") or [] + if genre_ids: + # 如果当前是动漫订阅,则判断是否在动漫分类中,如果不在则跳过 + if str(true_type) == '动漫' and not set(genre_ids).intersection(set(settings.ANIME_GENREIDS)): + logger.debug(f'{media.title_year} 不在动漫分类中,跳过') + continue + # 如果当前是电视剧订阅,则判断是否在动漫分类中,如果在则跳过 + if str(true_type) == '电视剧' and set(genre_ids).intersection(set(settings.ANIME_GENREIDS)): + logger.debug(f'{media.title_year} 在动漫分类中,跳过') + continue + + # 电视剧|动漫分类都通过,则计数 + tv_anime_cnt += 1 + + if media.title_year in already_handle: + logger.info(f"{media.type.value} {media.title_year} 已被处理,跳过") + continue + already_handle.append(media.title_year) + + title = media.title_year + season_str = None + if media.season and int(media.season) > 1: + # 小写数据转大写 + season_str = f"第{cn2an.an2cn(media.season, 'low')}季" + title = f"{media.title_year} {season_str}" + logger.info(f"{title} 订阅人数:{sub.get('count')} 满足 设定人数:{popular_cnt}") + + # 查询缺失的媒体信息 + exist_flag, _ = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=media) + if exist_flag: + logger.info(f'{media.title_year} 媒体库中已存在') + continue + + # 判断用户是否已经添加订阅 + if self.subscribechain.exists(mediainfo=media): + logger.info(f'{media.title_year} 订阅已存在') + continue + + # 添加订阅 + self.subscribechain.add(title=media.title, + year=media.year, + mtype=media.type, + tmdbid=media.tmdb_id, + season=media.season, + doubanid=media.douban_id, + exist_ok=True, + username=self._username) + logger.info(f'{media.title_year} 订阅人数:{sub.get("count")} 添加订阅') + + # 存储历史记录 + history.append({ + "title": media.title, + "type": media.type.value, + "year": media.year, + "season": season_str, + "poster": media.get_poster_image(), + "overview": media.overview, + "tmdbid": media.tmdb_id, + "doubanid": media.douban_id, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "unique": f"{media.title}:{media.tmdb_id}:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')})" + }) + + # 保存历史记录 + self.save_data('history', history) + self.save_data('already_handle', already_handle) + logger.info(f"{true_type}热门订阅检查完成") + + def delete_history(self, key: str, apikey: str): + """ + 删除同步历史记录 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + # 历史记录 + historys = self.get_data('history') + if not historys: + return schemas.Response(success=False, message="未找到历史记录") + # 删除指定记录 + historys = [h for h in historys if h.get("unique") != key] + self.save_data('history', historys) + return schemas.Response(success=True, message="删除成功") + + def get_state(self) -> bool: + return self._movie_enabled or self._tv_enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/delete_history", + "endpoint": self.delete_history, + "methods": ["GET"], + "summary": "删除订阅历史记录" + } + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'movie_enabled', + 'label': '电影热门订阅', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'movie_cron', + 'label': '电影订阅周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'movie_page_cnt', + 'label': '电影获取条数', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'movie_popular_cnt', + 'label': '电影订阅人次', + 'placeholder': '0' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'tv_enabled', + 'label': '电视剧热门订阅', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'tv_cron', + 'label': '电视剧订阅周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'tv_page_cnt', + 'label': '电视剧获取条数', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'tv_popular_cnt', + 'label': '电视剧订阅人次', + 'placeholder': '0' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'anime_enabled', + 'label': '动漫热门订阅', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'anime_cron', + 'label': '动漫订阅周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'anime_page_cnt', + 'label': '动漫获取条数', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'anime_popular_cnt', + 'label': '动漫订阅人次', + 'placeholder': '0' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '获取指定条数的热门媒体,自定义最低订阅人数要求进行订阅。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '立即运行一次:立即运行一次已开启的电影/电视剧/动漫订阅。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清理订阅记录', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_already_handle', + 'label': '清理已处理记录', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'username', + 'label': '订阅用户', + 'placeholder': '默认为`热门订阅`' + } + } + ] + }, + ] + } + ] + } + ], { + "movie_enabled": False, + "tv_enabled": False, + "anime_enabled": False, + "movie_cron": "5 1 * * *", + "tv_cron": "5 1 * * *", + "anime_cron": "5 1 * * *", + "movie_page_cnt": "", + "tv_page_cnt": "", + "anime_page_cnt": "", + "movie_popular_cnt": "", + "tv_popular_cnt": "", + "anime_popular_cnt": "", + "onlyonce": False, + "clear": False, + "clear_already_handle": False, + "username": "热门订阅" + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询历史记录 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + # 数据按时间降序排序 + historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) + # 拼装页面 + contents = [] + for history in historys: + title = history.get("title") + year = history.get("year") + season = history.get("season") + poster = history.get("poster") + mtype = history.get("type") + time_str = history.get("time") + tmdbid = history.get("tmdbid") + doubanid = history.get("doubanid") + unique = history.get("unique") + + if season: + contents.append( + { + 'component': 'VCard', + 'content': [ + { + "component": "VDialogCloseBtn", + "props": { + 'innerClass': 'absolute top-0 right-0', + }, + 'events': { + 'click': { + 'api': 'plugin/PopularSubscribe/delete_history', + 'method': 'get', + 'params': { + 'key': unique, + 'apikey': settings.API_TOKEN + } + } + }, + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': poster, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'VCardSubtitle', + 'props': { + 'class': 'pa-2 font-bold break-words whitespace-break-spaces' + }, + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"https://movie.douban.com/subject/{doubanid}", + 'target': '_blank' + }, + 'text': title + } + ] + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{mtype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'季度:{season}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{time_str}' + } + ] + } + ] + } + ] + } + ) + else: + contents.append( + { + 'component': 'VCard', + 'content': [ + { + "component": "VDialogCloseBtn", + "props": { + 'innerClass': 'absolute top-0 right-0', + }, + 'events': { + 'click': { + 'api': 'plugin/PopularSubscribe/delete_history', + 'method': 'get', + 'params': { + 'key': f"popularsubscribe: {title} (DB:{tmdbid})", + 'apikey': settings.API_TOKEN + } + } + }, + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex justify-space-start flex-nowrap flex-row', + }, + 'content': [ + { + 'component': 'div', + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': poster, + 'height': 120, + 'width': 80, + 'aspect-ratio': '2/3', + 'class': 'object-cover shadow ring-gray-500', + 'cover': True + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'VCardSubtitle', + 'props': { + 'class': 'pa-2 font-bold break-words whitespace-break-spaces' + }, + 'content': [ + { + 'component': 'a', + 'props': { + 'href': f"https://movie.douban.com/subject/{doubanid}", + 'target': '_blank' + }, + 'text': title + } + ] + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'类型:{mtype}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'年份:{year}' + }, + { + 'component': 'VCardText', + 'props': { + 'class': 'pa-0 px-2' + }, + 'text': f'时间:{time_str}' + } + ] + } + ] + } + ] + } + ) + return [ + { + 'component': 'div', + 'props': { + 'class': 'grid gap-3 grid-info-card', + }, + 'content': contents + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/removetorrent/__init__.py b/plugins/removetorrent/__init__.py new file mode 100644 index 0000000..7829507 --- /dev/null +++ b/plugins/removetorrent/__init__.py @@ -0,0 +1,425 @@ +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger + + +class RemoveTorrent(_PluginBase): + # 插件名称 + plugin_name = "删除站点种子" + # 插件描述 + plugin_desc = "删除下载器中某站点种子。" + # 插件图标 + plugin_icon = "delete.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "removetorrent_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _downloader = None + _onlyonce = None + _delete_type = False + _delete_torrent = False + _delete_file = False + _trackers = None + qb = None + tr = None + + def init_plugin(self, config: dict = None): + self.qb = Qbittorrent() + self.tr = Transmission() + + if config: + self._downloader = config.get("downloader") + self._onlyonce = config.get("onlyonce") + self._delete_type = config.get("delete_type") + self._delete_torrent = config.get("delete_torrent") + self._delete_file = config.get("delete_file") + self._trackers = config.get("trackers") + + if self._trackers and self._onlyonce: + self.update_config({ + "downloader": self._downloader, + "delete_type": self._delete_type, + "delete_torrent": self._delete_torrent, + "delete_file": self._delete_file, + "trackers": self._trackers, + "onlyonce": False + }) + + for tracker in str(self._trackers).split("\n"): + logger.info(f"下载器 {self._downloader} 开始处理站点tracker {tracker}") + self.__check_feed(tracker) + logger.info(f"下载器 {self._downloader} 处理站点tracker {tracker} 完成") + + def __check_feed(self, tracker: str): + """ + 检查tracker辅种情况 + """ + downloader_obj = self.__get_downloader(self._downloader) + # 获取下载器中已完成的种子 + torrents = downloader_obj.get_completed_torrents() + if not torrents: + logger.info(f"下载器 {self._downloader} 未获取到已完成种子") + return + logger.info(f"下载器 {self._downloader} 获取到已完成种子 {len(torrents)} 个") + + all_torrents = [] + tracker_torrents = [] + key_torrents = {} + # 遍历种子,以种子名称和种子大小为key,查询辅种数量 + for torrent in torrents: + torrent_size = self.__get_torrent_size(torrent, self._downloader) + torrent_name = self.__get_torrent_name(torrent, self._downloader) + torrent_key = "%s-%s" % (torrent_name, torrent_size) + all_torrents.append(torrent_key) + + torrent_trackers = self.__get_torrent_trackers(torrent, self._downloader) + if str(self._downloader) == "qb": + # 命中tracker的种子 + if str(tracker) in torrent_trackers: + tracker_torrents.append(torrent_key) + key_torrents[torrent_key] = torrent + else: + for torrent_tracker in torrent_trackers: + # 命中tracker的种子 + if str(tracker) in torrent_tracker.get('announce'): + tracker_torrents.append(torrent_key) + key_torrents[torrent_key] = torrent + + if not tracker_torrents: + logger.error(f"下载器 {self._downloader} 未获取到命中tracker {tracker} 的种子") + return + + logger.info(f"下载器 {self._downloader} 获取到命中tracker {tracker} 已完成种子 {len(tracker_torrents)} 个") + + # 查询tracker种子是否有其他辅种 + for tracker_torrent in tracker_torrents: + torrent = key_torrents.get(tracker_torrent) + torrent_name = self.__get_torrent_name(torrent, self._downloader) + torrent_hash = self.__get_torrent_hash(torrent, self._downloader) + + if self._delete_type: + # 有辅种 + if all_torrents.count(tracker_torrent) > 1: + # 删除逻辑 + if self._delete_torrent: + downloader_obj.delete_torrents(delete_file=self._delete_file, + ids=torrent_hash) + logger.info(f"种子 {torrent_name} {torrent_hash} 有其他辅种,已删除") + else: + logger.info(f"种子 {torrent_name} {torrent_hash} 有其他辅种,可删除") + else: + # 无辅种 + logger.warn(f"种子 {torrent_name} {torrent_hash} 在其他站无辅种,如需删除请手动处理") + else: + # 无辅种 + if all_torrents.count(tracker_torrent) == 1: + # 删除逻辑 + if self._delete_torrent: + downloader_obj.delete_torrents(delete_file=self._delete_file, + ids=torrent_hash) + logger.info(f"种子 {torrent_name} {torrent_hash} 无其他辅种,已删除") + else: + logger.info(f"种子 {torrent_name} {torrent_hash} 无其他辅种,可删除") + else: + logger.warn(f"种子 {torrent_name} {torrent_hash} 在其他站有辅种,如需删除请手动处理") + + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qb": + return self.qb + elif dtype == "tr": + return self.tr + else: + return None + + @staticmethod + def __get_torrent_trackers(torrent: Any, dl_type: str): + """ + 获取种子trackers + """ + try: + return torrent.get("tracker") if dl_type == "qb" else torrent.trackers + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_name(torrent: Any, dl_type: str): + """ + 获取种子name + """ + try: + return torrent.get("name") if dl_type == "qb" else torrent.name + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_size(torrent: Any, dl_type: str): + """ + 获取种子大小 + """ + try: + return torrent.get("size") if dl_type == "qb" else torrent.total_size + except Exception as e: + print(str(e)) + return "" + + @staticmethod + def __get_torrent_hash(torrent: Any, dl_type: str): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if dl_type == "qb" else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader', + 'label': '下载器', + 'items': [ + {'title': 'qb', 'value': 'qb'}, + {'title': 'tr', 'value': 'tr'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'delete_type', + 'label': '是否有辅种', + 'items': [ + {'title': '是', 'value': True}, + {'title': '否', 'value': False} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'delete_torrent', + 'label': '删除种子', + 'items': [ + {'title': '是', 'value': True}, + {'title': '否', 'value': False} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'delete_file', + 'label': '删除文件', + 'items': [ + {'title': '是', 'value': True}, + {'title': '否', 'value': False} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'trackers', + 'rows': '3', + 'label': '站点tracker域名', + 'placeholder': '站点tracker域名,一行一个' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '输入要删除辅种的站点tracker域名。' + '保留站点没有辅种的种子,其余在其他站有辅种的种子均删除。' + '(适用于某个站点不想保种了,但是可能有孤种没法直接全部删除的情况)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '场景一:某个站不想保种了,但是有些种子没有辅种,需要保留。' + '是否有辅种=是,删除种子=是,删除文件=否。' + '(保留站点没有辅种的种子,其余在其他站有辅种的种子均删除(保留文件)。)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '场景二:想删除某个站没有辅种的种子。' + '是否有辅种=否,删除种子=是,删除文件=是。' + } + } + ] + } + ] + } + ] + } + ], { + "downloader": "qb", + "delete_type": True, + "delete_torrent": False, + "delete_file": False, + "onlyonce": False, + "trackers": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/schedulereminder/__init__.py b/plugins/schedulereminder/__init__.py new file mode 100644 index 0000000..04a0640 --- /dev/null +++ b/plugins/schedulereminder/__init__.py @@ -0,0 +1,186 @@ +from app.core.config import settings +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.schemas import NotificationType + + +class ScheduleReminder(_PluginBase): + # 插件名称 + plugin_name = "日程提醒" + # 插件描述 + plugin_desc = "自定义提醒事项、提醒时间。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/reminder.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "schedulereminder_" + # 加载顺序 + plugin_order = 32 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _confs = None + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._confs = config.get("confs") + + if self._enabled and self._confs: + # 周期运行 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + confs = self._confs.split("\n") + if not confs: + return + for conf in confs: + if str(conf).count(":") != 1: + logger.warn(f"{conf} 格式错误,跳过处理") + continue + try: + self._scheduler.add_job(func=self.__send_notify, + trigger=CronTrigger.from_crontab(str(conf).split(":")[1]), + name=f"{str(conf).split(':')[0]}提醒", + kwargs={"theme": str(conf).split(":")[0]}) + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __send_notify(self, theme: str): + """ + 同步站点cookie到cookiecloud + """ + self.post_message(mtype=NotificationType.Manual, + title="日程提醒", + text=theme) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'confs', + 'label': '提醒事项', + 'rows': 5, + 'placeholder': '提醒内容:cron' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '提醒事项格式为:提醒内容:提醒时间cron表达式(一行一条)。' + '需开启(手动处理通知)通知类型' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "confs": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/shortplaymonitor/__init__.py b/plugins/shortplaymonitor/__init__.py new file mode 100644 index 0000000..078aba1 --- /dev/null +++ b/plugins/shortplaymonitor/__init__.py @@ -0,0 +1,1058 @@ +import os +import threading +import datetime +from pathlib import Path + +from typing import Any, List, Dict, Tuple, Optional +from xml.dom import minidom +from threading import Lock +from app.chain.tmdb import TmdbChain +from app.core.metainfo import MetaInfoPath +from app.schemas import MediaInfo, TransferInfo +from app.utils.dom import DomUtils +from PIL import Image +import pytz +from app.db.site_oper import SiteOper +from apscheduler.schedulers.background import BackgroundScheduler +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver +from app.utils.common import retry +from requests import RequestException +from app.core.meta.words import WordsMatcher +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings +from app.utils.system import SystemUtils +from app.schemas.types import NotificationType +import re + +import chardet +from lxml import etree + +from app.modules.indexer import TorrentSpider +from app.helper.sites import SitesHelper + +from app.utils.http import RequestUtils + +ffmpeg_lock = threading.Lock() +lock = Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, watching_path: str, file_change: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = watching_path + self.file_change = file_change + + def on_created(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path) + + +class ShortPlayMonitor(_PluginBase): + # 插件名称 + plugin_name = "短剧刮削" + # 插件描述 + plugin_desc = "监控视频短剧创建,刮削。" + # 插件图标 + plugin_icon = "Amule_B.png" + # 插件版本 + plugin_version = "3.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "shortplaymonitor_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _monitor_confs = None + _onlyonce = False + _image = False + _exclude_keywords = "" + _transfer_type = "link" + _observer = [] + _timeline = "00:00:10" + _dirconf = {} + _renameconf = {} + _coverconf = {} + tmdbchain = None + _interval = 10 + _notify = False + _medias = {} + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._renameconf = {} + self._coverconf = {} + self.tmdbchain = TmdbChain() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._image = config.get("image") + self._interval = config.get("interval") + self._notify = config.get("notify") + self._monitor_confs = config.get("monitor_confs") + self._exclude_keywords = config.get("exclude_keywords") or "" + self._transfer_type = config.get("transfer_type") or "link" + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + if self._notify: + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 监控方式#监控目录#目的目录#是否重命名#封面比例 + if not monitor_conf: + continue + if str(monitor_conf).count("#") != 4: + logger.error(f"{monitor_conf} 格式错误") + continue + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + rename_conf = str(monitor_conf).split("#")[3] + cover_conf = str(monitor_conf).split("#")[4] + + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + self._renameconf[source_dir] = rename_conf + self._coverconf[source_dir] = cover_conf + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if mode == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{source_dir} 的目录监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{source_dir} 启动目录监控失败:{err_msg}") + self.systemmessage.put(f"{source_dir} 启动目录监控失败:{err_msg}") + + # 运行一次定时服务 + if self._onlyonce: + logger.info("短剧监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3), + name="短剧监控全量执行") + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + if self._image: + self._image = False + self.__update_config() + self.__handle_image() + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步短剧监控目录 ...") + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): + self.__handle_file(is_directory=Path(file_path).is_dir(), + event_path=str(file_path), + source_dir=mon_path) + logger.info("全量同步短剧监控目录完成!") + + def __handle_image(self): + """ + 立即运行一次,裁剪封面 + """ + if not self._dirconf or not self._dirconf.keys(): + logger.error("未正确配置,停止裁剪 ...") + return + + logger.info("开始全量裁剪封面 ...") + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + cover_conf = self._coverconf.get(mon_path) + target_path = self._dirconf.get(mon_path) + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(target_path), ["poster.jpg"]): + try: + if Path(file_path).name != "poster.jpg": + continue + image = Image.open(file_path) + if image.width / image.height != int(str(cover_conf).split(":")[0]) / int( + str(cover_conf).split(":")[1]): + self.__save_poster(input_path=file_path, + poster_path=file_path, + cover_conf=cover_conf) + logger.info(f"封面 {file_path} 已裁剪 比例为 {cover_conf}") + except Exception: + continue + logger.info("全量裁剪封面完成!") + + def event_handler(self, event, source_dir: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param source_dir: 监控目录 + :param event_path: 事件文件路径 + """ + # 回收站及隐藏的文件不处理 + if (event_path.find("/@Recycle") != -1 + or event_path.find("/#recycle") != -1 + or event_path.find("/.") != -1 + or event_path.find("/@eaDir") != -1): + logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 不是媒体文件不处理 + if Path(event_path).suffix not in settings.RMT_MEDIAEXT: + logger.debug(f"{event_path} 不是媒体文件") + return + + # 文件发生变化 + logger.debug(f"变动类型 {event.event_type} 变动路径 {event_path}") + self.__handle_file(is_directory=event.is_directory, + event_path=event_path, + source_dir=source_dir) + + def __handle_file(self, is_directory: bool, event_path: str, source_dir: str): + """ + 同步一个文件 + :event.is_directory + :param event_path: 事件文件路径 + :param source_dir: 监控目录 + """ + try: + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 是否重命名 + rename_conf = self._renameconf.get(source_dir) + # 封面比例 + cover_conf = self._coverconf.get(source_dir) + # 元数据 + file_meta = MetaInfoPath(Path(event_path)) + if not file_meta.name: + logger.error(f"{Path(event_path).name} 无法识别有效信息") + return + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta) + + transfer_flag = False + title = None + # 走tmdb刮削 + if mediainfo: + try: + # 更新媒体图片 + self.chain.obtain_images(mediainfo=mediainfo) + episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, + season=file_meta.begin_season or 1) + mediainfo.category = "" + # 转移 + transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, + path=Path(event_path), + transfer_type=self._transfer_type, + target=Path(dest_dir), + meta=file_meta, + episodes_info=episodes_info) + if not transferinfo: + logger.error("文件转移模块运行失败") + transfer_flag = False + else: + self.chain.scrape_metadata(path=transferinfo.target_path, + mediainfo=mediainfo, + transfer_type=self._transfer_type) + transfer_flag = True + except Exception as e: + print(str(e)) + transfer_flag = False + logger.error(f"{event_path} tmdb刮削失败") + # 广播事件 + # self.eventmanager.send_event(EventType.TransferComplete, { + # 'meta': file_meta, + # 'mediainfo': mediainfo, + # 'transferinfo': transferinfo + # }) + if not transfer_flag: + target_path = event_path.replace(source_dir, dest_dir) + + # 目录重命名 + if str(rename_conf) == "true" or str(rename_conf) == "false": + rename_conf = bool(rename_conf) + target = target_path.replace(dest_dir, "") + parent = Path(Path(target).parents[0]) + last = target.replace(str(parent), "") + if rename_conf: + # 自定义识别次 + title, _ = WordsMatcher().prepare(parent) + target_path = Path(dest_dir).joinpath(title + last) + else: + title = parent + else: + if str(rename_conf) == "smart": + target = target_path.replace(dest_dir, "") + parent = Path(Path(target).parents[0]) + last = target.replace(str(parent), "") + # 取.第一个 + title = Path(parent).name.split(".")[0] + target_path = Path(dest_dir).joinpath(title + last) + else: + logger.error(f"{target_path} 智能重命名失败") + return + + # 文件夹同步创建 + if is_directory: + # 目标文件夹不存在则创建 + if not Path(target_path).exists(): + logger.info(f"创建目标文件夹 {target_path}") + os.makedirs(target_path) + else: + # 媒体重命名 + try: + pattern = r'S\d+E\d+' + matches = re.search(pattern, Path(target_path).name) + if matches: + target_path = Path( + target_path).parent / f"{matches.group()}{Path(Path(target_path).name).suffix}" + else: + print("未找到匹配的季数和集数") + except Exception as e: + print(e) + + # 目标文件夹不存在则创建 + if not Path(target_path).parent.exists(): + logger.info(f"创建目标文件夹 {Path(target_path).parent}") + os.makedirs(Path(target_path).parent) + + # 文件:nfo、图片、视频文件 + if Path(target_path).exists(): + logger.debug(f"目标文件 {target_path} 已存在") + return + + # 硬链接 + retcode = self.__transfer_command(file_item=Path(event_path), + target_file=target_path, + transfer_type=self._transfer_type) + if retcode == 0: + logger.info(f"文件 {event_path} 硬链接完成") + # 生成 tvshow.nfo + if not (target_path.parent / "tvshow.nfo").exists(): + self.__gen_tv_nfo_file(dir_path=target_path.parent, + title=title) + + # 生成缩略图 + if not (target_path.parent / "poster.jpg").exists(): + thumb_path = self.gen_file_thumb(title=title, + rename_conf=rename_conf, + file_path=target_path) + if thumb_path and Path(thumb_path).exists(): + self.__save_poster(input_path=thumb_path, + poster_path=target_path.parent / "poster.jpg", + cover_conf=cover_conf) + if (target_path.parent / "poster.jpg").exists(): + logger.info(f"{target_path.parent / 'poster.jpg'} 缩略图已生成") + thumb_path.unlink() + else: + # 检查是否有缩略图 + thumb_files = SystemUtils.list_files(directory=target_path.parent, + extensions=[".jpg"]) + if thumb_files: + # 生成poster + for thumb in thumb_files: + self.__save_poster(input_path=thumb, + poster_path=target_path.parent / "poster.jpg", + cover_conf=cover_conf) + break + # 删除多余jpg + for thumb in thumb_files: + Path(thumb).unlink() + else: + logger.error(f"文件 {event_path} 硬链接失败,错误码:{retcode}") + if self._notify: + # 发送消息汇总 + media_list = self._medias.get(mediainfo.title_year if mediainfo else title) or {} + if media_list: + media_files = media_list.get("files") or [] + if media_files: + if str(event_path) not in media_files: + media_files.append(str(event_path)) + else: + media_files = [str(event_path)] + media_list = { + "files": media_files, + "time": datetime.datetime.now() + } + else: + media_list = { + "files": [str(event_path)], + "time": datetime.datetime.now() + } + self._medias[mediainfo.title_year if mediainfo else title] = media_list + except Exception as e: + logger.error(f"event_handler_created error: {e}") + print(str(e)) + + def send_msg(self): + """ + 定时检查是否有媒体处理完,发送统一消息 + """ + if self._notify: + if not self._medias or not self._medias.keys(): + return + + # 遍历检查是否已刮削完,发送消息 + for medis_title_year in list(self._medias.keys()): + media_list = self._medias.get(medis_title_year) + logger.info(f"开始处理媒体 {medis_title_year} 消息") + + if not media_list: + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + media_files = media_list.get("files") + if not last_update_time or not media_files: + continue + + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval): + # 发送消息 + self.post_message(mtype=NotificationType.Organize, + title=f"{medis_title_year} 共{len(media_files)}集已入库", + text="类别:短剧") + # 发送完消息,移出key + del self._medias[medis_title_year] + continue + + @staticmethod + def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int: + """ + 使用系统命令处理单个文件 + :param file_item: 文件路径 + :param target_file: 目标文件路径 + :param transfer_type: RmtMode转移方式 + """ + with lock: + + # 转移 + if transfer_type == 'link': + # 硬链接 + retcode, retmsg = SystemUtils.link(file_item, target_file) + elif transfer_type == 'filesoftlink': + # 软链接 + retcode, retmsg = SystemUtils.softlink(file_item, target_file) + elif transfer_type == 'move': + # 移动 + retcode, retmsg = SystemUtils.move(file_item, target_file) + elif transfer_type == 'rclone_move': + # Rclone 移动 + retcode, retmsg = SystemUtils.rclone_move(file_item, target_file) + elif transfer_type == 'rclone_copy': + # Rclone 复制 + retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file) + else: + # 复制 + retcode, retmsg = SystemUtils.copy(file_item, target_file) + + if retcode != 0: + logger.error(retmsg) + + return retcode + + def __save_poster(self, input_path, poster_path, cover_conf): + """ + 截取图片做封面 + """ + try: + image = Image.open(input_path) + + # 需要截取的长宽比(比如 16:9) + if not cover_conf: + target_ratio = 2 / 3 + else: + covers = cover_conf.split(":") + target_ratio = int(covers[0]) / int(covers[1]) + + # 获取原始图片的长宽比 + original_ratio = image.width / image.height + + # 计算截取后的大小 + if original_ratio > target_ratio: + new_height = image.height + new_width = int(new_height * target_ratio) + else: + new_width = image.width + new_height = int(new_width / target_ratio) + + # 计算截取的位置 + left = (image.width - new_width) // 2 + top = (image.height - new_height) // 2 + right = left + new_width + bottom = top + new_height + + # 截取图片 + cropped_image = image.crop((left, top, right, bottom)) + + # 保存截取后的图片 + cropped_image.save(poster_path) + except Exception as e: + print(str(e)) + + def __gen_tv_nfo_file(self, dir_path: Path, title: str): + """ + 生成电视剧的NFO描述文件 + :param dir_path: 电视剧根目录 + """ + # 开始生成XML + logger.info(f"正在生成电视剧NFO文件:{dir_path.name}") + doc = minidom.Document() + root = DomUtils.add_node(doc, doc, "tvshow") + + # 标题 + DomUtils.add_node(doc, root, "title", title) + DomUtils.add_node(doc, root, "originaltitle", title) + DomUtils.add_node(doc, root, "season", "-1") + DomUtils.add_node(doc, root, "episode", "-1") + # 保存 + self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo")) + + def __save_nfo(self, doc, file_path: Path): + """ + 保存NFO + """ + xml_str = doc.toprettyxml(indent=" ", encoding="utf-8") + file_path.write_bytes(xml_str) + logger.info(f"NFO文件已保存:{file_path}") + + def gen_file_thumb_from_site(self, title: str, file_path: Path): + """ + 从agsv或者萝莉站查询封面 + """ + try: + image = None + # 查询索引 + domain = "agsvpt.com" + site = SiteOper().get_by_domain(domain) + index = SitesHelper().get_indexer(domain) + if site: + req_url = f"https://www.agsvpt.com/torrents.php?search_mode=0&search_area=0&page=0¬newword=1&cat=419&search={title}" + image_xpath = "//*[@id='kdescr']/img[1]/@src" + # 查询站点资源 + logger.info(f"开始检索 {site.name} {title}") + image = self.__get_site_torrents(url=req_url, site=site, image_xpath=image_xpath, index=index) + if not image: + domain = "ilolicon.com" + site = SiteOper().get_by_domain(domain) + index = SitesHelper().get_indexer(domain) + if site: + req_url = f"https://share.ilolicon.com/torrents.php?search_mode=0&search_area=0&page=0¬newword=1&cat=402&search={title}" + + image_xpath = "//*[@id='kdescr']/img[1]/@src" + # 查询站点资源 + logger.info(f"开始检索 {site.name} {title}") + image = self.__get_site_torrents(url=req_url, site=site, image_xpath=image_xpath, index=index) + + if not image: + logger.error(f"检索站点 {title} 封面失败") + return None + + # 下载图片保存 + if self.__save_image(url=image, file_path=file_path): + return file_path + return None + except Exception as e: + logger.error(f"检索站点 {title} 封面失败 {str(e)}") + return None + + @retry(RequestException, logger=logger) + def __save_image(self, url: str, file_path: Path): + """ + 下载图片并保存 + """ + try: + logger.info(f"正在下载{file_path.stem}图片:{url} ...") + r = RequestUtils().get_res(url=url, raise_exception=True) + if r: + file_path.write_bytes(r.content) + logger.info(f"图片已保存:{file_path}") + return True + else: + logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性") + return False + except RequestException as err: + raise err + except Exception as err: + logger.error(f"{file_path.stem}图片下载失败:{str(err)}") + return False + + def __get_site_torrents(self, url: str, site, image_xpath, index): + """ + 查询站点资源 + """ + page_source = self.__get_page_source(url=url, site=site) + if not page_source: + logger.error(f"请求站点 {site.name} 失败") + return None + _spider = TorrentSpider(indexer=index, + page=1) + torrents = _spider.parse(page_source) + if not torrents: + logger.error(f"未检索到站点 {site.name} 资源") + return None + + # 获取种子详情页 + torrent_detail_source = self.__get_page_source(url=torrents[0].get("page_url"), site=site) + if not torrent_detail_source: + logger.error(f"请求种子详情页失败 {torrents[0].get('page_url')}") + return None + + html = etree.HTML(torrent_detail_source) + if not html: + logger.error(f"请求种子详情页失败 {torrents[0].get('page_url')}") + return None + + image = html.xpath(image_xpath)[0] + if not image: + logger.error(f"未获取到种子封面图 {torrents[0].get('page_url')}") + return None + + return str(image) + + def __get_page_source(self, url: str, site): + """ + 获取页面资源 + """ + ret = RequestUtils( + cookies=site.cookie, + timeout=30, + ).get_res(url, allow_redirects=True) + if ret is not None: + # 使用chardet检测字符编码 + raw_data = ret.content + if raw_data: + try: + result = chardet.detect(raw_data) + encoding = result['encoding'] + # 解码为字符串 + page_source = raw_data.decode(encoding) + except Exception as e: + # 探测utf-8解码 + if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE): + ret.encoding = "utf-8" + else: + ret.encoding = ret.apparent_encoding + page_source = ret.text + else: + page_source = ret.text + else: + page_source = "" + + return page_source + + def gen_file_thumb(self, title: str, file_path: Path, rename_conf: str): + """ + 处理一个文件 + """ + # 智能重命名时从站点检索 + if str(rename_conf) == "smart": + thumb_path = file_path.with_name(file_path.stem + "-site.jpg") + if thumb_path.exists(): + logger.info(f"缩略图已存在:{thumb_path}") + return + self.gen_file_thumb_from_site(title=title, file_path=thumb_path) + if Path(thumb_path).exists(): + logger.info(f"{file_path} 缩略图已生成:{thumb_path}") + return thumb_path + # 单线程处理 + with ffmpeg_lock: + try: + thumb_path = file_path.with_name(file_path.stem + "-thumb.jpg") + if thumb_path.exists(): + logger.info(f"缩略图已存在:{thumb_path}") + return + self.get_thumb(video_path=str(file_path), + image_path=str(thumb_path), + frames=self._timeline) + if Path(thumb_path).exists(): + logger.info(f"{file_path} 缩略图已生成:{thumb_path}") + return thumb_path + except Exception as err: + logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}") + return None + + @staticmethod + def get_thumb(video_path: str, image_path: str, frames: str = None): + """ + 使用ffmpeg从视频文件中截取缩略图 + """ + if not frames: + frames = "00:00:10" + if not video_path or not image_path: + return False + cmd = 'ffmpeg -y -i "{video_path}" -ss {frames} -frames 1 "{image_path}"'.format( + video_path=video_path, + frames=frames, + image_path=image_path) + result = SystemUtils.execute(cmd) + if result: + return True + return False + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "exclude_keywords": self._exclude_keywords, + "transfer_type": self._transfer_type, + "onlyonce": self._onlyonce, + "interval": self._interval, + "notify": self._notify, + "image": self._image, + "monitor_confs": self._monitor_confs + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'image', + 'label': '封面裁剪', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'transfer_type', + 'label': '转移方式', + 'items': [ + {'title': '移动', 'value': 'move'}, + {'title': '复制', 'value': 'copy'}, + {'title': '硬链接', 'value': 'link'}, + {'title': '软链接', 'value': 'filesoftlink'}, + {'title': 'Rclone复制', 'value': 'rclone_copy'}, + {'title': 'Rclone移动', 'value': 'rclone_move'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控方式#监控目录#目的目录#是否重命名#封面比例' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/ShortPlayMonitor.md' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '默认从tmdb刮削,刮削失败则从pt站刮削。当重命名方式为smart时,如站点管理已配置AGSV、ilolicon,则优先从站点获取短剧封面。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '开启封面裁剪后,会把封面裁剪成配置的比例。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "image": False, + "notify": False, + "interval": 10, + "monitor_confs": "", + "exclude_keywords": "", + "transfer_type": "link" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] diff --git a/plugins/siteunreadmsg/__init__.py b/plugins/siteunreadmsg/__init__.py new file mode 100644 index 0000000..603fc12 --- /dev/null +++ b/plugins/siteunreadmsg/__init__.py @@ -0,0 +1,708 @@ +import re +import time +import warnings +from datetime import datetime, timedelta +from multiprocessing.dummy import Pool as ThreadPool +from threading import Lock +from typing import Optional, Any, List, Dict, Tuple + +import pytz +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from ruamel.yaml import CommentedMap + +from app.core.config import settings +from app.core.event import eventmanager +from app.db.site_oper import SiteOper +from app.helper.browser import PlaywrightHelper +from app.helper.module import ModuleHelper +from app.helper.sites import SitesHelper +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.sitestatistic.siteuserinfo import ISiteUserInfo +from app.schemas.types import EventType, NotificationType +from app.utils.http import RequestUtils + +warnings.filterwarnings("ignore", category=FutureWarning) + +lock = Lock() + + +class SiteUnreadMsg(_PluginBase): + # 插件名称 + plugin_name = "站点未读消息" + # 插件描述 + plugin_desc = "发送站点未读消息。" + # 插件图标 + plugin_icon = "Synomail_A.png" + # 插件版本 + plugin_version = "1.9" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "siteunreadmsg_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites = None + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + _history = [] + _exits_key = [] + _site_schema: List[ISiteUserInfo] = None + + # 配置属性 + _enabled: bool = False + _onlyonce: bool = False + _cron: str = "" + _notify: bool = False + _queue_cnt: int = 5 + _history_days: int = 30 + _unread_sites: list = [] + + def init_plugin(self, config: dict = None): + self.sites = SitesHelper() + self.siteoper = SiteOper() + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._queue_cnt = config.get("queue_cnt") + self._history_days = config.get("history_days") or 30 + self._unread_sites = config.get("unread_sites") or [] + + # 过滤掉已删除的站点 + all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites() + self._unread_sites = [site.get("id") for site in all_sites if + not site.get("public") and site.get("id") in self._unread_sites] + self.__update_config() + + if self._enabled or self._onlyonce: + # 加载模块 + self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo', + filter_func=lambda _, obj: hasattr(obj, 'schema')) + + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + self._site_schema.sort(key=lambda x: x.order) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"站点未读消息服务启动,立即运行一次") + self._scheduler.add_job(self.refresh_all_site_unread_msg, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="站点未读消息") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.refresh_all_site_unread_msg, + trigger=CronTrigger.from_crontab(self._cron), + name="站点未读消息") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 站点的可选项(内置站点 + 自定义站点) + customSites = self.__custom_sites() + + site_options = ([{"title": site.name, "value": site.id} + for site in self.siteoper.list_order_by_pri()] + + [{"title": site.get("name"), "value": site.get("id")} + for site in customSites]) + + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'queue_cnt', + 'label': '队列数量' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'history_days', + 'label': '保留历史天数' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'unread_sites', + 'label': '未读消息站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '依赖于[站点数据统计]插件,解析邮件失败请去[站点数据统计]插件仓库提交issue。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "notify": True, + "cron": "5 1 * * *", + "queue_cnt": 5, + "history_days": 30, + "unread_sites": [] + } + + def get_page(self) -> List[dict]: + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + unread_data = self.get_data("history") + if not unread_data: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + # 数据按时间降序排序 + unread_data = sorted(unread_data, + key=lambda item: item.get('time') or 0, + reverse=True) + + # 站点数据明细 + unread_msgs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': data.get("site") + }, + { + 'component': 'td', + 'text': data.get("head") + }, + { + 'component': 'td', + 'text': data.get("content") + }, + { + 'component': 'td', + 'text': data.get("time") + } + ] + } for data in unread_data + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '站点' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '标题' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '内容' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '时间' + }, + ] + }, + { + 'component': 'tbody', + 'content': unread_msgs + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) + + def __build_class(self, html_text: str) -> Any: + for site_schema in self._site_schema: + try: + if site_schema.match(html_text): + return site_schema + except Exception as e: + logger.error(f"站点匹配失败 {e}") + return None + + def build(self, site_info: CommentedMap) -> Optional[ISiteUserInfo]: + """ + 构建站点信息 + """ + site_cookie = site_info.get("cookie") + if not site_cookie: + return None + site_name = site_info.get("name") + apikey = site_info.get("apikey") + token = site_info.get("token") + url = site_info.get("url") + proxy = site_info.get("proxy") + ua = site_info.get("ua") + # 会话管理 + with requests.Session() as session: + proxies = settings.PROXY if proxy else None + proxy_server = settings.PROXY_SERVER if proxy else None + render = site_info.get("render") + + logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}") + if render: + # 演染模式 + html_text = PlaywrightHelper().get_page_source(url=url, + cookies=site_cookie, + ua=ua, + proxies=proxy_server) + else: + # 普通模式 + res = RequestUtils(cookies=site_cookie, + session=session, + ua=ua, + proxies=proxies + ).get_res(url=url) + if res and res.status_code == 200: + if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE): + res.encoding = "utf-8" + else: + res.encoding = res.apparent_encoding + html_text = res.text + # 第一次登录反爬 + if html_text.find("title") == -1: + i = html_text.find("window.location") + if i == -1: + return None + tmp_url = url + html_text[i:html_text.find(";")] \ + .replace("\"", "") \ + .replace("+", "") \ + .replace(" ", "") \ + .replace("window.location=", "") + res = RequestUtils(cookies=site_cookie, + session=session, + ua=ua, + proxies=proxies + ).get_res(url=tmp_url) + if res and res.status_code == 200: + if "charset=utf-8" in res.text or "charset=UTF-8" in res.text: + res.encoding = "UTF-8" + else: + res.encoding = res.apparent_encoding + html_text = res.text + if not html_text: + return None + elif res is not None: + logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code)) + return None + else: + logger.error("站点 %s 无法访问:%s" % (site_name, url)) + return None + + # 兼容假首页情况,假首页通常没有 0: + logger.debug(f"开始解析站点 {site_name} 未读消息 {site_user_info.message_unread_contents}") + for head, date, content in site_user_info.message_unread_contents: + msg_title = f"【站点 {site_user_info.site_name} 消息】" + msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}" + # 防止同一消息重复发送 + key = site_user_info.site_name + "_" + date + "_" + head + "_" + content + if key not in self._exits_key: + self._exits_key.append(key) + self.post_message(mtype=NotificationType.SiteMessage, title=msg_title, text=msg_text) + self._history.append({ + "site": site_name, + "head": head, + "content": content, + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())), + "date": date, + }) + else: + self.post_message(mtype=NotificationType.SiteMessage, + title=f"站点 {site_user_info.site_name} 收到 " + f"{site_user_info.message_unread} 条新消息,请登陆查看") + + def refresh_all_site_unread_msg(self): + """ + 多线程刷新站点未读消息 + """ + if not self.sites.get_indexers(): + return + + logger.info("开始刷新站点未读消息 ...") + + with lock: + all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites() + # 没有指定站点,默认使用全部站点 + if not self._unread_sites: + refresh_sites = all_sites + else: + refresh_sites = [site for site in all_sites if + site.get("id") in self._unread_sites] + if not refresh_sites: + return + + self._history = self.get_data("history") or [] + # 并发刷新 + with ThreadPool(min(len(refresh_sites), int(self._queue_cnt or 5))) as p: + p.map(self.__refresh_site_data, refresh_sites) + + if self._history: + thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60 + self._history = [record for record in self._history if + datetime.strptime(record["time"], '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago] + + # 保存数据 + self.save_data("history", self._history) + + logger.info("站点未读消息刷新完成") + + def __custom_sites(self) -> List[Any]: + custom_sites = [] + custom_sites_config = self.get_config("CustomSites") + if custom_sites_config and custom_sites_config.get("enabled"): + custom_sites = custom_sites_config.get("sites") + return custom_sites + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "notify": self._notify, + "queue_cnt": self._queue_cnt, + "history_days": self._history_days, + "unread_sites": self._unread_sites, + }) + + @eventmanager.register(EventType.SiteDeleted) + def site_deleted(self, event): + """ + 删除对应站点选中 + """ + site_id = event.event_data.get("site_id") + config = self.get_config() + if config: + unread_sites = config.get("unread_sites") + if unread_sites: + if isinstance(unread_sites, str): + unread_sites = [unread_sites] + + # 删除对应站点 + if site_id: + unread_sites = [site for site in unread_sites if int(site) != int(site_id)] + else: + # 清空 + unread_sites = [] + + # 若无站点,则停止 + if len(unread_sites) == 0: + self._enabled = False + + self._unread_sites = unread_sites + # 保存配置 + self.__update_config() diff --git a/plugins/softlinkredirect/__init__.py b/plugins/softlinkredirect/__init__.py new file mode 100644 index 0000000..f1e982b --- /dev/null +++ b/plugins/softlinkredirect/__init__.py @@ -0,0 +1,212 @@ +import os +from typing import List, Tuple, Dict, Any +from app.log import logger +from app.plugins import _PluginBase + + +class SoftLinkRedirect(_PluginBase): + # 插件名称 + plugin_name = "软连接重定向" + # 插件描述 + plugin_desc = "重定向软连接指向。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlinkredirect.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "softlinkredirect_" + # 加载顺序 + plugin_order = 9 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _onlyonce = False + _soft_path = None + _origin_path = None + _redirect_path = None + + def init_plugin(self, config: dict = None): + # 读取配置 + if config: + self._onlyonce = config.get("onlyonce") + self._soft_path = config.get("soft_path") + self._origin_path = config.get("origin_path") + self._redirect_path = config.get("redirect_path") + + if self._onlyonce and self._soft_path and self._origin_path and self._redirect_path: + logger.info(f"{self._soft_path} 软连接重定向开始 {self._origin_path} - {self._redirect_path}") + self.update_symlink(self._origin_path, self._redirect_path, self._soft_path) + logger.info(f"{self._soft_path} 软连接重定向完成") + self._onlyonce = False + self.update_config({ + "onlyonce": self._onlyonce, + "soft_path": self._soft_path, + "origin_path": self._origin_path, + "redirect_path": self._redirect_path + }) + + @staticmethod + def update_symlink(target_from, target_to, directory): + for root, dirs, files in os.walk(directory): + for name in dirs + files: + file_path = os.path.join(root, name) + if os.path.islink(file_path): + current_target = os.readlink(file_path) + if str(current_target).startswith(target_from): + new_target = current_target.replace(target_from, target_to) + os.remove(file_path) + os.symlink(new_target, file_path) + print(f"Updated symlink: {file_path} -> {new_target}") + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + return [] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'soft_path', + 'label': '软连接路径', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'origin_path', + 'label': '原来源文件路径', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'redirect_path', + 'label': '重定向源文件路径', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '软连接指向由A路径改为B路径' + } + } + ] + } + ] + } + ] + } + ], { + "onlyonce": False, + "soft_path": "", + "origin_path": "", + "redirect_path": "", + } + + def get_page(self) -> List[dict]: + pass + + def get_state(self): + return False + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/sqlexecute/__init__.py b/plugins/sqlexecute/__init__.py new file mode 100644 index 0000000..d544aa5 --- /dev/null +++ b/plugins/sqlexecute/__init__.py @@ -0,0 +1,279 @@ +import sqlite3 + +from app.core.event import eventmanager, Event +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.schemas.types import EventType, MessageChannel + + +class SqlExecute(_PluginBase): + # 插件名称 + plugin_name = "Sql执行器" + # 插件描述 + plugin_desc = "自定义MoviePilot数据库Sql执行。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/sqlite.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "sqlexecute_" + # 加载顺序 + plugin_order = 99 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _onlyonce = None + _sql = None + + def init_plugin(self, config: dict = None): + if config: + self._onlyonce = config.get("onlyonce") + self._sql = config.get("sql") + + if self._onlyonce and self._sql: + # 读取sqlite数据 + try: + gradedb = sqlite3.connect("/config/user.db") + except Exception as e: + logger.error(f"数据库链接失败 {str(e)}") + return + + # 创建游标cursor来执行executeSQL语句 + cursor = gradedb.cursor() + + # 执行SQL语句 + try: + for sql in self._sql.split("\n"): + logger.info(f"开始执行SQL语句 {sql}") + # 执行SQL语句 + cursor.execute(sql) + + rows = cursor.fetchall() + if 'select' in sql.lower(): + # 获取列名 + columns = [desc[0] for desc in cursor.description] + # 将查询结果转换为key-value对的列表 + results = [] + for row in rows: + result = dict(zip(columns, row)) + results.append(result) + result = "\n".join([str(i) for i in results]) + else: + result = "\n".join([str(i) for i in rows]) + + result = str(result).replace("'", "\"") + logger.info(result) + except Exception as e: + logger.error(f"SQL语句执行失败 {str(e)}") + return + finally: + # 关闭游标 + cursor.close() + + self._onlyonce = False + self.update_config({ + "onlyonce": self._onlyonce, + "sql": self._sql + }) + + @eventmanager.register(EventType.PluginAction) + def execute(self, event: Event = None): + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "sql_execute": + return + args = event_data.get("args") + if not args: + return + + logger.info(f"收到命令,开始执行SQL ...{args}") + + # 读取sqlite数据 + try: + gradedb = sqlite3.connect("/config/user.db") + except Exception as e: + logger.error(f"数据库链接失败 {str(e)}") + return + + # 创建游标cursor来执行executeSQL语句 + cursor = gradedb.cursor() + + # 执行SQL语句 + try: + # 执行SQL语句 + cursor.execute(args) + rows = cursor.fetchall() + if 'select' in args.lower(): + # 获取列名 + columns = [desc[0] for desc in cursor.description] + # 将查询结果转换为key-value对的列表 + results = [] + for row in rows: + result = dict(zip(columns, row)) + results.append(result) + result = "\n".join([str(i) for i in results]) + else: + result = "\n".join([str(i) for i in rows]) + + result = str(result).replace("'", "\"") + logger.info(result) + + if event.event_data.get("channel") == MessageChannel.Telegram: + result = f"```plaintext\n{result}\n```" + self.post_message(channel=event.event_data.get("channel"), + title="SQL执行结果", + text=result, + userid=event.event_data.get("user")) + except Exception as e: + logger.error(f"SQL语句执行失败 {str(e)}") + return + finally: + # 关闭游标 + cursor.close() + + def get_state(self) -> bool: + return True + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/sql", + "event": EventType.PluginAction, + "desc": "自定义sql执行", + "category": "", + "data": { + "action": "sql_execute" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '执行sql' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'sql', + 'rows': '2', + 'label': 'sql语句', + 'placeholder': '一行一条' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '执行日志将会输出到控制台,请谨慎操作。' + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '可使用交互命令/sql select *****' + } + ] + } + ] + } + ] + } + ] + } + ], { + "onlyonce": False, + "sql": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/strmconvert/__init__.py b/plugins/strmconvert/__init__.py new file mode 100644 index 0000000..216b4bf --- /dev/null +++ b/plugins/strmconvert/__init__.py @@ -0,0 +1,320 @@ +import re +import urllib.parse +from pathlib import Path + +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger + + +class StrmConvert(_PluginBase): + # 插件名称 + plugin_name = "Strm文件模式转换" + # 插件描述 + plugin_desc = "Strm文件内容转为本地路径或者cd2/alist API路径。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/convert.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "strmconvert_" + # 加载顺序 + plugin_order = 27 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _to_local = False + _to_api = False + _convert_confs = None + _library_path = None + _api_url = None + + def init_plugin(self, config: dict = None): + if config: + self._to_local = config.get("to_local") + self._to_api = config.get("to_api") + self._convert_confs = config.get("convert_confs") + + if self._to_local and self._to_api: + logger.error(f"本地模式和API模式同时只能开启一个") + return + + convert_confs = self._convert_confs.split("\n") + if not convert_confs: + return + + self.update_config({ + "to_local": False, + "to_api": False, + "convert_confs": self._convert_confs + }) + + if self._to_local: + self.__convert_to_local(convert_confs) + + if self._to_api: + self.__convert_to_api(convert_confs) + + def __convert_to_local(self, convert_confs: list): + """ + 转为本地模式 + """ + for convert_conf in convert_confs: + if str(convert_conf).count("#") != 1: + logger.error(f"转换配置 {convert_conf} 格式错误,已跳过处理") + continue + source_path = str(convert_conf).split("#")[0] + library_path = str(convert_conf).split("#")[1] + logger.info(f"{source_path} 开始转为本地模式") + self.__to_local(source_path, library_path) + logger.info(f"{source_path} 转换本地模式已结束") + + def __to_local(self, source_path: str, library_path: str): + files = self.__list_files(Path(source_path), ['.strm']) + for f in files: + logger.debug(f"开始处理文件 {f}") + try: + with open(f, 'r') as file: + content = file.read() + # 获取扩展名 + ext = str(content).split(".")[-1] + library_file = str(f).replace(source_path, library_path) + library_file = Path(library_file).parent.joinpath(Path(library_file).stem + "." + ext) + with open(f, 'w') as file2: + logger.debug(f"开始写入 媒体库路径 {library_file}") + file2.write(str(library_file)) + except Exception as e: + print(e) + + def __convert_to_api(self, convert_confs: list): + """ + 转为api模式 + """ + for convert_conf in convert_confs: + if str(convert_conf).count("#") != 3: + logger.error(f"转换配置 {convert_conf} 格式错误,已跳过处理") + continue + source_path = str(convert_conf).split("#")[0] + library_path = str(convert_conf).split("#")[1] + cloud_type = str(convert_conf).split("#")[2] + cloud_url = str(convert_conf).split("#")[3] + logger.info(f"{source_path} 开始转为API模式") + self.__to_api(source_path, library_path, cloud_type, cloud_url) + logger.info(f"{source_path} 转换本地模式已结束") + + def __to_api(self, source_path: str, library_path: str, cloud_type: str, cloud_url: str): + files = self.__list_files(Path(source_path), ['.strm']) + for f in files: + logger.debug(f"开始处理文件 {f}") + try: + library_file = str(f).replace(source_path, library_path) + # 对盘符之后的所有内容进行url转码 + library_file = urllib.parse.quote(library_file, safe='') + + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + # http://192.168.31.103:19798/static/http/192.168.31.103:19798/False/%2F115%2Femby%2Fanime%2F%20%E4%B8%83%E9%BE%99%E7%8F%A0%20%281986%29%2FSeason%201.%E5%9B%BD%E8%AF%AD%2F%E4%B8%83%E9%BE%99%E7%8F%A0%20-%20S01E002%20-%201080p%20AAC%20h264.mp4 + api_file = f"http://{cloud_url}/static/http/{cloud_url}/False/{library_file}" + else: + api_file = f"http://{cloud_url}/d/{library_file}" + with open(f, 'w') as file2: + logger.debug(f"开始写入 api路径 {api_file}") + file2.write(str(api_file)) + except Exception as e: + print(e) + + @staticmethod + def __list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]: + """ + 获取目录下所有指定扩展名的文件(包括子目录) + """ + if not min_filesize: + min_filesize = 0 + + if not directory.exists(): + return [] + + if directory.is_file(): + return [directory] + + if not min_filesize: + min_filesize = 0 + + files = [] + pattern = r".*(" + "|".join(extensions) + ")$" + + # 遍历目录及子目录 + for path in directory.rglob('**/*'): + if path.is_file() \ + and re.match(pattern, path.name, re.IGNORECASE) \ + and path.stat().st_size >= min_filesize * 1024 * 1024: + files.append(path) + + return files + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'to_local', + 'label': '转为本地模式', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'to_api', + 'label': '转为API模式', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'convert_confs', + 'label': '转换配置', + 'rows': 3, + 'placeholder': 'strm文件根路径#转换路径' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '转换配置(转为本地模式):' + 'strm文件根路径#转换路径。' + '转换路径为源文件挂载进媒体服务器的路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '转换配置(转为API模式):' + 'strm文件根路径#转换路径#cd2/alist#cd2/alist服务地址(ip:port)。' + '转换路径为云盘根路径。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/StrmConvert.md' + } + } + ] + } + ] + } + ] + } + ], { + "to_local": False, + "to_api": False, + "convert_confs": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/subscribeclear/__init__.py b/plugins/subscribeclear/__init__.py new file mode 100644 index 0000000..89d834c --- /dev/null +++ b/plugins/subscribeclear/__init__.py @@ -0,0 +1,122 @@ +from app.plugins import _PluginBase +from app.db.subscribe_oper import SubscribeOper +from typing import Any, List, Dict, Tuple +from app.log import logger + + +class SubscribeClear(_PluginBase): + # 插件名称 + plugin_name = "清理订阅缓存" + # 插件描述 + plugin_desc = "清理订阅已下载集数。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/broom.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "subscribeclear_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 任务执行间隔 + _subscribe_ids = None + subscribe = None + + def init_plugin(self, config: dict = None): + self.subscribe = SubscribeOper() + if config: + self._subscribe_ids = config.get("subscribe_ids") + if self._subscribe_ids: + # 遍历 清理订阅下载缓存 + for subscribe_id in self._subscribe_ids: + self.subscribe.update(subscribe_id, {'note': ""}) + logger.info(f"订阅 {subscribe_id} 下载缓存已清理") + + self.update_config( + { + "subscribe_ids": [] + } + ) + + def get_state(self) -> bool: + return False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + subscribe_options = [{"title": subscribe.name, "value": subscribe.id} for subscribe in + self.subscribe.list('R') if subscribe.type == '电视剧'] + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'subscribe_ids', + 'label': '电视剧订阅', + 'items': subscribe_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '请选择需要清理缓存的订阅,用于清理该订阅已下载集数。' + '注意!!!未入库的会被重新下载。' + } + } + ] + } + ] + } + ] + } + ], { + "subscribe_ids": [] + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/subscribegroup/__init__.py b/plugins/subscribegroup/__init__.py new file mode 100644 index 0000000..d9d4632 --- /dev/null +++ b/plugins/subscribegroup/__init__.py @@ -0,0 +1,755 @@ +import json +import re +import time + +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.subscribe_oper import SubscribeOper +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.core.event import eventmanager, Event +from app.schemas.types import EventType, SystemConfigKey + + +class SubscribeGroup(_PluginBase): + # 插件名称 + plugin_name = "订阅规则自动填充" + # 插件描述 + plugin_desc = "电视剧下载后自动添加官组等信息到订阅;添加订阅后根据二级分类名称自定义订阅规则。" + # 插件图标 + plugin_icon = "teamwork.png" + # 插件版本 + plugin_version = "2.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "subscribegroup_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _enabled: bool = False + _category: bool = False + _clear = False + _clear_handle = False + _update_details = [] + _update_confs = None + _subscribe_confs = {} + _subscribeoper = None + _downloadhistoryoper = None + _siteoper = None + + def init_plugin(self, config: dict = None): + self._downloadhistoryoper = DownloadHistoryOper() + self._subscribeoper = SubscribeOper() + self._siteoper = SiteOper() + + if config: + self._enabled = config.get("enabled") + self._category = config.get("category") + self._clear = config.get("clear") + self._clear_handle = config.get("clear_handle") + self._update_details = config.get("update_details") or [] + self._update_confs = config.get("update_confs") + + if self._update_confs: + active_sites = self._siteoper.list_active() + for confs in str(self._update_confs).split("\n"): + category = None + resolution = None + quality = None + effect = None + include = None + exclude = None + savepath = None + sites = [] + for conf in str(confs).split("#"): + if ":" in conf: + k = conf.split(":")[0] + v = ":".join(conf.split(":")[1:]) + if k == "category": + category = v + if k == "resolution": + resolution = v + if k == "quality": + quality = v + if k == "effect": + effect = v + if k == "include": + include = v + if k == "exclude": + exclude = v + if k == "savepath": + savepath = v + if k == "sites": + for site_name in str(v).split(","): + for active_site in active_sites: + if str(site_name) == str(active_site.name): + sites.append(active_site.id) + break + if category: + for c in str(category).split(","): + self._subscribe_confs[c] = { + 'resolution': resolution, + 'quality': quality, + 'effect': effect, + 'include': include, + 'exclude': exclude, + 'savepath': savepath, + 'sites': sites + } + logger.info(f"获取到二级分类自定义配置 {len(self._subscribe_confs.keys())} 个") + else: + self._subscribe_confs = {} + + # 清理已处理历史 + if self._clear_handle: + self.del_data(key="history_handle") + + self._clear_handle = False + self.__update_config() + logger.info("已处理历史清理完成") + + # 清理历史记录 + if self._clear: + self.del_data(key="history") + + self._clear = False + self.__update_config() + logger.info("历史记录清理完成") + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "category": self._category, + "clear": self._clear, + "clear_handle": self._clear_handle, + "update_details": self._update_details, + "update_confs": self._update_confs, + }) + + @eventmanager.register(EventType.SubscribeAdded) + def subscribe_notice(self, event: Event = None): + """ + 添加订阅根据二级分类填充订阅 + """ + if not event: + logger.error("订阅事件数据为空") + return + + if not self._category: + logger.error("二级分类自定义填充未开启") + return + + if len(self._subscribe_confs.keys()) == 0: + logger.error("插件未开启二级分类自定义填充") + return + + if event: + event_data = event.event_data + if not event_data or not event_data.get("subscribe_id") or not event_data.get("mediainfo"): + logger.error(f"订阅事件数据不完整 {event_data}") + return + + sid = event_data.get("subscribe_id") + category = event_data.get("mediainfo").get("category") + if not category: + logger.error(f"订阅ID:{sid} 未获取到二级分类") + return + + if category not in self._subscribe_confs.keys(): + logger.error(f"订阅ID:{sid} 二级分类:{category} 未配置自定义规则") + return + + # 查询订阅 + subscribe = self._subscribeoper.get(sid) + + # 二级分类自定义配置 + category_conf = self._subscribe_confs.get(category) + + update_dict = {} + if category_conf.get('include'): + update_dict['include'] = category_conf.get('include') + if category_conf.get('exclude'): + update_dict['exclude'] = category_conf.get('exclude') + if category_conf.get('sites'): + update_dict['sites'] = json.dumps(category_conf.get('sites')) + if category_conf.get('resolution'): + update_dict['resolution'] = self.__parse_pix(category_conf.get('resolution')) + if category_conf.get('quality'): + update_dict['quality'] = self.__parse_type(category_conf.get('quality')) + if category_conf.get('effect'): + update_dict['effect'] = self.__parse_effect(category_conf.get('effect')) + if category_conf.get('savepath'): + # 判断是否有变量{name} + if '{name}' in category_conf.get('savepath'): + savepath = category_conf.get('savepath').replace('{name}', f"{subscribe.name} ({subscribe.year})") + update_dict['save_path'] = savepath + else: + update_dict['save_path'] = category_conf.get('savepath') + + # 更新订阅自定义配置 + self._subscribeoper.update(sid, update_dict) + logger.info(f"订阅记录:{subscribe.name} 填充成功\n{update_dict}") + + # 读取历史记录 + history = self.get_data('history') or [] + + history.append({ + 'name': subscribe.name, + 'type': f'二级分类自定义配置 {category}', + 'content': json.dumps(update_dict), + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + # 保存历史 + self.save_data(key="history", value=history) + + @eventmanager.register(EventType.DownloadAdded) + def download_notice(self, event: Event = None): + """ + 添加下载填充订阅制作组等信息 + """ + if not event: + logger.error("下载事件数据为空") + return + + if not self._enabled: + logger.error("种子下载自定义填充未开启") + return + + if len(self._update_details) == 0: + logger.error("插件未开启更新填充内容") + return + + if event: + event_data = event.event_data + if not event_data or not event_data.get("hash") or not event_data.get("context"): + logger.error(f"下载事件数据不完整 {event_data}") + return + download_hash = event_data.get("hash") + # 根据hash查询下载记录 + download_history = self._downloadhistoryoper.get_by_hash(download_hash) + if not download_history: + logger.warning(f"种子hash:{download_hash} 对应下载记录不存在") + return + + history_handle: List[str] = self.get_data('history_handle') or [] + + if f"{download_history.type}:{download_history.tmdbid}" in history_handle: + logger.warning(f"下载历史:{download_history.title} 已处理过,不再重复处理") + return + + if download_history.type != '电视剧': + logger.warning(f"下载历史:{download_history.title} 不是电视剧,不进行官组填充") + return + + # 根据下载历史查询订阅记录 + subscribes = self._subscribeoper.list_by_tmdbid(tmdbid=download_history.tmdbid, + season=int(download_history.seasons.replace('S', '')) + if download_history.seasons and + download_history.seasons.count('-') == 0 else None) + if not subscribes or len(subscribes) == 0: + logger.warning(f"下载历史:{download_history.title} tmdbid:{download_history.tmdbid} 对应订阅记录不存在") + return + + logger.info( + f"获取到tmdbid {download_history.tmdbid} season {int(download_history.seasons.replace('S', '')) if download_history.seasons and download_history.seasons.count('-') == 0 else None} 订阅记录:{len(subscribes)} 个") + + for subscribe in subscribes: + if subscribe.type != '电视剧': + logger.warning(f"订阅记录:{subscribe.name} 不是电视剧,不进行官组填充") + continue + + # 开始填充官组和站点 + context = event_data.get("context") + _torrent = context.torrent_info + _meta = context.meta_info + + # 填充数据 + update_dict = {} + # 分辨率 + if "分辨率" in self._update_details and not subscribe.resolution: + resource_pix = _meta.resource_pix if _meta else None + if resource_pix: + resource_pix = self.__parse_pix(resource_pix) + if resource_pix: + update_dict['resolution'] = resource_pix + else: + logger.warning(f"订阅记录:{subscribe.name} 未获取到分辨率信息") + # 资源质量 + if "资源质量" in self._update_details and not subscribe.quality: + resource_type = _meta.resource_type if _meta else None + if resource_type: + resource_type = self.__parse_type(resource_type) + if resource_type: + update_dict['quality'] = resource_type + else: + logger.warning(f"订阅记录:{subscribe.name} 未获取到资源质量信息") + # 特效 + if "特效" in self._update_details and not subscribe.effect: + resource_effect = _meta.resource_effect if _meta else None + if resource_effect: + resource_effect = self.__parse_effect(resource_effect) + if resource_effect: + update_dict['effect'] = resource_effect + else: + logger.warning(f"订阅记录:{subscribe.name} 未获取到特效信息") + # 制作组 + if "制作组" in self._update_details and not subscribe.include: + # 官组 + resource_team = _meta.resource_team if _meta else None + customization = _meta.customization if _meta else None + if resource_team and customization: + resource_team = f"{customization}.+{resource_team}" + if not resource_team and customization: + resource_team = customization + if resource_team: + update_dict['include'] = resource_team + # 站点 + if "站点" in self._update_details and ( + not subscribe.sites or (subscribe.sites and len(json.loads(subscribe.sites)) == 0)): + # 站点 判断是否在订阅站点范围内 + rss_sites = self.systemconfig.get(SystemConfigKey.RssSites) or [] + if _torrent and _torrent.site and int(_torrent.site) in rss_sites: + sites = json.dumps([_torrent.site]) + update_dict['sites'] = sites + + if len(update_dict.keys()) == 0: + logger.info(f"订阅记录:{subscribe.name} 无需填充") + continue + + # 更新订阅记录 + self._subscribeoper.update(subscribe.id, update_dict) + logger.info(f"订阅记录:{subscribe.name} 填充成功\n {update_dict}") + + # 读取历史记录 + history = self.get_data('history') or [] + history.append({ + 'name': subscribe.name, + 'type': '种子下载自定义配置', + 'content': json.dumps(update_dict), + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + # 保存历史 + self.save_data(key="history", value=history) + + # 保存已处理历史 + history_handle.append(f"{download_history.type}:{download_history.tmdbid}") + self.save_data('history_handle', history_handle) + + def __parse_pix(self, resource_pix): + # 识别1080或者4k或720 + if re.match(r"1080[pi]|x1080", resource_pix): + resource_pix = "1080[pi]|x1080" + if re.match(r"4K|2160p|x2160", resource_pix): + resource_pix = "4K|2160p|x2160" + if re.match(r"720[pi]|x720", resource_pix): + resource_pix = "720[pi]|x720" + return resource_pix + + def __parse_type(self, resource_type): + if re.match(r"Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD", resource_type): + resource_type = "Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD" + if re.match(r"Remux", resource_type): + resource_type = "Remux" + if re.match(r"Blu-?Ray", resource_type): + resource_type = "Blu-?Ray" + if re.match(r"UHD|UltraHD", resource_type): + resource_type = "UHD|UltraHD" + if re.match(r"WEB-?DL|WEB-?RIP", resource_type): + resource_type = "WEB-?DL|WEB-?RIP" + if re.match(r"HDTV", resource_type): + resource_type = "HDTV" + if re.match(r"[Hx].?265|HEVC", resource_type): + resource_type = "[Hx].?265|HEVC" + if re.match(r"[Hx].?264|AVC", resource_type): + resource_type = "[Hx].?264|AVC" + return resource_type + + def __parse_effect(self, resource_effect): + if re.match(r"Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+", resource_effect): + resource_effect = "Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+" + if re.match(r"Dolby[\\s.]*\\+?Atmos|Atmos", resource_effect): + resource_effect = "Dolby[\\s.]*\\+?Atmos|Atmos" + if re.match(r"[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+", resource_effect): + resource_effect = "[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+" + if re.match(r"[\\s.]+SDR[\\s.]+", resource_effect): + resource_effect = "[\\s.]+SDR[\\s.]+" + return resource_effect + + def get_state(self) -> bool: + return self._enabled or self._category + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '种子下载自定义填充', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'category', + 'label': '二级分类自定义填充', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear', + 'label': '清理历史记录', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_handle', + 'label': '清理已处理记录', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'update_details', + 'label': '种子下载填充内容', + 'items': [ + { + "title": "资源质量", + "vale": "资源质量" + }, + { + "title": "分辨率", + "vale": "分辨率" + }, + { + "title": "特效", + "vale": "特效" + }, + { + "title": "制作组", + "vale": "制作组" + }, + { + "title": "站点", + "vale": "站点" + } + ] + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'update_confs', + 'label': '二级分类自定义填充', + 'rows': 3, + 'placeholder': 'category:日番#include:.*(CR.*简繁|简繁英).RLWeb|ADWeb.#sites:观众,红叶PT\n' + 'category:港台剧,日韩剧#include:国粤' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '种子下载自定义填充:需要下载种子才会填充订阅属性,且不会覆盖原有属性!' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '电视剧订阅未配置包含关键词、订阅站点等配置时,订阅或搜索下载后,' + '将下载种子的制作组、站点等信息填充到订阅信息中,以保证后续订阅资源的统一性。' + '(订阅新出的电视剧效果更佳。)' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '二级分类自定义填充:添加订阅才会填充订阅属性,会强制覆盖!用于根据二级分类自定义订阅规则,具体属性明细请查看电视剧订阅设置页面。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': 'category:二级分类名称(多个分类名称逗号拼接),resolution:分辨率,quality:质量,effect:特效,include:包含关键词,' + 'exclude:排除关键词,sites:站点名称(多个站点用逗号拼接),savepath:保存路径/{name}({name}为当前订阅的名称和年份)。' + 'category必填,多组属性用#分割。例如category:动漫#resolution:1080p' + '(添加的动漫订阅,指定分辨率为1080p)。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "category": False, + "clear": False, + "clear_handle": False, + "update_details": [], + "update_confs": "", + } + + def get_page(self) -> List[dict]: + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + if not isinstance(historys, list): + historys = [historys] + + # 按照时间倒序 + historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True) + + contens = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': history.get("time") + }, + { + 'component': 'td', + 'text': history.get("name") + }, + { + 'component': 'td', + 'text': history.get("type") + }, + { + 'component': 'td', + 'text': history.get("content").encode('utf-8').decode('unicode_escape') if history.get( + "content") else '' + } + ] + } for history in historys + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '执行时间' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '订阅名称' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '更新类型' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '更新内容' + }, + ] + }, + { + 'component': 'tbody', + 'content': contens + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/subscribereminder/__init__.py b/plugins/subscribereminder/__init__.py new file mode 100644 index 0000000..79e5a99 --- /dev/null +++ b/plugins/subscribereminder/__init__.py @@ -0,0 +1,284 @@ +from datetime import datetime, timedelta + +import pytz +from app.chain.media import MediaChain +from app.chain.tmdb import TmdbChain +from app.core.config import settings +from app.db.subscribe_oper import SubscribeOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.schemas import NotificationType, MediaType + + +class SubscribeReminder(_PluginBase): + # 插件名称 + plugin_name = "订阅提醒" + # 插件描述 + plugin_desc = "推送当天订阅更新内容。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribe_reminder.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "subscribereminder_" + # 加载顺序 + plugin_order = 33 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _time = None + tmdb = None + media = None + subscribe_oper = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.subscribe_oper = SubscribeOper() + self.tmdb = TmdbChain() + self.media = MediaChain() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._time = config.get("time") + + if self._enabled or self._onlyonce: + # 周期运行 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._time and str(self._time).isdigit(): + cron = f"0 {int(self._time)} * * *" + try: + self._scheduler.add_job(func=self.__send_notify, + trigger=CronTrigger.from_crontab(cron), + name="订阅提醒") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 立即运行一次 + if self._onlyonce: + logger.info(f"订阅提醒服务启动,立即运行一次") + self._scheduler.add_job(self.__send_notify, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="订阅提醒") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "time": self._time + }) + + def __send_notify(self): + # 查询所有订阅 + subscribes = self.subscribe_oper.list() + if not subscribes: + logger.error("当前没有订阅,跳过处理") + return + + # 当前日期 + current_date = datetime.now().date().strftime("%Y-%m-%d") + + current_tv_subscribe = [] + current_movie_subscribe = [] + # 遍历订阅,查询tmdb + for subscribe in subscribes: + # 电视剧 + if subscribe.type == "电视剧": + if not subscribe.tmdbid or not subscribe.season: + continue + + # 电视剧某季所有集 + episodes_info = self.tmdb.tmdb_episodes(tmdbid=subscribe.tmdbid, season=subscribe.season) + if not episodes_info: + continue + + episodes = [] + # 遍历集,筛选当前日期发布的剧集 + for episode in episodes_info: + if episode and episode.air_date and str(episode.air_date) == current_date: + episodes.append(episode.episode_number) + + if episodes: + current_tv_subscribe.append({ + 'name': f"{subscribe.name} ({subscribe.year})", + 'season': f"S{str(subscribe.season).rjust(2, '0')}", + 'episode': f"E{str(episodes[0]).rjust(2, '0')}-E{str(episodes[-1]).rjust(2, '0')}" if len( + episodes) > 1 else f"E{str(episodes[0]).rjust(2, '0')}" + }) + + # 电影 + else: + if not subscribe.tmdbid: + continue + mediainfo = self.media.recognize_media(tmdbid=subscribe.tmdbid, mtype=MediaType.MOVIE) + if not mediainfo: + continue + if str(mediainfo.release_date) == current_date: + current_movie_subscribe.append({ + 'name': f"{subscribe.name} ({subscribe.year})" + }) + + # 如当前日期匹配到订阅,则发送通知 + text = "" + for sub in current_tv_subscribe: + text += sub.get("name") + "\n" + text += sub.get("season") + sub.get("episode") + "\n" + text += "\n" + + for sub in current_movie_subscribe: + text += sub.get("name") + "\n" + text += "\n" + + if text: + self.post_message(mtype=NotificationType.Subscribe, + title=f"{current_date}订阅提醒", + text=text) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'time', + 'label': '时间', + 'placeholder': '默认9点' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '默认每天9点推送,需开启(订阅)通知类型。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "time": 9, + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/subscribestatistic/__init__.py b/plugins/subscribestatistic/__init__.py new file mode 100644 index 0000000..18594cc --- /dev/null +++ b/plugins/subscribestatistic/__init__.py @@ -0,0 +1,730 @@ +import json +from datetime import datetime, timedelta + +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from app.db.subscribe_oper import SubscribeOper +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.log import logger +from app.core.config import settings +from app.schemas import NotificationType +from app.schemas.types import SystemConfigKey + + +class SubscribeStatistic(_PluginBase): + # 插件名称 + plugin_name = "订阅下载统计" + # 插件描述 + plugin_desc = "统计指定时间内各站点订阅及下载情况。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/subscribestatistic.png" + # 插件版本 + plugin_version = "1.5" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "subscribestatistic_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 任务执行间隔 + _enabled = False + _notify = False + _onlyonce = False + _movie_subscribe_days = None + _tv_subscribe_days = None + _movie_download_days = None + _tv_download_days = None + _notify_type = None + _msgtype = None + subscribe = None + downloadhis = None + siteoper = None + _cron: str = "" + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.subscribe = SubscribeOper() + self.downloadhis = DownloadHistoryOper() + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._movie_subscribe_days = config.get("movie_subscribe_days") + self._tv_subscribe_days = config.get("tv_subscribe_days") + self._movie_download_days = config.get("movie_download_days") + self._tv_download_days = config.get("tv_download_days") + self._notify_type = config.get("notify_type") + self._msgtype = config.get("msgtype") + + if self._enabled and ( + self._cron or self._onlyonce) and self._notify and self._msgtype and self._notify_type: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"订阅下载统计服务启动,立即运行一次") + self._scheduler.add_job(self.notify, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="订阅下载统计") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.notify, + trigger=CronTrigger.from_crontab(self._cron), + name="订阅下载统计") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "notify": self._notify, + "movie_subscribe_days": self._movie_subscribe_days, + "tv_subscribe_days": self._tv_subscribe_days, + "movie_download_days": self._movie_download_days, + "tv_download_days": self._tv_download_days, + "notify_type": self._notify_type, + "msgtype": self._msgtype, + }) + + def notify(self): + """ + 发送统计消息 + """ + text = "" + if 'movie_subscribes' in self._notify_type: + text += f"【电影{self._movie_subscribe_days}天内订阅统计】\n" + _, movie_subscribe_sites, movie_subscribe_datas = self.__get_movie_subscribes() + movie_subscribe_dict = dict(zip(movie_subscribe_sites, movie_subscribe_datas)) + movie_subscribe_dict = dict(sorted(movie_subscribe_dict.items(), key=lambda x: x[1], reverse=True)) + for movie_subscribe_site in movie_subscribe_dict.keys(): + text += f"{movie_subscribe_site}: {movie_subscribe_dict[movie_subscribe_site]}\n" + text += "\n" + + if 'tv_subscribes' in self._notify_type: + text += f"【电视剧{self._tv_subscribe_days}天内订阅统计】\n" + _, tv_subscribe_sites, tv_subscribe_datas = self.__get_tv_subscribes() + tv_subscribe_dict = dict(zip(tv_subscribe_sites, tv_subscribe_datas)) + tv_subscribe_dict = dict(sorted(tv_subscribe_dict.items(), key=lambda x: x[1], reverse=True)) + for tv_subscribe_site in tv_subscribe_dict.keys(): + text += f"{tv_subscribe_site}: {tv_subscribe_dict[tv_subscribe_site]}\n" + text += "\n" + + if 'movie_downloads' in self._notify_type: + text += f"【电影{self._movie_download_days}天内下载统计】\n" + _, movie_download_sites, movie_download_datas = self.__get_movie_downloads() + movie_download_dict = dict(zip(movie_download_sites, movie_download_datas)) + movie_download_dict = dict(sorted(movie_download_dict.items(), key=lambda x: x[1], reverse=True)) + for movie_download_site in movie_download_dict.keys(): + text += f"{movie_download_site}: {movie_download_dict[movie_download_site]}\n" + text += "\n" + + if 'tv_downloads' in self._notify_type: + text += f"【电视剧{self._tv_download_days}天内下载统计】\n" + _, tv_download_sites, tv_download_datas = self.__get_tv_downloads() + tv_download_dict = dict(zip(tv_download_sites, tv_download_datas)) + tv_download_dict = dict(sorted(tv_download_dict.items(), key=lambda x: x[1], reverse=True)) + for tv_download_site in tv_download_dict.keys(): + text += f"{tv_download_site}: {tv_download_dict[tv_download_site]}\n" + + # 发送通知 + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + + self.post_message(title="【订阅下载统计】", + mtype=mtype, + text=text) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def __get_movie_subscribes(self): + """ + 获取电影订阅统计数据 + """ + # 电影订阅 + movie_subscribes = self.subscribe.list_by_type(mtype='电影', days=self._movie_subscribe_days) + movie_subscribe_sites = [] + movie_subscribe_datas = [] + if movie_subscribes: + movie_subscribe_site_ids = [] + for movie_subscribe in movie_subscribes: + if movie_subscribe.sites: + movie_subscribe_site_ids += [site for site in json.loads(movie_subscribe.sites)] + else: + movie_subscribe_site_ids += self.systemconfig.get(SystemConfigKey.RssSites) or [] + + for movie_subscribe_site_id in movie_subscribe_site_ids: + site = self.siteoper.get(movie_subscribe_site_id) + if site: + if not movie_subscribe_sites.__contains__(site.name): + movie_subscribe_sites.append(site.name) + movie_subscribe_datas.append(movie_subscribe_site_ids.count(movie_subscribe_site_id)) + + return movie_subscribes, movie_subscribe_sites, movie_subscribe_datas + + def __get_tv_subscribes(self): + """ + 获取电视剧订阅统计数据 + """ + tv_subscribes = self.subscribe.list_by_type(mtype='电视剧', days=self._tv_subscribe_days) + tv_subscribe_sites = [] + tv_subscribe_datas = [] + if tv_subscribes: + tv_subscribe_site_ids = [] + for tv_subscribe in tv_subscribes: + if tv_subscribe.sites: + tv_subscribe_site_ids += [site for site in json.loads(tv_subscribe.sites)] + else: + tv_subscribe_site_ids += self.systemconfig.get(SystemConfigKey.RssSites) or [] + + for tv_subscribe_site_id in tv_subscribe_site_ids: + site = self.siteoper.get(tv_subscribe_site_id) + if site: + if not tv_subscribe_sites.__contains__(site.name): + tv_subscribe_sites.append(site.name) + tv_subscribe_datas.append(tv_subscribe_site_ids.count(tv_subscribe_site_id)) + + return tv_subscribes, tv_subscribe_sites, tv_subscribe_datas + + def __get_movie_downloads(self): + """ + 获取电影下载统计数据 + """ + movie_downloads = self.downloadhis.list_by_type(mtype="电影", days=self._movie_download_days) + movie_download_sites = [] + movie_download_datas = [] + if movie_downloads: + movie_download_sites2 = [] + for movie_download in movie_downloads: + if movie_download.torrent_site: + movie_download_sites2.append(movie_download.torrent_site) + + for movie_download_site in movie_download_sites2: + if not movie_download_sites.__contains__(movie_download_site): + movie_download_sites.append(movie_download_site) + if not movie_download_datas.__contains__(movie_download_site): + movie_download_datas.append(movie_download_sites2.count(movie_download_site)) + + return movie_downloads, movie_download_sites, movie_download_datas + + def __get_tv_downloads(self): + """ + 获取电视剧下载统计数据 + """ + tv_downloads = self.downloadhis.list_by_type(mtype="电视剧", days=self._tv_download_days) + tv_download_sites = [] + tv_download_datas = [] + if tv_downloads: + tv_download_sites2 = [] + for tv_download in tv_downloads: + if tv_download.torrent_site: + tv_download_sites2.append(tv_download.torrent_site) + + for tv_download_site in tv_download_sites2: + if not tv_download_sites.__contains__(tv_download_site): + tv_download_sites.append(tv_download_site) + if not tv_download_datas.__contains__(tv_download_site): + tv_download_datas.append(tv_download_sites2.count(tv_download_site)) + + return tv_downloads, tv_download_sites, tv_download_datas + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'movie_subscribe_days', + 'label': '电影订阅天数', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'tv_subscribe_days', + 'label': '电视剧订阅天数', + 'placeholder': '30' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'movie_download_days', + 'label': '电影下载天数', + 'placeholder': '7' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'tv_download_days', + 'label': '电视剧下载天数', + 'placeholder': '7' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'model': 'notify_type', + 'label': '推送类型', + 'items': [ + {'title': '电影订阅', 'value': 'movie_subscribes'}, + {'title': '电视剧订阅', 'value': 'tv_subscribes'}, + {'title': '电影下载', 'value': 'movie_downloads'}, + {'title': '电视剧下载', 'value': 'tv_downloads'}, + ] + } + } + ] + }, + + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '订阅数量:MoviePilot指定天数内正在订阅的数量。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '下载数量:通过MoviePilot下载的数量,包括订阅下载、手动下载以及其他下载等场景。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "cron": "5 1 * * *", + "movie_subscribe_days": 30, + "tv_subscribe_days": 30, + "movie_download_days": 7, + "tv_download_days": 7, + "notify_type": "", + "msgtype": "" + } + + def get_page(self) -> List[dict]: + if not self._enabled: + return [ + { + 'component': 'div', + 'text': '暂未开启插件', + 'props': { + 'class': 'text-center', + } + } + ] + + # 电影订阅 + movie_subscribes, movie_subscribe_sites, movie_subscribe_datas = self.__get_movie_subscribes() + + # 电视剧订阅 + tv_subscribes, tv_subscribe_sites, tv_subscribe_datas = self.__get_tv_subscribes() + + # 电影下载 + movie_downloads, movie_download_sites, movie_download_datas = self.__get_movie_downloads() + + # 电视剧下载 + tv_downloads, tv_download_sites, tv_download_datas = self.__get_tv_downloads() + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + # 电影订阅图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': movie_subscribe_sites, + 'title': { + 'text': f'电影近 {self._movie_subscribe_days} 天订阅 {len(movie_subscribes)} 部' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '订阅未选择站点或站点已删除' + } + }, + 'series': movie_subscribe_datas + } + } + ] + }, + # 电视剧订阅图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': tv_subscribe_sites, + 'title': { + 'text': f'电视剧近 {self._tv_subscribe_days} 天订阅 {len(tv_subscribes)} 部' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '订阅未选择站点或站点已删除' + } + }, + 'series': tv_subscribe_datas + } + } + ] + }, + # 电影下载图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': movie_download_sites, + 'title': { + 'text': f'电影近 {self._movie_download_days} 天下载 {len(movie_downloads)} 个种子' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '暂无数据' + } + }, + 'series': movie_download_datas + } + } + ] + }, + # 电视剧下载图表 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VApexChart', + 'props': { + 'height': 300, + 'options': { + 'chart': { + 'type': 'pie', + }, + 'labels': tv_download_sites, + 'title': { + 'text': f'电视剧近 {self._tv_download_days} 天下载 {len(tv_downloads)} 个种子' + }, + 'legend': { + 'show': True + }, + 'plotOptions': { + 'pie': { + 'expandOnClick': False + } + }, + 'noData': { + 'text': '暂无数据' + } + }, + 'series': tv_download_datas + } + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py new file mode 100644 index 0000000..768ed9d --- /dev/null +++ b/plugins/synccookiecloud/__init__.py @@ -0,0 +1,276 @@ +import json +from datetime import datetime, timedelta +from hashlib import md5 + +import pytz + +from app.core.config import settings +from app.db.site_oper import SiteOper +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from app.utils.common import encrypt + + +class SyncCookieCloud(_PluginBase): + # 插件名称 + plugin_name = "同步CookieCloud" + # 插件描述 + plugin_desc = "同步MoviePilot站点Cookie到本地CookieCloud。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cookiecloud.png" + # 插件版本 + plugin_version = "1.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "synccookiecloud_" + # 加载顺序 + plugin_order = 28 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled: bool = False + _onlyonce: bool = False + _cron: str = "" + siteoper = None + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + self.siteoper = SiteOper() + + # 停止现有任务 + self.stop_service() + + if config: + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 立即运行一次 + if self._onlyonce: + logger.info(f"同步CookieCloud服务启动,立即运行一次") + self._scheduler.add_job(self.__sync_to_cookiecloud, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="同步CookieCloud") + # 关闭一次性开关 + self._onlyonce = False + + # 保存配置 + self.__update_config() + + # 周期运行 + if self._cron: + try: + self._scheduler.add_job(func=self.__sync_to_cookiecloud, + trigger=CronTrigger.from_crontab(self._cron), + name="同步CookieCloud") + except Exception as err: + logger.error(f"定时任务配置错误:{err}") + # 推送实时消息 + self.systemmessage.put(f"执行周期配置错误:{err}") + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __sync_to_cookiecloud(self): + """ + 同步站点cookie到cookiecloud + """ + # 获取所有站点 + sites = self.siteoper.list_order_by_pri() + if not sites: + return + + if not settings.COOKIECLOUD_ENABLE_LOCAL: + logger.error('本地CookieCloud服务器未启用') + return + + cookies = {} + for site in sites: + domain = site.domain + cookie = site.cookie + + if not cookie: + logger.error(f"站点{domain}无cookie,跳过处理") + continue + + # 解析cookie + site_cookies = [] + for ck in cookie.split(";"): + site_cookies.append({ + "domain": domain, + "sameSite": "unspecified", + "path": "/", + "name": ck.split("=")[0], + "value": ck.split("=")[1] + }) + + # 存储cookies + cookies[domain] = site_cookies + + # 覆盖到cookiecloud + if cookies: + crypt_key = self._get_crypt_key() + try: + cookies = {'cookie_data': cookies} + encrypted_data = encrypt(json.dumps(cookies).encode('utf-8'), crypt_key).decode('utf-8') + except Exception as e: + logger.error(f"CookieCloud加密失败,{e}") + return + + ck = {'encrypted': encrypted_data} + file = open(settings.COOKIE_PATH / f'{settings.COOKIECLOUD_KEY}.json', 'w') + file.write(json.dumps(ck)) + file.close() + + logger.info(cookies) + logger.info(f"同步站点cookie到CookieCloud成功") + + def _get_crypt_key(self) -> bytes: + """ + 使用UUID和密码生成CookieCloud的加解密密钥 + """ + md5_generator = md5() + md5_generator.update((str(settings.COOKIECLOUD_KEY).strip() + '-' + str(settings.COOKIECLOUD_PASSWORD).strip()).encode('utf-8')) + return (md5_generator.hexdigest()[:16]).encode('utf-8') + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式,留空自动' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '需要MoviePilot设定-站点启用本地CookieCloud服务器。' + } + } + ] + } + ] + }, + ] + } + ], { + "enabled": False, + "onlyonce": False, + "cron": "5 1 * * *", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins/synologynotify/__init__.py b/plugins/synologynotify/__init__.py new file mode 100644 index 0000000..6ed1ad7 --- /dev/null +++ b/plugins/synologynotify/__init__.py @@ -0,0 +1,215 @@ +from app.plugins import _PluginBase +from typing import Any, List, Dict, Tuple +from app.log import logger +from app.schemas import NotificationType +from app import schemas + + +class SynologyNotify(_PluginBase): + # 插件名称 + plugin_name = "群辉Webhook通知" + # 插件描述 + plugin_desc = "接收群辉webhook通知并推送。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/synology.png" + # 插件版本 + plugin_version = "1.1" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "synologynotify_" + # 加载顺序 + plugin_order = 30 + # 可使用的用户级别 + auth_level = 1 + + # 任务执行间隔 + _enabled = False + _notify = False + _msgtype = None + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._msgtype = config.get("msgtype") + + def send_notify(self, text: str) -> schemas.Response: + """ + 发送通知 + """ + logger.info(f"收到webhook消息啦。。。 {text}") + if self._enabled and self._notify: + mtype = NotificationType.Manual + if self._msgtype: + mtype = NotificationType.__getitem__(str(self._msgtype)) or NotificationType.Manual + self.post_message(title="群辉通知", + mtype=mtype, + text=text) + + return schemas.Response( + success=True, + message="发送成功" + ) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + """ + 获取插件API + [{ + "path": "/xx", + "endpoint": self.xxx, + "methods": ["GET", "POST"], + "summary": "API说明" + }] + """ + return [{ + "path": "/webhook", + "endpoint": self.send_notify, + "methods": ["GET"], + "summary": "群辉webhook", + "description": "接受群辉webhook通知并推送", + }] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + # 编历 NotificationType 枚举,生成消息类型选项 + MsgTypeOptions = [] + for item in NotificationType: + MsgTypeOptions.append({ + "title": item.value, + "value": item.name + }) + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': False, + 'chips': True, + 'model': 'msgtype', + 'label': '消息类型', + 'items': MsgTypeOptions + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '群辉webhook配置http://ip:3001/api/v1/plugin/SynologyNotify/webhook?text=hello world。' + 'text参数类型是消息内容。此插件安装完需要重启生效api。消息类型默认为手动处理通知。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '如安装完插件后,群晖发送webhook提示404,重启MoviePilot即可。' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "msgtype": "" + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + pass diff --git a/plugins/wechatforward/__init__.py b/plugins/wechatforward/__init__.py new file mode 100644 index 0000000..5c7e309 --- /dev/null +++ b/plugins/wechatforward/__init__.py @@ -0,0 +1,1093 @@ +import json +import re +import time +from datetime import datetime + +from app.core.config import settings +from app.db.models.subscribehistory import SubscribeHistory +from app.db.subscribe_oper import SubscribeOper +from app.plugins import _PluginBase +from app.core.event import eventmanager +from app.schemas.types import EventType, MessageChannel, MediaType +from app.utils.http import RequestUtils +from typing import Any, List, Dict, Tuple, Optional +from app.log import logger + + +class WeChatForward(_PluginBase): + # 插件名称 + plugin_name = "微信消息转发" + # 插件描述 + plugin_desc = "根据正则转发通知到其他WeChat应用。" + # 插件图标 + plugin_icon = "Wechat_A.png" + # 插件版本 + plugin_version = "2.7" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "wechatforward_" + # 加载顺序 + plugin_order = 16 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _rebuild = False + _wechat_confs = None + _specify_confs = None + _ignore_userid = None + _wechat_token_pattern_confs = {} + _extra_msg_history = {} + _history_days = None + + # 企业微信发送消息URL + _send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s" + # 企业微信获取TokenURL + _token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s" + + example = [ + { + "remark": "入库消息", + "appid": 1000001, + "corpid": "", + "appsecret": "", + "pattern": "已入库", + "extra_confs": [ + + ], + }, + { + "remark": "站点签到数据统计", + "appid": 1000002, + "corpid": "", + "appsecret": "", + "pattern": "自动签到|自动登录|数据统计|刷流任务", + "extra_confs": [] + } + ] + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled") + self._rebuild = config.get("rebuild") + self._wechat_confs = config.get("wechat_confs") or [] + self._ignore_userid = config.get("ignore_userid") + self._specify_confs = config.get("specify_confs") + self._history_days = config.get("history_days") or 7 + + # 兼容旧版本配置 + self.__sync_old_config() + + # 获取token存库 + if self._enabled and self._wechat_confs: + self.__save_wechat_token() + + def __sync_old_config(self): + """ + 兼容旧版本配置 + """ + config = self.get_config() + if not config or not config.get("wechat") or not config.get("pattern"): + return + + __extra_confs = {} + if config.get("extra_confs"): + for extra_conf in config.get("extra_confs").split("\n"): + if not extra_conf: + continue + if str(extra_conf).startswith("#"): + extra_conf = extra_conf.strip()[1:] + extras = str(extra_conf).split(" > ") + if len(extras) != 4: + continue + extra_pattern = extras[0] + extra_userid = extras[1] + extra_title = extras[2] + extra_appid = extras[3] + __extra = __extra_confs.get(extra_appid, []) + __extra.append({ + "pattern": extra_pattern, + "userid": extra_userid, + "msg": extra_title, + }) + __extra_confs[extra_appid] = __extra + + wechat_confs = [] + for index, wechat in enumerate(config.get("wechat").split("\n")): + remark = "" + if wechat.count("#") == 1: + remark = wechat.split("#")[1] + wechat = wechat.split("#")[0] + wechat_config = wechat.split(":") + if len(wechat_config) != 3: + continue + appid = wechat_config[0] + corpid = wechat_config[1] + appsecret = wechat_config[2] + if not remark: + remark = f"{appid}配置" + + # 获取对应appid的正则 + pattern = config.get("pattern").split("\n")[index] or "" + wechat_confs.append({ + "remark": remark, + "appid": appid, + "corpid": corpid, + "appsecret": appsecret, + "pattern": pattern, + "extra_confs": __extra_confs.get(appid, []) if __extra_confs else [] + }) + + if wechat_confs: + self._wechat_confs = json.dumps(wechat_confs, indent=4, ensure_ascii=False) + self.update_config({ + "enabled": self._enabled, + "wechat_confs": self._wechat_confs, + "ignore_userid": self._ignore_userid, + "specify_confs": self._specify_confs, + }) + logger.info("旧版本配置已转为新版本配置") + + def __save_wechat_token(self): + """ + 获取并存储wechat token + """ + # 如果重建则重新解析存库 + if self._rebuild: + self.__parse_token() + else: + # 从数据库获取token + wechat_confs = self.get_data('wechat_confs') + + if not self._wechat_token_pattern_confs and wechat_confs: + self._wechat_token_pattern_confs = wechat_confs + logger.info(f"WeChat配置 从数据库获取成功:{len(self._wechat_token_pattern_confs.keys())}条配置") + else: + self.__parse_token() + + def __parse_token(self): + """ + 解析token存库 + """ + # 解析配置 + for wechat in json.loads(self._wechat_confs): + remark = wechat.get("remark") + appid = wechat.get("appid") + corpid = wechat.get("corpid") + appsecret = wechat.get("appsecret") + pattern = wechat.get("pattern") + extra_confs = wechat.get("extra_confs") + if not appid or not corpid or not appsecret: + logger.error(f"{remark} 应用配置不正确, 跳过处理") + continue + + # 获取token + access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, + appsecret=appsecret) + if not access_token: + # 没有token,获取token + logger.error(f"WeChat配置 {remark} 获取token失败,请检查配置") + continue + + self._wechat_token_pattern_confs[appid] = { + "remark": remark, + "corpid": corpid, + "appsecret": appsecret, + "access_token": access_token, + "expires_in": expires_in, + "access_token_time": access_token_time, + "pattern": pattern, + "extra_confs": extra_confs, + } + logger.info(f"WeChat配置 {remark} 配置成功:{self._wechat_token_pattern_confs[appid]}") + + if self._rebuild: + self._rebuild = False + self.__update_config() + + # token存库 + if len(self._wechat_token_pattern_confs.keys()) > 0: + self.__save_wechat_confs() + + def __update_config(self): + self.update_config({ + "enabled": self._enabled, + "rebuild": self._rebuild, + "wechat_confs": self._wechat_confs, + "ignore_userid": self._ignore_userid, + "specify_confs": self._specify_confs, + "history_days": self._history_days + }) + + def __save_wechat_confs(self): + """ + 保存wechat配置 + """ + self.save_data(key="wechat_confs", + value=self._wechat_token_pattern_confs) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '开启转发' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'rebuild', + 'label': '重建缓存' + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "label": "设置微信配置" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'history_days', + 'label': '保留历史天数' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 9 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'ignore_userid', + 'rows': '1', + 'label': '忽略userid', + 'placeholder': '开始下载|添加下载任务失败' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'specify_confs', + 'rows': '2', + 'label': '特定消息指定用户', + 'placeholder': 'title > text > userid' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '12px' + }, + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '配置教程请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/WeChatForward.md', + 'target': '_blank' + }, + 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/WeChatForward.md' + } + ] + } + ] + } + ] + }, + { + "component": "VDialog", + "props": { + "model": "dialog_closed", + "max-width": "65rem", + "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked", + "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t" + }, + "content": [ + { + "component": "VCard", + "props": { + "title": "设置微信配置" + }, + "content": [ + { + "component": "VDialogCloseBtn", + "props": { + "model": "dialog_closed" + } + }, + { + "component": "VCardText", + "props": {}, + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAceEditor', + 'props': { + 'modelvalue': 'wechat_confs', + 'lang': 'json', + 'theme': 'monokai', + 'style': 'height: 30rem', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:只有正确配置微信配置时,该配置项才会生效,详细配置参考。' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "rebuild": False, + "ignore_userid": "", + "specify_confs": "", + "history_days": 7, + "wechat_confs": json.dumps(WeChatForward.example, indent=4, ensure_ascii=False) + } + + def get_page(self) -> List[dict]: + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + if not isinstance(historys, list): + historys = [historys] + + # 按照时间倒序 + historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True) + + msgs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': history.get("time") + }, + { + 'component': 'td', + 'text': f"{history.get('appid')}{history.get('remark') if history.get('remark') else ''}" + }, + { + 'component': 'td', + 'text': history.get("userid") + }, + { + 'component': 'td', + 'text': history.get("title") + }, + { + 'component': 'td', + 'text': history.get("text") + } + ] + } for history in historys + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'time' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'appid' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'userid' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'title' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'text' + }, + ] + }, + { + 'component': 'tbody', + 'content': msgs + } + ] + } + ] + } + ] + } + ] + + @eventmanager.register(EventType.NoticeMessage) + def send(self, event): + """ + 消息转发 + """ + if not self._enabled or not self._wechat_token_pattern_confs: + logger.error("插件未启用或未配置微信配置") + return + + # 消息体 + data = event.event_data + channel = data.get("channel") + if channel and channel != MessageChannel.Wechat: + return + + title = data.get("title") + text = data.get("text") + image = data.get("image") + userid = data.get("userid") + + # 遍历配置 匹配正则 发送消息 + for wechat_appid in self._wechat_token_pattern_confs.keys(): + wechat_conf = self._wechat_token_pattern_confs.get(wechat_appid) + if not wechat_conf or not wechat_conf.get("pattern"): + continue + + # 匹配正则 + if re.search(wechat_conf.get("pattern"), title): + # 忽略userid + if self._ignore_userid and re.search(self._ignore_userid, title): + userid = None + else: + # 特定消息指定用户 + userid = self.__specify_userid(title=title, text=text, userid=userid) + + access_token = self.__flush_access_token(appid=wechat_appid) + if not access_token: + logger.error("未获取到有效token,请检查配置") + continue + + # 发送消息 + if image: + self.__send_image_message(title=title, text=text, image_url=image, userid=userid, + access_token=wechat_conf.get("access_token"), appid=wechat_appid) + else: + self.__send_message(title=title, text=text, userid=userid, + access_token=wechat_conf.get("access_token"), + appid=wechat_appid) + + # 发送额外消息 + # 开始下载 > userid > {name} 后台下载任务已提交,请耐心等候入库通知。 > appid + # 已添加订阅 > userid > {name} 电视剧正在更新,已添加订阅,待更新后自动下载。 > appid + if wechat_conf.get("extra_confs"): + self.__send_extra_msg(wechat_appid=wechat_appid, + extra_confs=wechat_conf.get("extra_confs"), + access_token=wechat_conf.get("access_token"), + title=title, + text=text) + + def __specify_userid(self, title, text, userid): + """ + 特定消息指定用户 + """ + if self._specify_confs: + for specify_conf in self._specify_confs.split("\n"): + if not specify_conf: + continue + # 跳过注释 + if str(specify_conf).startswith("#"): + continue + specify = specify_conf.split(" > ") + if len(specify) != 3: + continue + if re.search(specify[0], title) and (re.search(specify[1], text) or re.search(specify[1], title)): + userid = specify[2] + logger.info(f"消息 {title} {text} 指定用户 {userid}") + break + + return userid + + def __send_extra_msg(self, wechat_appid, extra_confs, access_token, title, text): + """ + 根据自定义规则发送额外消息 + """ + self._extra_msg_history = self.get_data(key="extra_msg") or {} + is_save_history = False + for extra_conf in extra_confs: + if not extra_conf: + continue + + extra_pattern = extra_conf.get("pattern") + extra_userid = extra_conf.get("userid") + extra_msg = extra_conf.get("msg") + + # 正则匹配额外消息表达式 + if re.search(extra_pattern, title): + logger.info(f"{title} 正则匹配到额外消息 {extra_pattern}") + + # 处理变量{name} + if str(extra_msg).find('{name}') != -1: + extra_msg = extra_msg.replace('{name}', self.__parse_tv_title(title)) + + # 订阅完成消息单独处理 + if "已完成订阅" in str(title): + # 查订阅历史的用户 + subscribes = SubscribeHistory().list() + # 倒叙 + subscribes = sorted(subscribes, key=lambda x: x.id, reverse=True) + for subscribe in subscribes: + # 匹配订阅title + if f"{subscribe.name} ({subscribe.year}) 已完成订阅" == title \ + or f"{subscribe.name} ({subscribe.year}) S{str(subscribe.season).rjust(2, '0')} 已完成订阅" == title: + user_id = subscribe.username + logger.info(f"{title} 获取到订阅用户 {user_id}") + if user_id and any(user_id == user for user in extra_userid.split(",")): + logger.info(f"{title} 消息用户 {user_id} 匹配到目标用户 {extra_userid}") + self.__send_image_message(title=title, + text=extra_msg, + userid=user_id, + access_token=access_token, + appid=wechat_appid, + image_url=subscribe.backdrop) + logger.info(f"{wechat_appid} 发送额外消息 {extra_msg} 成功") + break + else: + # 搜索消息,获取消息text中的用户 + result = re.search(r"用户:(.*?)\n", text) + if not result: + # 订阅消息,获取消息text中的用户 + pattern = r"来自用户:(.*?)$" + result = re.search(pattern, text) + if not result: + logger.error(f"{title} 未获取到用户,跳过处理") + continue + + # 获取消息text中的用户 + user_id = result.group(1) + logger.info(f"{title} 获取到消息用户 {user_id}") + if user_id and any(user_id == user for user in extra_userid.split(",")): + if "开始下载" in str(title): + # 判断是否重复发送,10分钟内重复消息title、重复userid算重复消息 + extra_history_time = self._extra_msg_history.get( + f"{user_id}-{self.__parse_tv_title(title)}") or None + # 只处理下载消息 + if extra_history_time: + logger.info( + f"{title} 获取到额外消息上次发送时间 {datetime.strptime(extra_history_time, '%Y-%m-%d %H:%M:%S')}") + if (datetime.now() - datetime.strptime(extra_history_time, + '%Y-%m-%d %H:%M:%S')).total_seconds() < 600: + logger.warn( + f"{title} 额外消息 {self.__parse_tv_title(title)} 十分钟内重复发送,跳过。") + continue + # 判断当前用户是否订阅,是否订阅后续消息 + subscribes = SubscribeOper().list_by_username(username=str(user_id), + state="R", + mtype=MediaType.TV.value) + is_subscribe = False + for subscribe in subscribes: + # 匹配订阅title + if f"{subscribe.name} ({subscribe.year})" in title: + is_subscribe = True + break + + # 电视剧之前该用户订阅下载过,不再发送额外消息 + if is_subscribe: + logger.warn( + f"{title} 额外消息 {self.__parse_tv_title(title)} 用户 {user_id} 已订阅,不再发送额外消息。") + continue + + logger.info(f"{title} 消息用户 {user_id} 匹配到目标用户 {extra_userid}") + + self.__send_message(title=extra_msg, + userid=user_id, + access_token=access_token, + appid=wechat_appid) + logger.info(f"{title} {wechat_appid} 发送额外消息 {extra_msg} 成功") + # 保存已发送消息 + if "开始下载" in str(title): + self._extra_msg_history[ + f"{user_id}-{self.__parse_tv_title(title)}"] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + is_save_history = True + + # 保存额外消息历史 + if is_save_history: + self.save_data(key="extra_msg", + value=self._extra_msg_history) + + def __parse_tv_title(self, title): + """ + 解析title标题 + """ + titles = title.split(" ") + _title = "" + for sub_title_str in titles: + # 电影 功夫熊猫 (2008) 开始下载 + # 电影 功夫熊猫 (2008) 已添加订阅 + # 电视剧 追风者 (2024) S01 E01-E04 开始下载 + # 电视剧 追风者 (2024) S01 已添加订阅 + # 电视剧 追风者 (2024) S01 已完成订阅 + if '开始下载' in sub_title_str: + continue + if '已添加订阅' in sub_title_str: + continue + if '已完成订阅' in sub_title_str: + continue + _title += f"{sub_title_str} " + return self.__convert_season_episode(str(_title.rstrip())) + + @staticmethod + def __convert_season_episode(text): + season_pattern = re.compile(r'S(\d+)') + episode_pattern = re.compile(r'E(\d+)') + + def replace_season(match): + return f'第{int(match.group(1)):,}季' + + def replace_episode(match): + return f'第{int(match.group(1)):,}集' + + def convert_episode_range(text): + pattern = re.compile(r'E(\d+)-E(\d+)') + result = pattern.sub(lambda x: f'第{int(x.group(1)):02d}-{int(x.group(2)):02d}集', text) + return result + + text = re.sub(season_pattern, replace_season, text) + + if text.count("-") == 1: + text = convert_episode_range(text) + else: + text = re.sub(episode_pattern, replace_episode, text) + + return text + + def __flush_access_token(self, appid: int, force: bool = False): + """ + 获取appid wechat token + """ + wechat_confs = self._wechat_token_pattern_confs[appid] + if not wechat_confs: + logger.error(f"未获取到 {appid} 配置信息,请检查配置") + return None + + access_token = wechat_confs.get("access_token") + expires_in = wechat_confs.get("expires_in") + access_token_time = wechat_confs.get("access_token_time") + corpid = wechat_confs.get("corpid") + appsecret = wechat_confs.get("appsecret") + + # 判断token有效期 + if force or (datetime.now() - datetime.strptime(access_token_time, '%Y-%m-%d %H:%M:%S')).seconds >= expires_in: + # 重新获取token + access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, + appsecret=appsecret) + + if not access_token: + logger.error(f"WeChat配置 {appid} 获取token失败,请检查配置") + return None + + # 更新token回配置 + wechat_confs.update({ + "access_token": access_token, + "expires_in": expires_in, + "access_token_time": access_token_time, + }) + self._wechat_token_pattern_confs[appid] = wechat_confs + # 更新回库 + self.__save_wechat_confs() + + return access_token + + def __send_message(self, title: str, text: str = None, userid: str = None, + access_token: str = None, appid: int = None) -> Optional[bool]: + """ + 发送文本消息 + :param title: 消息标题 + :param text: 消息内容 + :param userid: 消息发送对象的ID,为空则发给所有人 + :return: 发送状态,错误信息 + """ + if text: + conent = "%s\n%s" % (title, text.replace("\n\n", "\n")) + else: + conent = title + + if not userid: + userid = "@all" + req_json = { + "touser": userid, + "msgtype": "text", + "agentid": appid, + "text": { + "content": conent + }, + "safe": 0, + "enable_id_trans": 0, + "enable_duplicate_check": 0 + } + return self.__post_request(access_token=access_token, req_json=req_json, appid=appid, title=title, text=text, + userid=userid) + + def __send_image_message(self, title: str, image_url: str, text: str = None, userid: str = None, + access_token: str = None, appid: int = None) -> Optional[bool]: + """ + 发送图文消息 + :param title: 消息标题 + :param text: 消息内容 + :param image_url: 图片地址 + :param userid: 消息发送对象的ID,为空则发给所有人 + :return: 发送状态,错误信息 + """ + if text: + text = text.replace("\n\n", "\n") + if not userid: + userid = "@all" + req_json = { + "touser": userid, + "msgtype": "news", + "agentid": appid, + "news": { + "articles": [ + { + "title": title, + "description": text, + "picurl": image_url, + "url": '' + } + ] + } + } + return self.__post_request(access_token=access_token, req_json=req_json, appid=appid, title=title, text=text, + userid=userid) + + def __post_request(self, access_token: str, req_json: dict, appid: int, title: str, retry: int = 0, + text: str = None, userid: str = None) -> bool: + message_url = self._send_msg_url % access_token + """ + 向微信发送请求 + """ + try: + res = RequestUtils(content_type='application/json').post( + message_url, + data=json.dumps(req_json, ensure_ascii=False).encode('utf-8') + ) + if res and res.status_code == 200: + ret_json = res.json() + if ret_json.get('errcode') == 0: + logger.info(f"转发 配置 {appid} 消息 {title} {req_json} 成功") + # 读取历史记录 + history = self.get_data('history') or [] + history.append({ + "appid": appid, + "remark": f"({self._wechat_token_pattern_confs.get(appid).get('remark')})" if self._wechat_token_pattern_confs.get( + appid).get('remark') else "", + "title": title, + "text": text, + "userid": userid, + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + thirty_days_ago = time.time() - int(self._history_days) * 24 * 60 * 60 + history = [record for record in history if + datetime.strptime(record["time"], + '%Y-%m-%d %H:%M:%S').timestamp() >= thirty_days_ago] + # 保存历史 + self.save_data(key="history", value=history) + return True + else: + if ret_json.get('errcode') == 81013: + return False + + logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 失败,错误信息:{ret_json}") + if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014: + logger.info("token已过期,正在重新刷新token重试") + # 重新获取token + access_token = self.__flush_access_token(appid=appid, + force=True) + if access_token: + retry += 1 + # 重发请求 + if retry <= 3: + return self.__post_request(access_token=access_token, + req_json=req_json, + appid=appid, + title=title, + retry=retry, + text=text, + userid=userid) + return False + elif res is not None: + logger.error( + f"转发 配置 {appid} 消息 {title} {req_json} 失败,错误码:{res.status_code},错误原因:{res.reason}") + return False + else: + logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 失败,未获取到返回信息") + return False + except Exception as err: + logger.error(f"转发 配置 {appid} 消息 {title} {req_json} 异常,错误信息:{str(err)}") + return False + + def __get_access_token(self, corpid: str, appsecret: str): + """ + 获取微信Token + :return: 微信Token + """ + try: + token_url = self._token_url % (corpid, appsecret) + res = RequestUtils().get_res(token_url) + if res: + ret_json = res.json() + if ret_json.get('errcode') == 0: + access_token = ret_json.get('access_token') + expires_in = ret_json.get('expires_in') + access_token_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + return access_token, expires_in, access_token_time + else: + logger.error(f"{ret_json.get('errmsg')}") + return None, None, None + else: + logger.error(f"{corpid} {appsecret} 获取token失败") + return None, None, None + except Exception as e: + logger.error(f"获取微信access_token失败,错误信息:{str(e)}") + return None, None, None + + def stop_service(self): + """ + 退出插件 + """ + pass + + +if __name__ == '__main__': + def __parse_tv_title(title): + """ + 解析title标题 + """ + titles = title.split(" ") + _title = "" + for sub_title_str in titles: + # 电影 功夫熊猫 (2008) 开始下载 + # 电影 功夫熊猫 (2008) 已添加订阅 + # 电视剧 追风者 (2024) S01 E01-E04 开始下载 + # 电视剧 追风者 (2024) S01 已添加订阅 + if '开始下载' in sub_title_str: + continue + if '已添加订阅' in sub_title_str: + continue + _title += f"{sub_title_str} " + return __convert_season_episode(str(_title.rstrip())) + + + def __convert_season_episode(text): + season_pattern = re.compile(r'S(\d+)') + episode_pattern = re.compile(r'E(\d+)') + + def replace_season(match): + return f'第{int(match.group(1)):,}季' + + def replace_episode(match): + return f'第{int(match.group(1)):,}集' + + def convert_episode_range(text): + pattern = re.compile(r'E(\d+)-E(\d+)') + result = pattern.sub(lambda x: f'第{int(x.group(1)):02d}-{int(x.group(2)):02d}集', text) + return result + + text = re.sub(season_pattern, replace_season, text) + if text.count("-") == 1: + text = convert_episode_range(text) + else: + text = re.sub(episode_pattern, replace_episode, text) + + return text + + + print(__parse_tv_title("时光代理人 (2021) S02 E01-E22 开始下载"))