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

", "").replace("

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