Compare commits

...

140 Commits

Author SHA1 Message Date
jxxghp
cfe113f6c3 fix bug 2023-09-28 22:16:21 +08:00
jxxghp
83500128c9 Merge pull request #698 from song-zhou/main
修复通知emby时libraryId错误bug
2023-09-28 22:09:32 +08:00
songzhou
2bff3a80da 修复通知emby时libraryId错误bug 2023-09-28 22:05:43 +08:00
jxxghp
3dd7b33f3e fix bug 2023-09-28 21:37:57 +08:00
jxxghp
8de487b0bf fix bug 2023-09-28 21:27:39 +08:00
jxxghp
ce88a6818f fix #693 2023-09-28 21:18:40 +08:00
jxxghp
6172832f41 fix 图片下载重试 2023-09-28 21:13:40 +08:00
jxxghp
a0ed228f4b fix 演员头像&中文名 2023-09-28 21:11:08 +08:00
jxxghp
01fd56a019 feat 演职人员优先使用TMDB中的中文名 2023-09-28 20:24:47 +08:00
jxxghp
087fcd340a fix #692 2023-09-28 20:06:03 +08:00
jxxghp
b3b09f3c03 Merge pull request #692 from DDS-Derek/main 2023-09-28 20:04:30 +08:00
DDSRem
11d17bf21a fix: https://github.com/jxxghp/MoviePilot/pull/654 2023-09-28 19:57:28 +08:00
jxxghp
b1ee80edee fix themoivedb timeout 2023-09-28 19:08:34 +08:00
jxxghp
107d496adb v1.2.7 2023-09-28 17:43:34 +08:00
jxxghp
9f1112b58d fix 2023-09-28 17:41:48 +08:00
jxxghp
989d6e3fe7 fix 2023-09-28 17:29:21 +08:00
jxxghp
3999c64853 add PersonMeta 2023-09-28 17:11:55 +08:00
jxxghp
760e3d6de0 更新 __init__.py 2023-09-28 16:32:56 +08:00
jxxghp
02111a3b9f fix #684 2023-09-28 16:23:10 +08:00
jxxghp
e6af2c0f34 fix 2023-09-28 16:14:52 +08:00
jxxghp
bd4c639761 Merge pull request #688 from thsrite/main
feat 定时清理媒体库插件
2023-09-28 15:46:13 +08:00
thsrite
d39b7ec021 fix 2023-09-28 15:40:13 +08:00
thsrite
63ca5f5017 fix 下载进度推送逻辑 2023-09-28 15:32:07 +08:00
thsrite
2202cf457b fix 2023-09-28 15:25:04 +08:00
thsrite
5d04b7abd6 feat 定时清理媒体库插件 2023-09-28 15:21:01 +08:00
jxxghp
0588d5d5f3 fix get_location 2023-09-28 14:49:54 +08:00
jxxghp
5a59e443d7 fix 2023-09-28 14:43:08 +08:00
jxxghp
470f4df979 fix #669 2023-09-28 14:32:34 +08:00
jxxghp
84bda71330 fix #657 2023-09-28 14:16:27 +08:00
jxxghp
ea883255cb fix #685 添加resourceType资源类型 2023-09-28 13:45:06 +08:00
jxxghp
e9abb69fb5 fix 2023-09-28 12:52:32 +08:00
jxxghp
ff63390794 Merge pull request #686 from thsrite/main 2023-09-28 12:39:12 +08:00
jxxghp
78b3135276 feat 媒体文件同步删除插件:支持手动删除源文件同步处理下载任务 2023-09-28 12:35:41 +08:00
thsrite
15bd2c09ed fix 2023-09-28 12:28:24 +08:00
thsrite
34d44857e4 fix messageforward 2023-09-28 12:11:39 +08:00
thsrite
dccded2d3e fix 下载消息增加用户 2023-09-28 12:03:18 +08:00
thsrite
295cafc060 fix 2023-09-28 11:56:13 +08:00
thsrite
c792e97f67 fix 下载进度增加识别名 2023-09-28 11:41:30 +08:00
thsrite
d30a02987d feat 正在下载进度推送插件 2023-09-28 11:10:34 +08:00
jxxghp
84d4c9cf73 feat 重命名支持episode_title集标题 2023-09-28 10:58:31 +08:00
jxxghp
21ecd1f708 fix #673 2023-09-28 08:34:34 +08:00
jxxghp
248b9a8e8c fix #663 2023-09-28 08:24:39 +08:00
jxxghp
3c7abfada6 fix #677 2023-09-28 08:14:22 +08:00
jxxghp
f363656e0a Merge remote-tracking branch 'origin/main' 2023-09-28 08:09:01 +08:00
jxxghp
e9ee9dbce1 fix #676 2023-09-28 08:08:55 +08:00
jxxghp
ab0b8653ab Merge pull request #674 from developer-wlj/wlj0909 2023-09-27 18:12:10 +08:00
developer-wlj
20711e17fb Merge branch 'jxxghp:main' into wlj0909 2023-09-27 18:06:51 +08:00
mayun110
a89bd8b816 Merge remote-tracking branch 'origin/wlj0909' into wlj0909 2023-09-27 18:05:46 +08:00
mayun110
3692cfea64 fix 无法匹配国语标签的bug 2023-09-27 15:38:35 +08:00
jxxghp
81d9d39029 fix bug 2023-09-27 14:12:11 +08:00
jxxghp
f5a61ceff1 fix bug 2023-09-27 13:40:35 +08:00
jxxghp
404a7b8337 fix bug 2023-09-27 11:14:56 +08:00
jxxghp
71ce3a2920 v1.2.6 2023-09-27 10:19:37 +08:00
jxxghp
3a27656769 fix #557 2023-09-27 10:18:40 +08:00
jxxghp
27b1e0ffd5 fix #668 rollback #654 2023-09-27 09:47:56 +08:00
jxxghp
1401ea74dd fix #667 硬链接支持极空间 2023-09-27 08:22:32 +08:00
jxxghp
cb93a63970 feat 历史记录支持重新识别 2023-09-27 08:16:26 +08:00
jxxghp
da4ff99570 fix #655 2023-09-25 08:40:19 +08:00
jxxghp
b3c0dc813b fix #662 2023-09-25 07:12:36 +08:00
jxxghp
a7b51d9fcc fix bug 2023-09-24 19:48:03 +08:00
jxxghp
76f1de42a8 v1.2.5 2023-09-24 19:33:25 +08:00
jxxghp
bad016b2b4 rollback mteam 2023-09-24 19:29:24 +08:00
jxxghp
5cd48d5447 fix 优化定时服务调度 2023-09-24 12:41:59 +08:00
jxxghp
41ff5363ea Merge remote-tracking branch 'origin/main' 2023-09-24 11:14:00 +08:00
jxxghp
85014f4acb feat 服务手动触发 2023-09-24 11:13:49 +08:00
jxxghp
d9a68daddd Merge pull request #658 from WithdewHua/fix-torrentremover 2023-09-24 08:02:45 +08:00
WithdewHua
141e78f274 fix: 种子分类为空时被删除 2023-09-24 02:58:24 +08:00
jxxghp
de98ccd33c fix mteam、zhuque登录判定 2023-09-23 21:42:21 +08:00
jxxghp
d490dadfdd fix mteam 2023-09-23 16:35:27 +08:00
jxxghp
f46bbf73ba Merge pull request #654 from DDS-Derek/main
fix: container id retrieval error
2023-09-23 16:21:05 +08:00
jxxghp
17eba86f7a fix mteam 2023-09-23 16:20:08 +08:00
DDSRem
fdf25b8c66 fix: container id retrieval error 2023-09-23 16:04:25 +08:00
jxxghp
516cb443b9 fix mteam 2023-09-23 15:58:42 +08:00
jxxghp
7c4c3b3f9a feat 支持新版本mteam 2023-09-23 12:30:19 +08:00
jxxghp
e298a1a8a0 feat 支持新版本mteam 2023-09-23 12:02:04 +08:00
jxxghp
fd9eef2089 feat 支持多媒体服务器同时使用 2023-09-23 09:20:51 +08:00
jxxghp
78dab04c96 fix #650 2023-09-23 08:33:49 +08:00
jxxghp
c34475653f Merge pull request #652 from WithdewHua/fix-torrentremover 2023-09-22 22:45:17 +08:00
WithdewHua
eb6a6eee0a fix: 种子分类为空时被删除 2023-09-22 21:27:48 +08:00
jxxghp
48f6a45194 v1.2.4 2023-09-22 16:06:00 +08:00
jxxghp
c8ae6bcc78 fix message format 2023-09-22 16:04:04 +08:00
jxxghp
7f6beb2a78 feat SynologyChat 2023-09-22 15:40:23 +08:00
jxxghp
ea160afd90 fix CronTrigger.from_crontab异常捕捉 2023-09-22 14:42:11 +08:00
jxxghp
29df0813fd fix 屏蔽telebot的trackback日志 2023-09-22 14:37:10 +08:00
jxxghp
b014c4a4e5 fix #646 2023-09-22 14:26:46 +08:00
jxxghp
f173c21695 更新 telegram.py 2023-09-22 13:04:20 +08:00
jxxghp
dc41f4946a fix bug 2023-09-22 12:52:40 +08:00
jxxghp
fed754f03a fix memory 2023-09-22 11:42:34 +08:00
jxxghp
382d9ed525 Merge remote-tracking branch 'origin/main' 2023-09-22 11:33:32 +08:00
jxxghp
e3707f39bb fix wallpaper 2023-09-22 11:33:25 +08:00
jxxghp
9df8d3d360 fix bug 2023-09-22 11:20:12 +08:00
jxxghp
5b3c310cda Merge pull request #643 from thsrite/main 2023-09-22 11:01:16 +08:00
jxxghp
79d692771e Merge remote-tracking branch 'origin/main' 2023-09-22 10:59:28 +08:00
jxxghp
f74ffed3ae fix #628 2023-09-22 10:59:19 +08:00
thsrite
0325d7f4f1 fix 优化删除代码 2023-09-22 10:30:04 +08:00
jxxghp
3926298907 Merge pull request #642 from developer-wlj/wlj0909 2023-09-22 09:46:27 +08:00
mayun110
d98376b490 filter_torrents_by_default_rule方法 添加参数和返回值声明 2023-09-22 09:45:23 +08:00
mayun110
219690afc0 fix 在搜索模式中 默认过滤规则无效问题 2023-09-22 09:10:58 +08:00
jxxghp
bcb1fc1600 fix memory 2023-09-21 23:12:06 +08:00
jxxghp
923be7e1e9 feat 历史记录删除支持删除源文件 2023-09-21 19:59:29 +08:00
jxxghp
951353ee0b Merge pull request #634 from thsrite/main 2023-09-21 12:34:17 +08:00
thsrite
52bdfa7f9a feat 媒体服务器同步黑名单 2023-09-21 12:08:09 +08:00
jxxghp
4af29aa76d Merge pull request #632 from Sowevo/main 2023-09-21 10:04:48 +08:00
Sowevo
8efa6a742b Merge branch 'jxxghp:main' into main 2023-09-20 21:02:31 -05:00
sowevo
ada5e1cca5 feat: plex更精准的媒体库刷新 2023-09-21 10:01:48 +08:00
jxxghp
859191203f Merge pull request #630 from thsrite/main 2023-09-21 09:09:36 +08:00
thsrite
cab4055315 fix #629 2023-09-21 09:08:53 +08:00
jxxghp
cacee7abfe - 修复删除媒体库文件时范围过大的问题,v1.2.3版本需要升级! 2023-09-20 16:26:46 +08:00
jxxghp
61694f4c2b Merge pull request #626 from thsrite/main 2023-09-20 16:14:38 +08:00
thsrite
9c328e3d1c fix #625 2023-09-20 16:11:53 +08:00
jxxghp
b2fe86c744 v1.2.3
- 优先级规则现可以按订阅和搜索分别设置
- 中文字幕过滤规则只针对原语种为非中文生效
2023-09-20 06:52:31 +08:00
jxxghp
600e32d3e4 更新 __init__.py 2023-09-19 23:29:35 +08:00
jxxghp
3ad733bab4 Merge remote-tracking branch 'origin/main' 2023-09-19 21:40:52 +08:00
jxxghp
1799b63abb feat 优先级规则按订阅和搜索拆分 2023-09-19 21:40:36 +08:00
jxxghp
d71dc13e32 Merge pull request #621 from developer-wlj/wlj0909 2023-09-19 18:21:47 +08:00
mayun110
f4633788e9 Merge remote-tracking branch 'origin/wlj0909' into wlj0909 2023-09-19 18:14:47 +08:00
jxxghp
2250e7db39 Merge remote-tracking branch 'origin/main' 2023-09-19 17:15:26 +08:00
jxxghp
b1bb0ced7a fix #608 2023-09-19 17:15:16 +08:00
jxxghp
28aecd79c6 Merge pull request #612 from thsrite/main
fix #553 修复unraid删除资源慢的问题
2023-09-19 17:08:24 +08:00
thsrite
d097ef45eb fix 当前路径下没有媒体文件则删除 2023-09-19 16:44:20 +08:00
thsrite
dac718edc8 fix 7a5d2101 2023-09-19 16:15:05 +08:00
mayun110
598ab23a2c 优化Windows下Cloudflare IP优选插件 2023-09-19 13:39:41 +08:00
jxxghp
8be6e28933 feat 中文字幕过滤规则只针对原语种为非中文 2023-09-19 12:42:10 +08:00
mayun110
bd6805be58 优化Windows下Cloudflare IP优选插件 2023-09-19 11:45:06 +08:00
thsrite
c147d36cb2 fix 资源下载msg增加下载用户 2023-09-19 11:15:14 +08:00
thsrite
7a5d210167 fix #553 修复unraid删除资源慢的问题 2023-09-19 09:17:48 +08:00
mayun110
ef335f2b8e Cloudflare IP优选新增windows支持 2023-09-19 00:02:59 +08:00
jxxghp
19eca11d17 Merge pull request #616 from thsrite/fix 2023-09-18 18:33:42 +08:00
thsrite
ab99bd356a fix iyuuautoseed 2023-09-18 18:32:19 +08:00
jxxghp
70f2d72532 Merge pull request #615 from thsrite/fix 2023-09-18 18:29:43 +08:00
thsrite
0ca995da0f fix #613 2023-09-18 18:25:52 +08:00
jxxghp
2a67abe62d v1.2.2
- 修复了RSS模式指定订阅站点时不刷新订阅的问题
- 推荐页面后退时会记住浏览位置
- 订阅及搜索支持设置全局包含和排除规则
2023-09-18 17:13:51 +08:00
jxxghp
03a07ac7bf fix RSS模式指定订阅站点时不刷新订阅的问题 2023-09-18 17:05:08 +08:00
jxxghp
f104c903ec Merge pull request #611 from thsrite/main 2023-09-18 11:38:00 +08:00
thsrite
6b74a8e266 fix 插件站点排序、删除 2023-09-18 10:30:28 +08:00
thsrite
cadd885dbf fix #592 2023-09-18 10:29:27 +08:00
jxxghp
7e0cad8491 fix 2023-09-17 19:49:21 +08:00
jxxghp
4c05e9fb2b Merge pull request #609 from WithdewHua/subscribe 2023-09-17 18:59:42 +08:00
WithdewHua
42311f0118 feat: 订阅搜索支持默认包含与排除规则 2023-09-17 18:35:31 +08:00
WithdewHua
951be74a21 fix: 函数命名 2023-09-17 18:35:31 +08:00
91 changed files with 3749 additions and 1288 deletions

View File

@@ -51,6 +51,7 @@ docker pull jxxghp/moviepilot:latest
- **PGID**:运行程序用户的`gid`,默认`0`
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`
- **NGINX_PORT** WEB服务端口默认`3000`可自行修改不能与API服务端口冲突
- **PORT** API服务端口默认`3001`可自行修改不能与WEB服务端口冲突
- **SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
@@ -84,7 +85,7 @@ docker pull jxxghp/moviepilot:latest
- **SUBSCRIBE_MODE** 订阅模式,`rss`/`spider`,默认`spider``rss`模式通过定时刷新RSS来匹配订阅RSS地址会自动获取也可手动维护对站点压力小同时可设置订阅刷新周期24小时运行但订阅和下载通知不能过滤和显示免费推荐使用rss模式。
- **SUBSCRIBE_RSS_INTERVAL** RSS订阅模式刷新时间间隔分钟默认`30`分钟不能小于5分钟。
- **SUBSCRIBE_SEARCH** 订阅搜索,`true`/`false`,默认`false`开启后会每隔24小时对所有订阅进行全量搜索以补齐缺失剧集一般情况下正常订阅即可订阅搜索只做为兜底会增加站点压力不建议开启
- **MESSAGER** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
- **MESSAGER** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
- `wechat`设置项:
@@ -108,6 +109,11 @@ docker pull jxxghp/moviepilot:latest
- **SLACK_OAUTH_TOKEN** Slack Bot User OAuth Token
- **SLACK_APP_TOKEN** Slack App-Level Token
- **SLACK_CHANNEL** Slack 频道名称,默认`全体`
- `synologychat`设置项:
- **SYNOLOGYCHAT_WEBHOOK** 在Synology Chat中创建机器人获取机器人`传入URL`
- **SYNOLOGYCHAT_TOKEN** SynologyChat机器人`令牌`
- **DOWNLOADER** 下载器,支持`qbittorrent`/`transmission`QB版本号要求>= 4.3.9TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
@@ -127,7 +133,7 @@ docker pull jxxghp/moviepilot:latest
- **DOWNLOADER_MONITOR** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
- **MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
- **MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
- `emby`设置项:
@@ -145,6 +151,7 @@ docker pull jxxghp/moviepilot:latest
- **PLEX_TOKEN** Plex网页Url中的`X-Plex-Token`通过浏览器F12->网络从请求URL中获取
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
### 2. **用户认证**
@@ -182,10 +189,11 @@ docker pull jxxghp/moviepilot:latest
> `original_title` 原语种标题
> `name` 识别名称
> `year` 年份
> `edition` 版本
> `resourceType`:资源类型
> `effect`:特效
> `edition` 版本(资源类型+特效)
> `videoFormat` 分辨率
> `releaseGroup` 制作组/字幕组
> `effect` 特效
> `videoCodec` 视频编码
> `audioCodec` 音频编码
> `tmdbid` TMDBID
@@ -206,6 +214,7 @@ docker pull jxxghp/moviepilot:latest
> `season` 季号
> `episode` 集号
> `season_episode` 季集 SxxExx
> `episode_title` 集标题
`TV_RENAME_FORMAT`默认配置格式:
@@ -228,9 +237,9 @@ docker pull jxxghp/moviepilot:latest
- 通过CookieCloud同步快速同步站点不需要使用的站点可在WEB管理界面中禁用无法同步的站点可手动新增。
- 通过WEB进行管理将WEB添加到手机桌面获得类App使用效果管理界面端口`3000`后台API端口`3001`
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
- 通过微信/Telegram/Slack远程管理其中微信/Telegram将会自动添加操作菜单微信菜单条数有限制部分菜单不显示微信需要在官方页面设置回调地址,地址相对路径为:`/api/v1/message/`
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单微信菜单条数有限制部分菜单不显示微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址地址相对路径为:`/api/v1/message/`
- 设置媒体服务器Webhook通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot``3001`端口),其中`moviepilot`为设置的`API_TOKEN`
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr`3001`端口可使用Overseerr/Jellyseerr浏览订阅。
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr`API服务端口`可使用Overseerr/Jellyseerr浏览订阅。
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
**注意**

View File

@@ -27,4 +27,4 @@ def upgrade() -> None:
def downgrade() -> None:
pass
pass

View File

@@ -0,0 +1,40 @@
"""1.0.6
Revision ID: 232dfa044617
Revises: e734c7fe6056
Create Date: 2023-09-19 21:34:41.994617
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '232dfa044617'
down_revision = 'e734c7fe6056'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# 搜索优先级
op.execute("delete from systemconfig where key = 'SearchFilterRules';")
op.execute(
"insert into systemconfig(key, value) VALUES('SearchFilterRules', (select value from systemconfig where key= 'FilterRules'));")
# 订阅优先级
op.execute("delete from systemconfig where key = 'SubscribeFilterRules';")
op.execute(
"insert into systemconfig(key, value) VALUES('SubscribeFilterRules', (select value from systemconfig where key= 'FilterRules'));")
# 洗版优先级
op.execute("delete from systemconfig where key = 'BestVersionFilterRules';")
op.execute(
"insert into systemconfig(key, value) VALUES('BestVersionFilterRules', (select value from systemconfig where key= 'FilterRules2'));")
# 删除旧的优先级规则
op.execute("delete from systemconfig where key = 'FilterRules';")
op.execute("delete from systemconfig where key = 'FilterRules2';")
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -0,0 +1,29 @@
"""1.0.7
Revision ID: 30329639c12b
Revises: 232dfa044617
Create Date: 2023-09-23 08:25:59.776488
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30329639c12b'
down_revision = '232dfa044617'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.execute("delete from systemconfig where key = 'DefaultFilterRules';")
op.execute(
"insert into systemconfig(key, value) VALUES('DefaultFilterRules', (select value from systemconfig where key= 'DefaultIncludeExcludeFilter'));")
op.execute("delete from systemconfig where key = 'DefaultIncludeExcludeFilter';")
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -0,0 +1,30 @@
"""1_0_9
Revision ID: a521fbc28b18
Revises: b2f011d3a8b7
Create Date: 2023-09-28 13:37:16.479360
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a521fbc28b18'
down_revision = 'b2f011d3a8b7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
try:
with op.batch_alter_table("downloadhistory") as batch_op:
batch_op.add_column(sa.Column('date', sa.String, nullable=True))
batch_op.add_column(sa.Column('channel', sa.String, nullable=True))
except Exception as e:
pass
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -0,0 +1,29 @@
"""1_0_8
Revision ID: b2f011d3a8b7
Revises: 30329639c12b
Create Date: 2023-09-28 10:15:58.410003
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b2f011d3a8b7'
down_revision = '30329639c12b'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
try:
with op.batch_alter_table("downloadhistory") as batch_op:
batch_op.add_column(sa.Column('userid', sa.String, nullable=True))
except Exception as e:
pass
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any, List
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from requests import Session
@@ -11,9 +11,7 @@ from app.core.security import verify_token
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.scheduler import Scheduler
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
from app.utils.timer import TimerUtils
router = APIRouter()
@@ -24,14 +22,16 @@ def statistic(db: Session = Depends(get_db),
"""
查询媒体数量统计信息
"""
media_statistic = DashboardChain(db).media_statistic()
if media_statistic:
return schemas.Statistic(
movie_count=media_statistic.movie_count,
tv_count=media_statistic.tv_count,
episode_count=media_statistic.episode_count,
user_count=media_statistic.user_count
)
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain(db).media_statistic()
if media_statistics:
# 汇总各媒体库统计信息
ret_statistic = schemas.Statistic()
for media_statistic in media_statistics:
ret_statistic.movie_count += media_statistic.movie_count
ret_statistic.tv_count += media_statistic.tv_count
ret_statistic.episode_count += media_statistic.episode_count
ret_statistic.user_count += media_statistic.user_count
return ret_statistic
else:
return schemas.Statistic()
@@ -64,13 +64,16 @@ def downloader(db: Session = Depends(get_db),
"""
transfer_info = DashboardChain(db).downloader_info()
free_space = SystemUtils.free_space(Path(settings.DOWNLOAD_PATH))
return schemas.DownloaderInfo(
download_speed=transfer_info.download_speed,
upload_speed=transfer_info.upload_speed,
download_size=transfer_info.download_size,
upload_size=transfer_info.upload_size,
free_space=free_space
)
if transfer_info:
return schemas.DownloaderInfo(
download_speed=transfer_info.download_speed,
upload_speed=transfer_info.upload_speed,
download_size=transfer_info.download_size,
upload_size=transfer_info.upload_size,
free_space=free_space
)
else:
return schemas.DownloaderInfo()
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
@@ -78,37 +81,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询后台服务信息
"""
# 返回计时任务
schedulers = []
# 去重
added = []
jobs = Scheduler().list()
# 按照下次运行时间排序
jobs.sort(key=lambda x: x.next_run_time)
for job in jobs:
if job.name not in added:
added.append(job.name)
else:
continue
if not StringUtils.is_chinese(job.name):
continue
if not job.next_run_time:
status = "已停止"
next_run = ""
else:
next_run = TimerUtils.time_difference(job.next_run_time)
if not next_run:
status = "正在运行"
else:
status = "阻塞" if job.pending else "等待"
schedulers.append(schemas.ScheduleInfo(
id=job.id,
name=job.name,
status=status,
next_run=next_run
))
return schedulers
return Scheduler().list()
@router.get("/transfer", summary="文件整理统计", response_model=List[int])

View File

@@ -6,11 +6,13 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.transfer import TransferChain
from app.core.event import eventmanager
from app.core.security import verify_token
from app.db import get_db
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.schemas import MediaType
from app.schemas.types import EventType
router = APIRouter()
@@ -62,20 +64,29 @@ def transfer_history(title: str = None,
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
def delete_transfer_history(history_in: schemas.TransferHistory,
delete_file: bool = False,
deletesrc: bool = False,
deletedest: bool = False,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除转移历史记录
"""
# 触发删除事件
if delete_file:
history = TransferHistory.get(db, history_in.id)
if not history:
return schemas.Response(success=False, msg="记录不存在")
# 册除文件
if history.dest:
TransferChain(db).delete_files(Path(history.dest))
history = TransferHistory.get(db, history_in.id)
if not history:
return schemas.Response(success=False, msg="记录不存在")
# 册除媒体库文件
if deletedest and history.dest:
TransferChain(db).delete_files(Path(history.dest))
# 删除源文件
if deletesrc and history.src:
TransferChain(db).delete_files(Path(history.src))
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
{
"src": history.src
}
)
# 删除记录
TransferHistory.delete(db, history_in.id)
return schemas.Response(success=True)
@@ -83,15 +94,18 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
def redo_transfer_history(history_in: schemas.TransferHistory,
mtype: str,
new_tmdbid: int,
mtype: str = None,
new_tmdbid: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
历史记录重新转移
历史记录重新转移,不输入 mtype 和 new_tmdbid 时,自动使用文件名重新识别
"""
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
mtype=MediaType(mtype), tmdbid=new_tmdbid)
if mtype and new_tmdbid:
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
mtype=MediaType(mtype), tmdbid=new_tmdbid)
else:
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id)
if state:
return schemas.Response(success=True)
else:

View File

@@ -1,4 +1,3 @@
import random
from datetime import timedelta
from typing import Any
@@ -15,7 +14,7 @@ from app.core.security import get_password_hash
from app.db import get_db
from app.db.models.user import User
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.web import WebUtils
router = APIRouter()
@@ -67,21 +66,10 @@ def bing_wallpaper() -> Any:
"""
获取Bing每日壁纸
"""
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
try:
resp = RequestUtils(timeout=5).get_res(url)
except Exception as err:
print(str(err))
return schemas.Response(success=False)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
for image in result.get('images') or []:
return schemas.Response(success=False,
message=f"https://cn.bing.com{image.get('url')}" if 'url' in image else '')
except Exception as err:
print(str(err))
url = WebUtils.get_bing_wallpaper()
if url:
return schemas.Response(success=False,
message=url)
return schemas.Response(success=False)
@@ -90,14 +78,10 @@ def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
"""
获取TMDB电影海报
"""
infos = TmdbChain(db).tmdb_trending()
if infos:
# 随机一个电影
while True:
info = random.choice(infos)
if info and info.get("backdrop_path"):
return schemas.Response(
success=True,
message=f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
)
wallpager = TmdbChain(db).get_random_wallpager()
if wallpager:
return schemas.Response(
success=True,
message=wallpager
)
return schemas.Response(success=False)

View File

@@ -73,7 +73,9 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
if not switchs:
for noti in NotificationType:
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True))
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True))
else:
for switch in switchs:
return_list.append(NotificationSwitch(**switch))

View File

@@ -1,12 +1,10 @@
from typing import Any, List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.db import get_db
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey

View File

@@ -5,7 +5,6 @@ from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
from app import schemas
from app.chain.cookiecloud import CookieCloudChain
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.core.event import EventManager
@@ -15,19 +14,13 @@ from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.utils.string import StringUtils
router = APIRouter()
def start_cookiecloud_sync(db: Session):
"""
后台启动CookieCloud站点同步
"""
CookieCloudChain(db).process(manual=True)
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
def read_sites(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
@@ -38,7 +31,7 @@ def read_sites(db: Session = Depends(get_db),
@router.post("/", summary="新增站点", response_model=schemas.Response)
def update_site(
def add_site(
*,
db: Session = Depends(get_db),
site_in: schemas.Site,
@@ -101,12 +94,11 @@ def delete_site(
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
def cookie_cloud_sync(background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
运行CookieCloud同步站点信息
"""
background_tasks.add_task(start_cookiecloud_sync, db)
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
return schemas.Response(success=True, message="CookieCloud同步任务已启动")
@@ -119,7 +111,8 @@ def cookie_cloud_sync(db: Session = Depends(get_db),
Site.reset(db)
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
SystemConfigOper().set(SystemConfigKey.RssSites, [])
CookieCloudChain().process(manual=True)
# 启动定时服务
Scheduler().start("cookiecloud", manual=True)
# 插件站点删除
EventManager().send_event(EventType.SiteDeleted,
{
@@ -234,14 +227,14 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
获取站点列表
"""
# 选中的rss站点
rss_sites = SystemConfigOper().get(SystemConfigKey.RssSites)
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
# 所有站点
all_site = Site.list_order_by_pri(db)
if not rss_sites or not all_site:
if not selected_sites or not all_site:
return []
# 选中的rss站点
rss_sites = [site for site in all_site if site and site.id in rss_sites]
rss_sites = [site for site in all_site if site and site.id in selected_sites]
return rss_sites

View File

@@ -1,5 +1,5 @@
import json
from typing import List, Any, Optional
from typing import List, Any
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
from sqlalchemy.orm import Session
@@ -12,6 +12,7 @@ from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.db.models.user import User
from app.db.userauth import get_current_active_user
from app.scheduler import Scheduler
from app.schemas.types import MediaType
router = APIRouter()
@@ -26,13 +27,6 @@ def start_subscribe_add(db: Session, title: str, year: str,
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
def start_subscribe_search(db: Session, sid: Optional[int], state: Optional[str]):
"""
启动订阅搜索任务
"""
SubscribeChain(db).search(sid=sid, state=state, manual=True)
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
def read_subscribes(
db: Session = Depends(get_db),
@@ -94,7 +88,7 @@ def update_subscribe(
subscribe = Subscribe.get(db, subscribe_in.id)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
if subscribe_in.sites:
if subscribe_in.sites is not None:
subscribe_in.sites = json.dumps(subscribe_in.sites)
# 避免更新缺失集数
subscribe_dict = subscribe_in.dict()
@@ -140,35 +134,38 @@ def subscribe_mediaid(
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
def refresh_subscribes(
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
刷新所有订阅
"""
SubscribeChain(db).refresh()
Scheduler().start("subscribe_refresh")
return schemas.Response(success=True)
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
def check_subscribes(
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
刷新所有订阅
刷新订阅 TMDB 信息
"""
SubscribeChain(db).check()
Scheduler().start("subscribe_tmdb")
return schemas.Response(success=True)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
def search_subscribes(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
搜索所有订阅
"""
background_tasks.add_task(start_subscribe_search, db=db, sid=None, state='R')
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
sid=None,
state='R',
manual=True
)
return schemas.Response(success=True)
@@ -176,12 +173,17 @@ def search_subscribes(
def search_subscribe(
subscribe_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据订阅编号搜索订阅
"""
background_tasks.add_task(start_subscribe_search, db=db, sid=subscribe_id, state=None)
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
sid=subscribe_id,
state=None,
manual=True
)
return schemas.Response(success=True)

View File

@@ -1,9 +1,9 @@
import json
import time
import tailer
from datetime import datetime
from typing import Union
import tailer
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -16,6 +16,7 @@ from app.db import get_db
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
@@ -25,7 +26,7 @@ router = APIRouter()
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
def get_setting(_: schemas.TokenPayload = Depends(verify_token)):
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询系统环境变量,包括当前版本号
"""
@@ -83,7 +84,7 @@ def set_setting(key: str, value: Union[list, dict, str, int] = None,
@router.get("/message", summary="实时消息")
def get_progress(token: str):
def get_message(token: str):
"""
实时获取系统消息返回格式为SSE
"""
@@ -169,31 +170,33 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=False)
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
def ruletest(title: str,
subtitle: str = None,
ruletype: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)):
"""
过滤规则测试,规则类型 1-订阅2-洗版
过滤规则测试,规则类型 1-订阅2-洗版3-搜索
"""
torrent = schemas.TorrentInfo(
title=title,
description=subtitle,
)
if ruletype == "2":
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules2)
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
elif ruletype == "3":
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
else:
rule_string = SystemConfigOper().get(SystemConfigKey.FilterRules)
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
if not rule_string:
return schemas.Response(success=False, message="过滤规则未设置!")
return schemas.Response(success=False, message="优先级规则未设置!")
# 过滤
result = SearchChain(db).filter_torrents(rule_string=rule_string,
torrent_list=[torrent])
if not result:
return schemas.Response(success=False, message="不符合过滤规则!")
return schemas.Response(success=False, message="不符合优先级规则!")
return schemas.Response(success=True, data={
"priority": 100 - result[0].pri_order + 1
})
@@ -209,3 +212,15 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
# 执行重启
ret, msg = SystemUtils.restart()
return schemas.Response(success=ret, message=msg)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
def execute_command(jobid: str,
_: schemas.TokenPayload = Depends(verify_token)):
"""
执行命令
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
Scheduler().start(jobid)
return schemas.Response(success=True)

View File

@@ -684,7 +684,7 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
"monitored": True,
}],
year=subscribe.year,
remotePoster=subscribe.image,
remotePoster=subscribe.poster,
tmdbId=subscribe.tmdbid,
tvdbId=subscribe.tvdbid,
imdbId=subscribe.imdbid,

View File

@@ -18,7 +18,7 @@ from app.core.meta import MetaBase
from app.core.module import ModuleManager
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
WebhookEventInfo
WebhookEventInfo, TmdbEpisode
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
from app.utils.object import ObjectUtils
@@ -223,16 +223,19 @@ class ChainBase(metaclass=ABCMeta):
def filter_torrents(self, rule_string: str,
torrent_list: List[TorrentInfo],
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
season_episodes: Dict[int, list] = None,
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
"""
过滤种子资源
:param rule_string: 过滤规则
:param torrent_list: 资源列表
:param season_episodes: 季集数过滤 {season:[episodes]}
:param mediainfo: 识别的媒体信息
:return: 过滤后的资源列表,添加资源优先级
"""
return self.run_module("filter_torrents", rule_string=rule_string,
torrent_list=torrent_list, season_episodes=season_episodes)
torrent_list=torrent_list, season_episodes=season_episodes,
mediainfo=mediainfo)
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: str = None
@@ -271,7 +274,8 @@ class ChainBase(metaclass=ABCMeta):
return self.run_module("list_torrents", status=status, hashs=hashs)
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None) -> Optional[TransferInfo]:
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
"""
文件转移
:param path: 文件路径
@@ -279,10 +283,12 @@ class ChainBase(metaclass=ABCMeta):
:param mediainfo: 识别的媒体信息
:param transfer_type: 转移模式
:param target: 转移目标路径
:param episodes_info: 当前季的全部集信息
:return: {path, target_path, message}
"""
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
transfer_type=transfer_type, target=target)
transfer_type=transfer_type, target=target,
episodes_info=episodes_info)
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
"""
@@ -333,14 +339,14 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
return self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
def post_message(self, message: Notification) -> None:
"""
@@ -388,22 +394,22 @@ class ChainBase(metaclass=ABCMeta):
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
return self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
def register_commands(self, commands: Dict[str, dict]) -> None:
"""
注册菜单命令
"""
return self.run_module("register_commands", commands=commands)
self.run_module("register_commands", commands=commands)
def scheduler_job(self) -> None:
"""
定时任务每10分钟调用一次模块实现该接口以实现定时服务
"""
return self.run_module("scheduler_job")
self.run_module("scheduler_job")
def clear_cache(self) -> None:
"""
清理缓存,模块实现该接口响应清理缓存事件
"""
return self.run_module("clear_cache")
self.run_module("clear_cache")

View File

@@ -1,5 +1,5 @@
import base64
from typing import Tuple, Optional, Union
from typing import Tuple, Optional
from urllib.parse import urljoin
from lxml import etree
@@ -16,7 +16,6 @@ from app.helper.message import MessageHelper
from app.helper.rss import RssHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas import Notification, NotificationType, MessageChannel
from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
@@ -40,21 +39,6 @@ class CookieCloudChain(ChainBase):
password=settings.COOKIECLOUD_PASSWORD
)
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
"""
远程触发同步站点,发送消息
"""
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
title="开始同步CookieCloud站点 ...", userid=userid))
# 开始同步
success, msg = self.process()
if success:
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
title=f"同步站点成功,{msg}", userid=userid))
else:
self.post_message(Notification(channel=channel, mtype=NotificationType.SiteMessage,
title=f"同步站点失败:{msg}", userid=userid))
def process(self, manual=False) -> Tuple[bool, str]:
"""
通过CookieCloud同步站点Cookie

View File

@@ -1,3 +1,5 @@
from typing import Optional, List
from app import schemas
from app.chain import ChainBase
@@ -6,7 +8,7 @@ class DashboardChain(ChainBase):
"""
各类仪表板统计处理链
"""
def media_statistic(self) -> schemas.Statistic:
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
"""
媒体数量统计
"""

View File

@@ -1,4 +1,7 @@
import base64
import json
import re
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
@@ -15,6 +18,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -36,8 +40,10 @@ class DownloadChain(ChainBase):
发送添加下载的消息
"""
msg_text = ""
if userid:
msg_text = f"用户:{userid}"
if torrent.site_name:
msg_text = f"站点:{torrent.site_name}"
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
if meta.resource_term:
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
if torrent.size:
@@ -79,8 +85,68 @@ class DownloadChain(ChainBase):
下载种子文件,如果是磁力链,会返回磁力链接本身
:return: 种子路径,种子目录名,种子文件清单
"""
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
"""
获取下载链接, url格式[base64]url
"""
# 获取[]中的内容
m = re.search(r"\[(.*)](.*)", url)
if m:
# 参数
base64_str = m.group(1)
# URL
url = m.group(2)
if not base64_str:
return url
# 解码参数
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
req_params: Dict[str, dict] = json.loads(req_str)
if req_params.get('method') == 'get':
# GET请求
res = RequestUtils(
ua=ua,
cookies=cookie
).get_res(url, params=req_params.get('params'))
else:
# POST请求
res = RequestUtils(
ua=ua,
cookies=cookie
).post_res(url, params=req_params.get('params'))
if not res:
return None
if not req_params.get('result'):
return res.text
else:
data = res.json()
for key in str(req_params.get('result')).split("."):
data = data.get(key)
if not data:
return None
logger.info(f"获取到下载地址:{data}")
return data
return None
# 获取下载链接
if not torrent.enclosure:
return None, "", []
if torrent.enclosure.startswith("magnet:"):
return torrent.enclosure, "", []
if torrent.enclosure.startswith("["):
# 需要解码获取下载地址
torrent_url = __get_redict_url(url=torrent.enclosure,
ua=torrent.site_ua,
cookie=torrent.site_cookie)
else:
torrent_url = torrent.enclosure
if not torrent_url:
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}")
return None, "", []
# 下载种子文件
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
url=torrent.enclosure,
url=torrent_url,
cookie=torrent.site_cookie,
ua=torrent.site_ua,
proxy=torrent.site_proxy)
@@ -90,7 +156,7 @@ class DownloadChain(ChainBase):
return content, "", []
if not torrent_file:
logger.error(f"下载种子文件失败:{torrent.title} - {torrent.enclosure}")
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
self.post_message(Notification(
channel=channel,
mtype=NotificationType.Manual,
@@ -122,7 +188,9 @@ class DownloadChain(ChainBase):
_folder_name = ""
if not torrent_file:
# 下载种子文件,得到的可能是文件也可能是磁力链
content, _folder_name, _file_list = self.download_torrent(_torrent, userid=userid)
content, _folder_name, _file_list = self.download_torrent(_torrent,
channel=channel,
userid=userid)
if not content:
return
else:
@@ -201,7 +269,10 @@ class DownloadChain(ChainBase):
download_hash=_hash,
torrent_name=_torrent.title,
torrent_description=_torrent.description,
torrent_site=_torrent.site_name
torrent_site=_torrent.site_name,
userid=userid,
channel=channel.value if channel else None,
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
)
# 登记下载文件
@@ -225,7 +296,7 @@ class DownloadChain(ChainBase):
self.downloadhis.add_files(files_to_add)
# 发送消息
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel)
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件
@@ -253,12 +324,14 @@ class DownloadChain(ChainBase):
contexts: List[Context],
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
save_path: str = None,
channel: MessageChannel = None,
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
"""
根据缺失数据,自动种子列表中组合择优下载
:param contexts: 资源上下文列表
:param no_exists: 缺失的剧集信息
:param save_path: 保存路径
:param channel: 通知渠道
:param userid: 用户ID
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
"""
@@ -323,7 +396,8 @@ class DownloadChain(ChainBase):
# 如果是电影,直接下载
for context in contexts:
if context.media_info.type == MediaType.MOVIE:
if self.download_single(context, save_path=save_path, userid=userid):
if self.download_single(context, save_path=save_path,
channel=channel, userid=userid):
# 下载成功
downloaded_list.append(context)
@@ -390,11 +464,13 @@ class DownloadChain(ChainBase):
context=context,
torrent_file=content if isinstance(content, Path) else None,
save_path=save_path,
channel=channel,
userid=userid
)
else:
# 下载
download_id = self.download_single(context, save_path=save_path, userid=userid)
download_id = self.download_single(context, save_path=save_path,
channel=channel, userid=userid)
if download_id:
# 下载成功
@@ -452,7 +528,8 @@ class DownloadChain(ChainBase):
# 为需要集的子集则下载
if torrent_episodes.issubset(set(need_episodes)):
# 下载
download_id = self.download_single(context, save_path=save_path, userid=userid)
download_id = self.download_single(context, save_path=save_path,
channel=channel, userid=userid)
if download_id:
# 下载成功
downloaded_list.append(context)
@@ -508,7 +585,7 @@ class DownloadChain(ChainBase):
and len(meta.season_list) == 1 \
and meta.season_list[0] == need_season:
# 检查种子看是否有需要的集
content, _, torrent_files = self.download_torrent(torrent, userid=userid)
content, _, torrent_files = self.download_torrent(torrent)
if not content:
continue
if isinstance(content, str):
@@ -529,6 +606,7 @@ class DownloadChain(ChainBase):
torrent_file=content if isinstance(content, Path) else None,
episodes=selected_episodes,
save_path=save_path,
channel=channel,
userid=userid
)
if not download_id:

View File

@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from app.chain import ChainBase
from app.core.context import Context, MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.log import logger
from app.utils.string import StringUtils
@@ -38,12 +38,8 @@ class MediaChain(ChainBase):
"""
logger.info(f'开始识别媒体信息,文件:{path} ...')
file_path = Path(path)
# 上级目录元数据
dir_meta = MetaInfo(title=file_path.parent.name)
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=file_path.stem)
# 合并元数据
file_meta.merge(dir_meta)
# 元数据
file_meta = MetaInfoPath(file_path)
# 识别媒体信息
mediainfo = self.recognize_media(meta=file_meta)
if not mediainfo:

View File

@@ -10,7 +10,6 @@ from app.core.config import settings
from app.db import SessionFactory
from app.db.mediaserver_oper import MediaServerOper
from app.log import logger
from app.schemas import MessageChannel, Notification
lock = threading.Lock()
@@ -23,33 +22,23 @@ class MediaServerChain(ChainBase):
def __init__(self, db: Session = None):
super().__init__(db)
def librarys(self) -> List[schemas.MediaServerLibrary]:
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库
"""
return self.run_module("mediaserver_librarys")
return self.run_module("mediaserver_librarys", server=server)
def items(self, library_id: Union[str, int]) -> Generator:
def items(self, server: str, library_id: Union[str, int]) -> Generator:
"""
获取媒体服务器所有项目
"""
return self.run_module("mediaserver_items", library_id=library_id)
return self.run_module("mediaserver_items", server=server, library_id=library_id)
def episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取媒体服务器剧集信息
"""
return self.run_module("mediaserver_tv_episodes", item_id=item_id)
def remote_sync(self, channel: MessageChannel, userid: Union[int, str]):
"""
同步豆瓣想看数据,发送消息
"""
self.post_message(Notification(channel=channel,
title="开始媒体服务器 ...", userid=userid))
self.sync()
self.post_message(Notification(channel=channel,
title="同步媒体服务器完成!", userid=userid))
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def sync(self):
"""
@@ -59,37 +48,49 @@ class MediaServerChain(ChainBase):
# 媒体服务器同步使用独立的会话
_db = SessionFactory()
_dbOper = MediaServerOper(_db)
logger.info("开始同步媒体库数据 ...")
# 汇总统计
total_count = 0
# 清空登记薄
_dbOper.empty(server=settings.MEDIASERVER)
for library in self.librarys():
logger.info(f"正在同步媒体库 {library.name} ...")
library_count = 0
for item in self.items(library.id):
if not item:
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
# 设置的媒体服务器
if not settings.MEDIASERVER:
return
mediaservers = settings.MEDIASERVER.split(",")
# 遍历媒体服务器
for mediaserver in mediaservers:
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
for library in self.librarys(mediaserver):
# 同步黑名单 跳过
if library.name in sync_blacklist:
continue
if not item.item_id:
continue
# 计数
library_count += 1
seasoninfo = {}
# 类型
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
if item_type == "电视剧":
# 查询剧集信息
espisodes_info = self.episodes(item.item_id) or []
for episode in espisodes_info:
seasoninfo[episode.season] = episode.episodes
# 插入数据
item_dict = item.dict()
item_dict['seasoninfo'] = json.dumps(seasoninfo)
item_dict['item_type'] = item_type
_dbOper.add(**item_dict)
logger.info(f"媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加
total_count += library_count
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
library_count = 0
for item in self.items(mediaserver, library.id):
if not item:
continue
if not item.item_id:
continue
# 计数
library_count += 1
seasoninfo = {}
# 类型
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
if item_type == "电视剧":
# 查询剧集信息
espisodes_info = self.episodes(mediaserver, item.item_id) or []
for episode in espisodes_info:
seasoninfo[episode.season] = episode.episodes
# 插入数据
item_dict = item.dict()
item_dict['seasoninfo'] = json.dumps(seasoninfo)
item_dict['item_type'] = item_type
_dbOper.add(**item_dict)
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加
total_count += library_count
# 关闭数据库连接
if _db:
_db.close()

View File

@@ -187,7 +187,7 @@ class MessageChain(ChainBase):
# 下载种子
context: Context = cache_list[int(text) - 1]
# 下载
self.downloadchain.download_single(context, userid=userid)
self.downloadchain.download_single(context, userid=userid, channel=channel)
elif text.lower() == "p":
# 上一页
@@ -348,6 +348,7 @@ class MessageChain(ChainBase):
# 批量下载
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
no_exists=no_exists,
channel=channel,
userid=userid)
if downloads and not lefts:
# 全部下载完成

View File

@@ -1,4 +1,5 @@
import pickle
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict
@@ -80,7 +81,8 @@ class SearchChain(ChainBase):
keyword: str = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
sites: List[int] = None,
filter_rule: str = None,
priority_rule: str = None,
filter_rule: Dict[str, str] = None,
area: str = "title") -> List[Context]:
"""
根据媒体信息搜索种子资源精确匹配应用过滤规则同时根据no_exists过滤本地已存在的资源
@@ -88,6 +90,7 @@ class SearchChain(ChainBase):
:param keyword: 搜索关键词
:param no_exists: 缺失的媒体信息
:param sites: 站点ID列表为空时搜索所有站点
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
:param filter_rule: 过滤规则,为空是使用默认过滤规则
:param area: 搜索范围title or imdbid
"""
@@ -128,19 +131,26 @@ class SearchChain(ChainBase):
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return []
# 过滤种子
if filter_rule is None:
# 取默认过滤规则
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
if filter_rule:
logger.info(f'开始过滤资源,当前规则:{filter_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=filter_rule,
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
torrent_list=torrents,
season_episodes=season_episodes)
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
torrents = result
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤条件的资源')
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用默认过滤规则再次过滤
torrents = self.filter_torrents_by_rule(torrents=torrents,
filter_rule=filter_rule)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
# 匹配的资源
_match_torrents = []
# 总数
@@ -247,14 +257,14 @@ class SearchChain(ChainBase):
"""
# 未开启的站点不搜索
indexer_sites = []
# 配置的索引站点
if sites:
config_indexers = [str(sid) for sid in sites]
else:
config_indexers = [str(sid) for sid in self.systemconfig.get(SystemConfigKey.IndexerSites) or []]
if not sites:
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
for indexer in self.siteshelper.get_indexers():
# 检查站点索引开关
if not config_indexers or str(indexer.get("id")) in config_indexers:
if not sites or indexer.get("id") in sites:
# 站点流控
state, msg = self.siteshelper.check(indexer.get("domain"))
if state:
@@ -264,6 +274,7 @@ class SearchChain(ChainBase):
if not indexer_sites:
logger.warn('未开启任何有效站点,无法搜索资源')
return []
# 开始进度
self.progress.start(ProgressKey.Search)
# 开始计时
@@ -305,3 +316,44 @@ class SearchChain(ChainBase):
self.progress.end(ProgressKey.Search)
# 返回
return results
def filter_torrents_by_rule(self,
torrents: List[TorrentInfo],
filter_rule: Dict[str, str] = None
) -> List[TorrentInfo]:
"""
使用过滤规则过滤种子
:param torrents: 种子列表
:param filter_rule: 过滤规则
"""
# 取默认过滤规则
if not filter_rule:
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
if not filter_rule:
return torrents
# 包含
include = filter_rule.get("include")
# 排除
exclude = filter_rule.get("exclude")
def __filter_torrent(t: TorrentInfo) -> bool:
"""
过滤种子
"""
# 包含
if include:
if not re.search(r"%s" % include,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 不匹配包含规则 {include}")
return False
# 排除
if exclude:
if re.search(r"%s" % exclude,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 匹配排除规则 {exclude}")
return False
return True
# 使用默认过滤规则再次过滤
return list(filter(lambda t: __filter_torrent(t), torrents))

View File

@@ -1,3 +1,4 @@
import re
from typing import Union, Tuple
from sqlalchemy.orm import Session
@@ -28,6 +29,66 @@ class SiteChain(ChainBase):
self.cookiehelper = CookieHelper()
self.message = MessageHelper()
# 特殊站点登录验证
self.special_site_test = {
"zhuque.in": self.__zhuque_test,
# "m-team.io": self.__mteam_test,
}
@staticmethod
def __zhuque_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆zhuique
"""
# 获取token
token = None
res = RequestUtils(
ua=site.ua,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
).get_res(url=site.url)
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
if csrf_token:
token = csrf_token.group(1)
if not token:
return False, "无法获取Token"
# 调用查询用户信息接口
user_res = RequestUtils(
headers={
'X-CSRF-TOKEN': token,
"Content-Type": "application/json; charset=utf-8",
"User-Agent": f"{site.ua}"
},
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
).get_res(url=f"{site.url}api/user/getInfo")
if user_res and user_res.status_code == 200:
user_info = user_res.json()
if user_info and user_info.get("data"):
return True, "连接成功"
return False, "Cookie已失效"
@staticmethod
def __mteam_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆m-team
"""
url = f"{site.url}api/member/profile"
res = RequestUtils(
ua=site.ua,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
).post_res(url=url)
if res and res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("data"):
return True, "连接成功"
return False, "Cookie已失效"
def test(self, url: str) -> Tuple[bool, str]:
"""
测试站点是否可用
@@ -39,6 +100,12 @@ class SiteChain(ChainBase):
site_info = self.siteoper.get_by_domain(domain)
if not site_info:
return False, f"站点【{url}】不存在"
# 特殊站点测试
if self.special_site_test.get(domain):
return self.special_site_test[domain](site_info)
# 通用站点测试
site_url = site_info.url
site_cookie = site_info.cookie
ua = site_info.ua

View File

@@ -132,45 +132,6 @@ class SubscribeChain(ChainBase):
return True
return False
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
远程刷新订阅,发送消息
"""
self.post_message(Notification(channel=channel,
title=f"开始刷新订阅 ...", userid=userid))
self.refresh()
self.post_message(Notification(channel=channel,
title=f"订阅刷新完成!", userid=userid))
def remote_search(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
"""
远程搜索订阅,发送消息
"""
if arg_str and not str(arg_str).isdigit():
self.post_message(Notification(channel=channel,
title="请输入正确的命令格式:/subscribe_search [id]"
"[id]为订阅编号,不输入订阅编号时搜索所有订阅", userid=userid))
return
if arg_str:
sid = int(arg_str)
subscribe = self.subscribeoper.get(sid)
if not subscribe:
self.post_message(Notification(channel=channel,
title=f"订阅编号 {sid} 不存在!", userid=userid))
return
self.post_message(Notification(channel=channel,
title=f"开始搜索 {subscribe.name} ...", userid=userid))
# 搜索订阅
self.search(sid=int(arg_str))
self.post_message(Notification(channel=channel,
title=f"{subscribe.name} 搜索完成!", userid=userid))
else:
self.post_message(Notification(channel=channel,
title=f"开始搜索所有订阅 ...", userid=userid))
self.search(state='R')
self.post_message(Notification(channel=channel,
title=f"订阅搜索完成!", userid=userid))
def search(self, sid: int = None, state: str = 'N', manual: bool = False):
"""
订阅搜索
@@ -262,16 +223,25 @@ class SubscribeChain(ChainBase):
sites = json.loads(subscribe.sites)
else:
sites = None
# 过滤规则
# 优先级过滤规则
if subscribe.best_version:
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
else:
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
# 默认过滤规则
if subscribe.include or subscribe.exclude:
filter_rule = {
"include": subscribe.include,
"exclude": subscribe.exclude
}
else:
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
# 搜索,同时电视剧会过滤掉不需要的剧集
contexts = self.searchchain.process(mediainfo=mediainfo,
keyword=subscribe.keyword,
no_exists=no_exists,
sites=sites,
priority_rule=priority_rule,
filter_rule=filter_rule)
if not contexts:
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
@@ -285,16 +255,6 @@ class SubscribeChain(ChainBase):
torrent_meta = context.meta_info
torrent_info = context.torrent_info
torrent_mediainfo = context.media_info
# 包含
if subscribe.include:
if not re.search(r"%s" % subscribe.include,
f"{torrent_info.title} {torrent_info.description}", re.I):
continue
# 排除
if subscribe.exclude:
if re.search(r"%s" % subscribe.exclude,
f"{torrent_info.title} {torrent_info.description}", re.I):
continue
# 非洗版
if not subscribe.best_version:
# 如果是电视剧过滤掉已经下载的集数
@@ -340,7 +300,7 @@ class SubscribeChain(ChainBase):
# 更新订阅剩余集数和时间
update_date = True if downloads else False
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=update_date)
mediainfo=mediainfo, update_date=update_date)
# 手动触发时发送系统消息
if manual:
if sid:
@@ -487,6 +447,10 @@ class SubscribeChain(ChainBase):
}
else:
no_exists = {}
# 默认过滤规则
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
include = subscribe.include or default_filter.get("include")
exclude = subscribe.exclude or default_filter.get("exclude")
# 遍历缓存种子
_match_context = []
for domain, contexts in torrents.items():
@@ -499,14 +463,15 @@ class SubscribeChain(ChainBase):
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
or torrent_mediainfo.type != mediainfo.type:
continue
# 过滤规则
# 优先级过滤规则
if subscribe.best_version:
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2)
filter_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
else:
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
result: List[TorrentInfo] = self.filter_torrents(
rule_string=filter_rule,
torrent_list=[torrent_info])
torrent_list=[torrent_info],
mediainfo=torrent_mediainfo)
if result is not None and not result:
# 不符合过滤规则
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
@@ -558,14 +523,16 @@ class SubscribeChain(ChainBase):
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 包含
if subscribe.include:
if not re.search(r"%s" % subscribe.include,
if include:
if not re.search(r"%s" % include,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
continue
# 排除
if subscribe.exclude:
if re.search(r"%s" % subscribe.exclude,
if exclude:
if re.search(r"%s" % exclude,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
continue
# 匹配成功
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
@@ -588,7 +555,7 @@ class SubscribeChain(ChainBase):
update_date = True if downloads else False
# 未完成下载,计算剩余集数
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=update_date)
mediainfo=mediainfo, update_date=update_date)
else:
if meta.type == MediaType.TV:
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
@@ -621,7 +588,8 @@ class SubscribeChain(ChainBase):
if len(episodes) > (subscribe.total_episode or 0):
total_episode = len(episodes)
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info(f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
logger.info(
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
else:
total_episode = subscribe.total_episode
lack_episode = subscribe.lack_episode
@@ -682,9 +650,9 @@ class SubscribeChain(ChainBase):
return False
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
subscribe: Subscribe,
mediainfo: MediaInfo,
update_date: bool = False):
subscribe: Subscribe,
mediainfo: MediaInfo,
update_date: bool = False):
"""
更新订阅剩余集数
"""

View File

@@ -1,11 +1,15 @@
import random
from typing import Optional, List
from cachetools import cached, TTLCache
from app import schemas
from app.chain import ChainBase
from app.schemas import MediaType
from app.utils.singleton import Singleton
class TmdbChain(ChainBase):
class TmdbChain(ChainBase, metaclass=Singleton):
"""
TheMovieDB处理链
"""
@@ -106,3 +110,17 @@ class TmdbChain(ChainBase):
:param page: 页码
"""
return self.run_module("person_credits", person_id=person_id, page=page)
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_random_wallpager(self):
"""
获取随机壁纸缓存1个小时
"""
infos = self.tmdb_trending()
if infos:
# 随机一个电影
while True:
info = random.choice(infos)
if info and info.get("backdrop_path"):
return f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
return None

View File

@@ -60,7 +60,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
return self.load_cache(self._rss_file) or {}
@cached(cache=TTLCache(maxsize=128, ttl=600))
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=600))
def browse(self, domain: str) -> List[TorrentInfo]:
"""
浏览站点首页内容返回种子清单TTL缓存10分钟
@@ -73,7 +73,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
return []
return self.refresh_torrents(site=site)
@cached(cache=TTLCache(maxsize=128, ttl=300))
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=300))
def rss(self, domain: str) -> List[TorrentInfo]:
"""
获取站点RSS内容返回种子清单TTL缓存5分钟
@@ -129,7 +129,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 刷新站点
if not sites:
sites = [str(sid) for sid in (self.systemconfig.get(SystemConfigKey.RssSites) or [])]
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
# 读取缓存
torrents_cache = self.get_torrents()
@@ -139,7 +139,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 遍历站点缓存资源
for indexer in indexers:
# 未开启的站点不刷新
if sites and str(indexer.get("id")) not in sites:
if sites and indexer.get("id") not in sites:
continue
domain = StringUtils.get_url_domain(indexer.get("domain"))
if stype == "spider":

View File

@@ -1,3 +1,4 @@
import glob
import re
import shutil
import threading
@@ -8,10 +9,11 @@ from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.core.metainfo import MetaInfoPath
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
@@ -40,6 +42,7 @@ class TransferChain(ChainBase):
self.transferhis = TransferHistoryOper(self._db)
self.progress = ProgressHelper()
self.mediachain = MediaChain(self._db)
self.tmdbchain = TmdbChain(self._db)
self.systemconfig = SystemConfigOper()
def process(self) -> bool:
@@ -109,17 +112,6 @@ class TransferChain(ChainBase):
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
return False, f"{path.name} 没有找到可转移的媒体文件"
# 汇总错误信息
err_msgs: List[str] = []
# 汇总季集清单
season_episodes: Dict[Tuple, List[int]] = {}
# 汇总元数据
metas: Dict[Tuple, MetaBase] = {}
# 汇总媒体信息
medias: Dict[Tuple, MediaInfo] = {}
# 汇总转移信息
transfers: Dict[Tuple, TransferInfo] = {}
# 有集自定义格式
formaterHandler = FormatParser(eformat=epformat.format,
details=epformat.detail,
@@ -128,17 +120,24 @@ class TransferChain(ChainBase):
# 开始进度
self.progress.start(ProgressKey.FileTransfer)
# 总数
# 目录所有文件清单
transfer_files = SystemUtils.list_files(directory=path,
extensions=settings.RMT_MEDIAEXT,
min_filesize=min_filesize)
if formaterHandler:
# 有集自定义格式,过滤文件
transfer_files = [f for f in transfer_files if formaterHandler.match(f.name)]
# 总数
# 汇总错误信息
err_msgs: List[str] = []
# 总文件数
total_num = len(transfer_files)
# 已处理数量
processed_num = 0
# 失败数量
fail_num = 0
# 跳过数量
skip_num = 0
self.progress.update(value=0,
text=f"开始转移 {path},共 {total_num} 个文件 ...",
key=ProgressKey.FileTransfer)
@@ -148,6 +147,15 @@ class TransferChain(ChainBase):
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
for trans_path in trans_paths:
# 汇总季集清单
season_episodes: Dict[Tuple, List[int]] = {}
# 汇总元数据
metas: Dict[Tuple, MetaBase] = {}
# 汇总媒体信息
medias: Dict[Tuple, MediaInfo] = {}
# 汇总转移信息
transfers: Dict[Tuple, TransferInfo] = {}
# 如果是目录且不是⼀蓝光原盘,获取所有文件并转移
if (not trans_path.is_file()
and not SystemUtils.is_bluray_dir(trans_path)):
@@ -164,7 +172,6 @@ class TransferChain(ChainBase):
# 转移所有文件
for file_path in file_paths:
# 回收站及隐藏的文件不处理
file_path_str = str(file_path)
if file_path_str.find('/@Recycle/') != -1 \
@@ -172,6 +179,9 @@ class TransferChain(ChainBase):
or file_path_str.find('/.') != -1 \
or file_path_str.find('/@eaDir') != -1:
logger.debug(f"{file_path_str} 是回收站或隐藏的文件")
# 计数
processed_num += 1
skip_num += 1
continue
# 整理屏蔽词不处理
@@ -186,6 +196,9 @@ class TransferChain(ChainBase):
break
if is_blocked:
err_msgs.append(f"{file_path.name} 命中整理屏蔽词")
# 计数
processed_num += 1
skip_num += 1
continue
# 转移成功的不再处理
@@ -193,6 +206,9 @@ class TransferChain(ChainBase):
transferd = self.transferhis.get_by_src(file_path_str)
if transferd and transferd.status:
logger.info(f"{file_path} 已成功转移过,如需重新处理,请删除历史记录。")
# 计数
processed_num += 1
skip_num += 1
continue
# 更新进度
@@ -201,12 +217,8 @@ class TransferChain(ChainBase):
key=ProgressKey.FileTransfer)
if not meta:
# 上级目录元数据
dir_meta = MetaInfo(title=file_path.parent.name)
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=file_path.stem)
# 合并元数据
file_meta.merge(dir_meta)
# 文件元数据
file_meta = MetaInfoPath(file_path)
else:
file_meta = meta
@@ -217,6 +229,9 @@ class TransferChain(ChainBase):
if not file_meta:
logger.error(f"{file_path} 无法识别有效信息")
err_msgs.append(f"{file_path} 无法识别有效信息")
# 计数
processed_num += 1
fail_num += 1
continue
# 自定义识别
@@ -240,7 +255,7 @@ class TransferChain(ChainBase):
# 新增转移失败历史记录
his = self.transferhis.add_fail(
src_path=file_path,
mode=settings.TRANSFER_TYPE,
mode=transfer_type,
meta=file_meta,
download_hash=download_hash
)
@@ -249,6 +264,9 @@ class TransferChain(ChainBase):
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
))
# 计数
processed_num += 1
fail_num += 1
continue
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
@@ -260,31 +278,17 @@ class TransferChain(ChainBase):
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
# 电视剧没有集无法转移
if file_mediainfo.type == MediaType.TV and not file_meta.episode:
# 转移失败
logger.warn(f"{file_path.name} 入库失败:未识别到集数")
err_msgs.append(f"{file_path.name} 未识别到集数")
# 新增转移失败历史记录
self.transferhis.add_fail(
src_path=file_path,
mode=settings.TRANSFER_TYPE,
download_hash=download_hash,
meta=file_meta,
mediainfo=file_mediainfo
)
# 发送消息
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{file_path.name} 入库失败!",
text=f"原因:未识别到集数",
image=file_mediainfo.get_message_image()
))
continue
# 更新媒体图片
self.obtain_images(mediainfo=file_mediainfo)
# 获取集数据
if file_mediainfo.type == MediaType.TV:
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
season=file_meta.begin_season or 1)
else:
episodes_info = None
# 获取下载hash
if not download_hash:
download_file = self.downloadhis.get_file_by_fullpath(file_path_str)
if download_file:
@@ -295,18 +299,19 @@ class TransferChain(ChainBase):
mediainfo=file_mediainfo,
path=file_path,
transfer_type=transfer_type,
target=target)
target=target,
episodes_info=episodes_info)
if not transferinfo:
logger.error("文件转移模块运行失败")
return False, "文件转移模块运行失败"
if not transferinfo.target_path:
if not transferinfo.success:
# 转移失败
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
err_msgs.append(f"{file_path.name} {transferinfo.message}")
# 新增转移失败历史记录
self.transferhis.add_fail(
src_path=file_path,
mode=settings.TRANSFER_TYPE,
mode=transfer_type,
download_hash=download_hash,
meta=file_meta,
mediainfo=file_mediainfo,
@@ -319,6 +324,9 @@ class TransferChain(ChainBase):
text=f"原因:{transferinfo.message or '未知'}",
image=file_mediainfo.get_message_image()
))
# 计数
processed_num += 1
fail_num += 1
continue
# 汇总信息
@@ -342,7 +350,7 @@ class TransferChain(ChainBase):
# 新增转移成功历史记录
self.transferhis.add_success(
src_path=file_path,
mode=settings.TRANSFER_TYPE,
mode=transfer_type,
download_hash=download_hash,
meta=file_meta,
mediainfo=file_mediainfo,
@@ -358,8 +366,7 @@ class TransferChain(ChainBase):
key=ProgressKey.FileTransfer)
# 目录或文件转移完成
self.progress.update(value=100,
text=f"所有文件转移完成,正在执行后续处理 ...",
self.progress.update(text=f"{trans_path} 转移完成,正在执行后续处理 ...",
key=ProgressKey.FileTransfer)
# 执行后续处理
@@ -386,10 +393,16 @@ class TransferChain(ChainBase):
'mediainfo': media,
'transferinfo': transfer_info
})
# 结束进度
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
f"成功 {total_num - len(err_msgs)} 个,失败 {len(err_msgs)}")
self.progress.end(ProgressKey.FileTransfer)
# 结束进度
logger.info(f"{path} 转移完成,共 {total_num}文件,"
f"失败 {fail_num} 个,跳过 {skip_num}")
self.progress.update(value=100,
text=f"{path} 转移完成,共 {total_num} 个文件,"
f"失败 {fail_num} 个,跳过 {skip_num}",
key=ProgressKey.FileTransfer)
self.progress.end(ProgressKey.FileTransfer)
return True, "\n".join(err_msgs)
@@ -473,7 +486,8 @@ class TransferChain(ChainBase):
text=errmsg, userid=userid))
return
def re_transfer(self, logid: int, mtype: MediaType, tmdbid: int) -> Tuple[bool, str]:
def re_transfer(self, logid: int,
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
"""
根据历史记录重新识别转移只处理对应的src目录
:param logid: 历史记录ID
@@ -489,12 +503,17 @@ class TransferChain(ChainBase):
src_path = Path(history.src)
if not src_path.exists():
return False, f"源目录不存在:{src_path}"
dest_path = Path(history.dest) if history.dest else None
# 查询媒体信息
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
if mtype and tmdbid:
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
else:
meta = MetaInfoPath(src_path)
mediainfo = self.recognize_media(meta=meta)
if not mediainfo:
return False, f"未识别到媒体信息,类型:{mtype.value}tmdbid{tmdbid}"
# 重新执行转移
logger.info(f"{mtype.value} {tmdbid} 识别为:{mediainfo.title_year}")
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
@@ -506,6 +525,7 @@ class TransferChain(ChainBase):
state, errmsg = self.do_transfer(path=src_path,
mediainfo=mediainfo,
download_hash=history.download_hash,
target=dest_path,
force=True)
if not state:
return False, errmsg
@@ -596,13 +616,16 @@ class TransferChain(ChainBase):
def delete_files(path: Path):
"""
删除转移后的文件以及空目录
:param path: 文件路径
"""
logger.info(f"开始删除文件以及空目录:{path} ...")
if not path.exists():
return
if path.is_file():
# 删除文件
path.unlink()
# 删除文件、nfo、jpg
files = glob.glob(f"{Path(path.parent).joinpath(path.stem)}*")
for file in files:
Path(file).unlink()
logger.warn(f"文件 {path} 已删除")
# 需要删除父目录
elif str(path.parent) == str(path.root):
@@ -615,11 +638,24 @@ class TransferChain(ChainBase):
# 删除目录
logger.warn(f"目录 {path} 已删除")
# 需要删除父目录
# 判断父目录是否为空, 为空则删除
for parent_path in path.parents:
if str(parent_path.parent) != str(path.root):
# 父目录非根目录,才删除父目录
files = SystemUtils.list_files(parent_path, settings.RMT_MEDIAEXT)
if not files:
shutil.rmtree(parent_path)
logger.warn(f"目录 {parent_path} 已删除")
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
# 媒体库二级分类根路径
library_root_names = [
settings.LIBRARY_MOVIE_NAME or '电影',
settings.LIBRARY_TV_NAME or '电视剧',
settings.LIBRARY_ANIME_NAME or '动漫',
]
# 判断父目录是否为空, 为空则删除
for parent_path in path.parents:
# 遍历父目录到媒体库二级分类根路径
if str(parent_path.name) in library_root_names:
break
if str(parent_path.parent) != str(path.root):
# 父目录非根目录,才删除父目录
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
# 当前路径下没有媒体文件则删除
shutil.rmtree(parent_path)
logger.warn(f"目录 {parent_path} 已删除")

View File

@@ -4,7 +4,7 @@ from typing import Any
from app.chain import ChainBase
from app.schemas import Notification
from app.schemas.types import EventType, MediaImageType, MediaType, NotificationType
from app.utils.http import WebUtils
from app.utils.web import WebUtils
class WebhookChain(ChainBase):

View File

@@ -1,11 +1,9 @@
import traceback
from threading import Thread, Event
from typing import Any, Union
from typing import Any, Union, Dict
from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain
from app.chain.download import DownloadChain
from app.chain.mediaserver import MediaServerChain
from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
@@ -15,6 +13,8 @@ from app.core.event import eventmanager, EventManager
from app.core.plugin import PluginManager
from app.db import SessionFactory
from app.log import logger
from app.scheduler import Scheduler
from app.schemas import Notification
from app.schemas.types import EventType, MessageChannel
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
@@ -49,13 +49,15 @@ class Command(metaclass=Singleton):
self.pluginmanager = PluginManager()
# 处理链
self.chain = CommandChian(self._db)
# 定时服务管理
self.scheduler = Scheduler()
# 内置命令
self._commands = {
"/cookiecloud": {
"func": CookieCloudChain(self._db).remote_sync,
"id": "cookiecloud",
"type": "scheduler",
"description": "同步站点",
"category": "站点",
"data": {}
"category": "站点"
},
"/sites": {
"func": SiteChain(self._db).remote_list,
@@ -79,10 +81,10 @@ class Command(metaclass=Singleton):
"data": {}
},
"/mediaserver_sync": {
"func": MediaServerChain(self._db).remote_sync,
"id": "mediaserver_sync",
"type": "scheduler",
"description": "同步媒体服务器",
"category": "管理",
"data": {}
"category": "管理"
},
"/subscribes": {
"func": SubscribeChain(self._db).remote_list,
@@ -91,22 +93,27 @@ class Command(metaclass=Singleton):
"data": {}
},
"/subscribe_refresh": {
"func": SubscribeChain(self._db).remote_refresh,
"id": "subscribe_refresh",
"type": "scheduler",
"description": "刷新订阅",
"category": "订阅",
"data": {}
"category": "订阅"
},
"/subscribe_search": {
"func": SubscribeChain(self._db).remote_search,
"id": "subscribe_search",
"type": "scheduler",
"description": "搜索订阅",
"category": "订阅",
"data": {}
"category": "订阅"
},
"/subscribe_delete": {
"func": SubscribeChain(self._db).remote_delete,
"description": "删除订阅",
"data": {}
},
"/subscribe_tmdb": {
"id": "subscribe_tmdb",
"type": "scheduler",
"description": "订阅元数据更新"
},
"/downloading": {
"func": DownloadChain(self._db).remote_downloading,
"description": "正在下载",
@@ -114,10 +121,10 @@ class Command(metaclass=Singleton):
"data": {}
},
"/transfer": {
"func": TransferChain(self._db).process,
"id": "transfer",
"type": "scheduler",
"description": "下载文件整理",
"category": "管理",
"data": {}
"category": "管理"
},
"/redo": {
"func": TransferChain(self._db).remote_transfer,
@@ -175,6 +182,56 @@ class Command(metaclass=Singleton):
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
def __run_command(self, command: Dict[str, any],
data_str: str = "",
channel: MessageChannel = None, userid: Union[str, int] = None):
"""
运行定时服务
"""
if command.get("type") == "scheduler":
# 定时服务
if userid:
self.chain.post_message(
Notification(
channel=channel,
title=f"开始执行 {command.get('description')} ...",
userid=userid
)
)
# 执行定时任务
self.scheduler.start(job_id=command.get("id"))
if userid:
self.chain.post_message(
Notification(
channel=channel,
title=f"{command.get('description')} 执行完成",
userid=userid
)
)
else:
# 命令
cmd_data = command['data'] if command.get('data') else {}
args_num = ObjectUtils.arguments(command['func'])
if args_num > 0:
if cmd_data:
# 有内置参数直接使用内置参数
data = cmd_data.get("data") or {}
data['channel'] = channel
data['user'] = userid
cmd_data['data'] = data
command['func'](**cmd_data)
elif args_num == 2:
# 没有输入参数只输入渠道和用户ID
command['func'](channel, userid)
elif args_num > 2:
# 多个输入参数用户输入、用户ID
command['func'](data_str, channel, userid)
else:
# 没有参数
command['func']()
def stop(self):
"""
停止事件处理线程
@@ -216,27 +273,19 @@ class Command(metaclass=Singleton):
command = self.get(cmd)
if command:
try:
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
cmd_data = command['data'] if command.get('data') else {}
args_num = ObjectUtils.arguments(command['func'])
if args_num > 0:
if cmd_data:
# 有内置参数直接使用内置参数
data = cmd_data.get("data") or {}
data['channel'] = channel
data['user'] = userid
cmd_data['data'] = data
command['func'](**cmd_data)
elif args_num == 2:
# 没有输入参数只输入渠道和用户ID
command['func'](channel, userid)
elif args_num > 2:
# 多个输入参数用户输入、用户ID
command['func'](data_str, channel, userid)
if userid:
logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...")
else:
# 没有参数
command['func']()
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
logger.info(f"开始执行:{command.get('description')} ...")
# 执行命令
self.__run_command(command, data_str=data_str,
channel=channel, userid=userid)
if userid:
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
else:
logger.info(f"{command.get('description')} 执行完成")
except Exception as err:
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
traceback.print_exc()

View File

@@ -76,7 +76,7 @@ class Settings(BaseSettings):
AUTH_SITE: str = ""
# 交互搜索自动下载用户ID使用,分割
AUTO_DOWNLOAD_USER: str = None
# 消息通知渠道 telegram/wechat/slack
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
MESSAGER: str = "telegram"
# WeChat企业ID
WECHAT_CORPID: str = None
@@ -106,6 +106,10 @@ class Settings(BaseSettings):
SLACK_APP_TOKEN: str = ""
# Slack 频道名称
SLACK_CHANNEL: str = ""
# SynologyChat Webhook
SYNOLOGYCHAT_WEBHOOK: str = ""
# SynologyChat Token
SYNOLOGYCHAT_TOKEN: str = ""
# 下载器 qbittorrent/transmission
DOWNLOADER: str = "qbittorrent"
# 下载器监控开关
@@ -138,12 +142,14 @@ class Settings(BaseSettings):
DOWNLOAD_CATEGORY: bool = False
# 下载站点字幕
DOWNLOAD_SUBTITLE: bool = True
# 媒体服务器 emby/jellyfin/plex
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
MEDIASERVER: str = "emby"
# 入库刷新媒体库
REFRESH_MEDIASERVER: bool = True
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: int = 6
# 媒体服务器同步黑名单,多个媒体库名称,分割
MEDIASERVER_SYNC_BLACKLIST: str = None
# EMBY服务器地址IP:PORT
EMBY_HOST: str = None
# EMBY Api Key

View File

@@ -1,6 +1,6 @@
import re
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any
from typing import List, Dict, Any, Tuple
from app.core.config import settings
from app.core.meta import MetaBase
@@ -272,7 +272,7 @@ class MediaInfo:
初始化媒信息
"""
def __directors_actors(tmdbinfo: dict):
def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:
"""
查询导演和演员
:param tmdbinfo: TMDB元数据

View File

@@ -9,7 +9,7 @@ from app.core.meta.words import WordsMatcher
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
"""
媒体整理入口,根据名称和副标题,判断是哪种类型的识别,返回对应对象
根据标题和副标题识别元数据
:param title: 标题、种子名、文件名
:param subtitle: 副标题、描述
:return: MetaAnime、MetaVideo
@@ -33,6 +33,20 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
return meta
def MetaInfoPath(path: Path) -> MetaBase:
"""
根据路径识别元数据
:param path: 路径
"""
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name)
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=path.stem)
# 合并元数据
file_meta.merge(dir_meta)
return file_meta
def is_anime(name: str) -> bool:
"""
判断是否为动漫

View File

@@ -74,6 +74,16 @@ class DownloadHistoryOper(DbOper):
"""
DownloadFiles.delete_by_fullpath(self._db, fullpath)
def get_hash_by_fullpath(self, fullpath: str) -> str:
"""
按fullpath查询下载文件记录hash
:param fullpath: 数据key
"""
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
if fileinfo:
return fileinfo.download_hash
return ""
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
"""
分页查询下载历史
@@ -98,3 +108,11 @@ class DownloadHistoryOper(DbOper):
season=season,
episode=episode,
tmdbid=tmdbid)
def list_by_user_date(self, date: str, userid: str = None) -> List[DownloadHistory]:
"""
查询某用户某时间之后的下载历史
"""
return DownloadHistory.list_by_user_date(db=self._db,
date=date,
userid=userid)

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Self, List
from sqlalchemy.orm import as_declarative, declared_attr, Session
@@ -16,13 +16,13 @@ class Base:
db.rollback()
raise err
def create(self, db: Session):
def create(self, db: Session) -> Self:
db.add(self)
self.commit(db)
return self
@classmethod
def get(cls, db: Session, rid: int):
def get(cls, db: Session, rid: int) -> Self:
return db.query(cls).filter(cls.id == rid).first()
def update(self, db: Session, payload: dict):
@@ -42,7 +42,7 @@ class Base:
Base.commit(db)
@classmethod
def list(cls, db: Session):
def list(cls, db: Session) -> List[Self]:
return db.query(cls).all()
def to_dict(self):

View File

@@ -35,6 +35,12 @@ class DownloadHistory(Base):
torrent_description = Column(String)
# 种子站点
torrent_site = Column(String)
# 下载用户
userid = Column(String)
# 下载渠道
channel = Column(String)
# 创建时间
date = Column(String)
# 附加信息
note = Column(String)
@@ -90,6 +96,19 @@ class DownloadHistory(Base):
DownloadHistory.episodes == episode).order_by(
DownloadHistory.id.desc()).all()
@staticmethod
def list_by_user_date(db: Session, date: str, userid: str = None):
"""
查询某用户某时间之后的下载历史
"""
if userid:
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
DownloadHistory.userid == userid).order_by(
DownloadHistory.id.desc()).all()
else:
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
DownloadHistory.id.desc()).all()
class DownloadFiles(Base):
"""

View File

@@ -65,6 +65,10 @@ class TransferHistory(Base):
def get_by_src(db: Session, src: str):
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
@staticmethod
def list_by_hash(db: Session, download_hash: str):
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
@staticmethod
def statistic(db: Session, days: int = 7):
"""

View File

@@ -36,6 +36,13 @@ class TransferHistoryOper(DbOper):
"""
return TransferHistory.get_by_src(self._db, src)
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
"""
按种子hash查询转移记录
:param download_hash: 种子hash
"""
return TransferHistory.list_by_hash(self._db, download_hash)
def add(self, **kwargs) -> TransferHistory:
"""
新增转移历史

View File

@@ -23,14 +23,17 @@ class CookieHelper:
"password": [
'//input[@name="password"]',
'//input[@id="form_item_password"]',
'//input[@id="password"]'
'//input[@id="password"]',
'//input[@type="password"]'
],
"captcha": [
'//input[@name="imagestring"]',
'//input[@name="captcha"]',
'//input[@id="form_item_captcha"]'
'//input[@id="form_item_captcha"]',
'//input[@placeholder="驗證碼"]'
],
"captcha_img": [
'//img[@alt="captcha"]/@src',
'//img[@alt="CAPTCHA"]/@src',
'//img[@alt="SECURITY CODE"]/@src',
'//img[@id="LAY-user-get-vercode"]/@src',

View File

@@ -1,5 +1,6 @@
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Optional
class NfoReader:
@@ -8,6 +9,9 @@ class NfoReader:
self.tree = ET.parse(xml_file_path)
self.root = self.tree.getroot()
def get_element_value(self, element_path):
def get_element_value(self, element_path) -> Optional[str]:
element = self.root.find(element_path)
return element.text if element is not None else None
def get_elements(self, element_path) -> List[ET.Element]:
return self.root.findall(element_path)

View File

@@ -58,6 +58,8 @@ def checkMessage(channel_type: MessageChannel):
return None
if channel_type == MessageChannel.Slack and not switch.get("slack"):
return None
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
return None
return func(self, message, *args, **kwargs)
return wrapper

View File

@@ -1,4 +1,3 @@
import json
from pathlib import Path
from typing import Optional, Tuple, Union, Any, List, Generator
@@ -29,7 +28,7 @@ class EmbyModule(_ModuleBase):
"""
# 定时重连
if not self.emby.is_inactive():
self.emby = Emby()
self.emby.reconnect()
def user_authenticate(self, name: str, password: str) -> Optional[str]:
"""
@@ -41,7 +40,7 @@ class EmbyModule(_ModuleBase):
# Emby认证
return self.emby.authenticate(name, password)
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
"""
解析Webhook报文体
:param body: 请求体
@@ -49,11 +48,7 @@ class EmbyModule(_ModuleBase):
:param args: 请求参数
:return: 字典解析为消息时需要包含title、text、image
"""
if form and form.get("data"):
result = form.get("data")
else:
result = json.dumps(dict(args))
return self.emby.get_webhook_message(result)
return self.emby.get_webhook_message(form, args)
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
"""
@@ -87,7 +82,7 @@ class EmbyModule(_ModuleBase):
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
@@ -103,25 +98,27 @@ class EmbyModule(_ModuleBase):
target_path=file_path
)
]
return self.emby.refresh_library_by_items(items)
self.emby.refresh_library_by_items(items)
def media_statistic(self) -> schemas.Statistic:
def media_statistic(self) -> List[schemas.Statistic]:
"""
媒体数量统计
"""
media_statistic = self.emby.get_medias_count()
user_count = self.emby.get_user_count()
return schemas.Statistic(
return [schemas.Statistic(
movie_count=media_statistic.get("MovieCount") or 0,
tv_count=media_statistic.get("SeriesCount") or 0,
episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=user_count or 0
)
)]
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "emby":
return None
librarys = self.emby.get_librarys()
if not librarys:
return []
@@ -133,10 +130,12 @@ class EmbyModule(_ModuleBase):
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
媒体库项目列表
"""
if server != "emby":
return None
items = self.emby.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
@@ -153,10 +152,13 @@ class EmbyModule(_ModuleBase):
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
def mediaserver_tv_episodes(self, server: str,
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
"""
获取剧集信息
"""
if server != "emby":
return None
seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []

View File

@@ -35,6 +35,13 @@ class Emby(metaclass=Singleton):
return False
return True if not self.user else False
def reconnect(self):
"""
重连
"""
self.user = self.get_user()
self.folders = self.get_emby_folders()
def get_emby_folders(self) -> List[dict]:
"""
获取Emby媒体库路径列表
@@ -272,8 +279,8 @@ class Emby(metaclass=Singleton):
return None
return ""
def get_movies(self,
title: str,
def get_movies(self,
title: str,
year: str = None,
tmdb_id: int = None) -> Optional[List[dict]]:
"""
@@ -338,10 +345,12 @@ class Emby(metaclass=Singleton):
if not item_id:
return {}
# 验证tmdbid是否相同
item_tmdbid = (self.get_iteminfo(item_id).get("ProviderIds") or {}).get("Tmdb")
if tmdb_id and item_tmdbid:
if str(tmdb_id) != str(item_tmdbid):
return {}
item_info = self.get_iteminfo(item_id)
if item_info:
item_tmdbid = (item_info.get("ProviderIds") or {}).get("Tmdb")
if tmdb_id and item_tmdbid:
if str(tmdb_id) != str(item_tmdbid):
return {}
# /Shows/Id/Episodes 查集的信息
if not season:
season = ""
@@ -479,14 +488,14 @@ class Emby(metaclass=Singleton):
# 匹配子目录
subfolder_path = Path(subfolder.get("Path"))
if item_path.is_relative_to(subfolder_path):
return subfolder.get("Id")
return folder.get("Id")
except Exception as err:
print(str(err))
# 如果找不到,只要路径中有分类目录名就命中
for subfolder in folder.get("SubFolders"):
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
subfolder.get("Path")):
return folder.get("Id")
# 如果找不到,只要路径中有分类目录名就命中
for subfolder in folder.get("SubFolders"):
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
subfolder.get("Path")):
return folder.get("Id")
# 刷新根目录
return "/"
@@ -543,7 +552,7 @@ class Emby(metaclass=Singleton):
logger.error(f"连接Users/Items出错" + str(e))
yield {}
def get_webhook_message(self, message_str: str) -> WebhookEventInfo:
def get_webhook_message(self, form: any, args: dict) -> Optional[WebhookEventInfo]:
"""
解析Emby Webhook报文
电影:
@@ -781,9 +790,22 @@ class Emby(metaclass=Singleton):
}
}
"""
message = json.loads(message_str)
if not form and not args:
return None
try:
if form and form.get("data"):
result = form.get("data")
else:
result = json.dumps(dict(args))
message = json.loads(result)
except Exception as e:
logger.debug(f"解析emby webhook报文出错" + str(e))
return None
eventType = message.get('Event')
if not eventType:
return None
logger.info(f"接收到emby webhook{message}")
eventItem = WebhookEventInfo(event=message.get('Event', ''), channel="emby")
eventItem = WebhookEventInfo(event=eventType, channel="emby")
if message.get('Item'):
if message.get('Item', {}).get('Type') == 'Episode':
eventItem.item_type = "TV"

View File

@@ -329,7 +329,11 @@ class FanartModule(_ModuleBase):
if mediainfo.type == MediaType.MOVIE:
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
else:
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
if mediainfo.tvdb_id:
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
else:
logger.info(f"{mediainfo.title_year} 没有tvdbid无法获取Fanart图片")
return
if not result or result.get('status') == 'error':
logger.warn(f"没有获取到 {mediainfo.title_year} 的Fanart图片数据")
return
@@ -351,6 +355,7 @@ class FanartModule(_ModuleBase):
# 季图片格式 seasonxx-poster
image_name = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
if not mediainfo.get_image(image_name):
# 没有图片才设置
mediainfo.set_image(image_name, image_obj.get('url'))
return mediainfo

View File

@@ -11,7 +11,7 @@ from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.schemas import TransferInfo, ExistMediaInfo
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
from app.schemas.types import MediaType
from app.utils.system import SystemUtils
@@ -30,7 +30,8 @@ class FileTransferModule(_ModuleBase):
pass
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None) -> TransferInfo:
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
"""
文件转移
:param path: 文件路径
@@ -38,20 +39,26 @@ class FileTransferModule(_ModuleBase):
:param mediainfo: 识别的媒体信息
:param transfer_type: 转移方式
:param target: 目标路径
:param episodes_info: 当前季的全部集信息
:return: {path, target_path, message}
"""
# 获取目标路径
if not target:
target = self.get_target_path(in_path=path)
else:
target = self.get_library_path(target)
if not target:
logger.error("未找到媒体库目录,无法转移文件")
return TransferInfo(message="未找到媒体库目录,无法转移文件")
return TransferInfo(success=False,
path=path,
message="未找到媒体库目录")
# 转移
return self.transfer_media(in_path=path,
in_meta=meta,
mediainfo=mediainfo,
transfer_type=transfer_type,
target_dir=target)
target_dir=target,
episodes_info=episodes_info)
@staticmethod
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
@@ -316,9 +323,11 @@ class FileTransferModule(_ModuleBase):
over_flag=over_flag)
@staticmethod
def __get_library_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
"""
根据设置并装媒体库目录
:param mediainfo: 媒体信息
:target_dir: 媒体库根目录
"""
if mediainfo.type == MediaType.MOVIE:
# 电影
@@ -349,25 +358,31 @@ class FileTransferModule(_ModuleBase):
mediainfo: MediaInfo,
transfer_type: str,
target_dir: Path,
episodes_info: List[TmdbEpisode] = None
) -> TransferInfo:
"""
识别并转移一个文件或者一个目录下的所有文件
:param in_path: 转移的路径,可能是一个文件也可以是一个目录
:param in_meta预识别元数据
:param mediainfo: 媒体信息
:param target_dir: 目的文件夹,非空的转移到该文件夹,为空时则按类型转移到配置文件中的媒体库文件夹
:param target_dir: 媒体库根目录
:param transfer_type: 文件转移方式
:param episodes_info: 当前季的全部集信息
:return: TransferInfo、错误信息
"""
# 检查目录路径
if not in_path.exists():
return TransferInfo(message=f"{in_path} 路径不存在")
return TransferInfo(success=False,
path=in_path,
message=f"{in_path} 路径不存在")
if not target_dir.exists():
return TransferInfo(message=f"{target_dir} 目标路径不存在")
return TransferInfo(success=False,
path=in_path,
message=f"{target_dir} 目标路径不存在")
# 媒体库目录
target_dir = self.__get_library_dir(mediainfo=mediainfo, target_dir=target_dir)
# 媒体库目的目
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
@@ -393,27 +408,39 @@ class FileTransferModule(_ModuleBase):
transfer_type=transfer_type)
if retcode != 0:
logger.error(f"文件夹 {in_path} 转移失败,错误码:{retcode}")
return TransferInfo(message=f"文件夹 {in_path} 转移失败,错误码:{retcode}")
return TransferInfo(success=False,
message=f"错误码:{retcode}",
path=in_path,
target_path=new_path,
is_bluray=bluray_flag)
logger.info(f"文件夹 {in_path} 转移成功")
# 返回转移后的路径
return TransferInfo(path=in_path,
return TransferInfo(success=True,
path=in_path,
target_path=new_path,
total_size=new_path.stat().st_size,
is_bluray=bluray_flag)
else:
# 转移单个文件
# 文件结束季为空
in_meta.end_season = None
if mediainfo.type == MediaType.TV:
# 电视剧
if in_meta.begin_episode is None:
logger.warn(f"文件 {in_path} 转移失败:未识别到文件集数")
return TransferInfo(success=False,
message=f"未识别到文件集数",
path=in_path,
fail_list=[str(in_path)])
# 文件总季数为1
if in_meta.total_season:
in_meta.total_season = 1
# 文件不可能有多集
if in_meta.total_episode > 2:
in_meta.total_episode = 1
in_meta.end_episode = None
# 文件结束季为空
in_meta.end_season = None
# 文件总季数为1
if in_meta.total_season:
in_meta.total_season = 1
# 文件不可能超过2集
if in_meta.total_episode > 2:
in_meta.total_episode = 1
in_meta.end_episode = None
# 目的文件名
new_file = self.get_rename_path(
@@ -422,6 +449,7 @@ class FileTransferModule(_ModuleBase):
rename_dict=self.__get_naming_dict(
meta=in_meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
file_ext=in_path.suffix
)
)
@@ -440,11 +468,15 @@ class FileTransferModule(_ModuleBase):
over_flag=overflag)
if retcode != 0:
logger.error(f"文件 {in_path} 转移失败,错误码:{retcode}")
return TransferInfo(message=f"文件 {in_path.name} 转移失败,错误码:{retcode}",
return TransferInfo(success=False,
message=f"错误码:{retcode}",
path=in_path,
target_path=new_file,
fail_list=[str(in_path)])
logger.info(f"文件 {in_path} 转移成功")
return TransferInfo(path=in_path,
return TransferInfo(success=True,
path=in_path,
target_path=new_file,
file_count=1,
total_size=new_file.stat().st_size,
@@ -453,13 +485,23 @@ class FileTransferModule(_ModuleBase):
file_list_new=[str(new_file)])
@staticmethod
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None) -> dict:
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
episodes_info: List[TmdbEpisode] = None) -> dict:
"""
根据媒体信息返回Format字典
:param meta: 文件元数据
:param mediainfo: 识别的媒体信息
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
# 获取集标题
episode_title = None
if meta.begin_episode and episodes_info:
for episode in episodes_info:
if episode.episode_number == meta.begin_episode:
episode_title = episode.name
break
return {
# 标题
"title": mediainfo.title,
@@ -471,14 +513,16 @@ class FileTransferModule(_ModuleBase):
"name": meta.name,
# 年份
"year": mediainfo.year or meta.year,
# 资源类型
"resourceType": meta.resource_type,
# 特效
"effect": meta.resource_effect,
# 版本
"edition": meta.edition,
# 分辨率
"videoFormat": meta.resource_pix,
# 制作组/字幕组
"releaseGroup": meta.resource_team,
# 特效
"effect": meta.resource_effect,
# 视频编码
"videoCodec": meta.video_encode,
# 音频编码
@@ -495,6 +539,8 @@ class FileTransferModule(_ModuleBase):
"season_episode": "%s%s" % (meta.season, meta.episodes),
# 段/节
"part": meta.part,
# 剧集标题
"episode_title": episode_title,
# 文件后缀
"fileExt": file_ext
}
@@ -514,6 +560,26 @@ class FileTransferModule(_ModuleBase):
else:
return Path(render_str)
@staticmethod
def get_library_path(path: Path):
"""
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
"""
if not path:
return None
if not settings.LIBRARY_PATHS:
return path
# 目的路径,多路径以,分隔
dest_paths = settings.LIBRARY_PATHS
for libpath in dest_paths:
try:
if path.is_relative_to(libpath):
return libpath
except Exception as e:
logger.debug(f"计算媒体库路径时出错:{e}")
continue
return path
@staticmethod
def get_target_path(in_path: Path = None) -> Optional[Path]:
"""
@@ -533,7 +599,7 @@ class FileTransferModule(_ModuleBase):
if in_path:
for path in dest_paths:
try:
relative = Path(in_path).relative_to(path).as_posix()
relative = in_path.relative_to(path).as_posix()
if len(relative) > max_length:
max_length = len(relative)
target_path = path
@@ -569,14 +635,15 @@ class FileTransferModule(_ModuleBase):
if not target_dir:
continue
# 媒体分类路径
target_dir = self.__get_library_dir(mediainfo=mediainfo, target_dir=target_dir)
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 相对路径
meta = MetaInfo(mediainfo.title)
rel_path = self.get_rename_path(
template_string=rename_format,
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
rename_dict=self.__get_naming_dict(meta=meta,
mediainfo=mediainfo)
)
# 取相对路径的第1层目录

View File

@@ -1,7 +1,7 @@
import re
from typing import List, Tuple, Union, Dict, Optional
from app.core.context import TorrentInfo
from app.core.context import TorrentInfo, MediaInfo
from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase
@@ -9,9 +9,10 @@ from app.modules.filter.RuleParser import RuleParser
class FilterModule(_ModuleBase):
# 规则解析器
parser: RuleParser = None
# 媒体信息
media: MediaInfo = None
# 内置规则集
rule_set: Dict[str, dict] = {
@@ -37,8 +38,12 @@ class FilterModule(_ModuleBase):
},
# 中字
"CNSUB": {
"include": [r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
"exclude": []
"include": [
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
"exclude": [],
"tmdb": {
"original_language": "zh,cn"
}
},
# 特效字幕
"SPECSUB": {
@@ -91,7 +96,7 @@ class FilterModule(_ModuleBase):
},
# 国语配音
"CNVOI": {
"include": [r'[国國][语語]配音|[国國]配'],
"include": [r'[国國][语語]配音|[国國]配|[国國][语語]'],
"exclude": []
}
}
@@ -107,16 +112,19 @@ class FilterModule(_ModuleBase):
def filter_torrents(self, rule_string: str,
torrent_list: List[TorrentInfo],
season_episodes: Dict[int, list] = None) -> List[TorrentInfo]:
season_episodes: Dict[int, list] = None,
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
"""
过滤种子资源
:param rule_string: 过滤规则
:param torrent_list: 资源列表
:param season_episodes: 季集数过滤 {season:[episodes]}
:param mediainfo: 媒体信息
:return: 过滤后的资源列表,添加资源优先级
"""
if not rule_string:
return torrent_list
self.media = mediainfo
# 返回种子列表
ret_torrents = []
for torrent in torrent_list:
@@ -215,6 +223,11 @@ class FilterModule(_ModuleBase):
if not self.rule_set.get(rule_name):
# 规则不存在
return False
# TMDB规则
tmdb = self.rule_set[rule_name].get("tmdb")
# 符合TMDB规则的直接返回True即不过滤
if tmdb and self.__match_tmdb(tmdb):
return True
# 包含规则项
includes = self.rule_set[rule_name].get("include") or []
# 排除规则项
@@ -236,3 +249,44 @@ class FilterModule(_ModuleBase):
# FREE规则不匹配
return False
return True
def __match_tmdb(self, tmdb: dict) -> bool:
"""
判断种子是否匹配TMDB规则
"""
def __get_media_value(key: str):
try:
return getattr(self.media, key)
except ValueError:
return ""
if not self.media:
return False
for attr, value in tmdb.items():
if not value:
continue
# 获取media信息的值
info_value = __get_media_value(attr)
if not info_value:
# 没有该值,不匹配
return False
elif attr == "production_countries":
# 国家信息
info_values = [str(val.get("iso_3166_1")).upper() for val in info_value]
else:
# media信息转化为数组
if isinstance(info_value, list):
info_values = [str(val).upper() for val in info_value]
else:
info_values = [str(info_value).upper()]
# 过滤值转化为数组
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
else:
values = [str(value).upper()]
# 没有交集为不匹配
if not set(values).intersection(set(info_values)):
return False
return True

View File

@@ -6,6 +6,7 @@ from ruamel.yaml import CommentedMap
from app.core.context import MediaInfo, TorrentInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.mtorrent import MTorrentSpider
from app.modules.indexer.spider import TorrentSpider
from app.modules.indexer.tnode import TNodeSpider
from app.modules.indexer.torrentleech import TorrentLeech
@@ -74,6 +75,12 @@ class IndexerModule(_ModuleBase):
keyword=search_word,
page=page
)
elif site.get('parser') == "mTorrent":
error_flag, result_array = MTorrentSpider(site).search(
keyword=search_word,
mtype=mediainfo.type if mediainfo else None,
page=page
)
else:
error_flag, result_array = self.__spider_search(
keyword=search_word,

View File

@@ -0,0 +1,144 @@
import base64
import json
import re
from typing import Tuple, List
from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class MTorrentSpider:
_indexerid = None
_domain = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 100
_searchurl = "%sapi/torrent/search"
_downloadurl = "%sapi/torrent/genDlToken"
_pageurl = "%sdetail/%s"
# 电影分类
_movie_category = ['401', '419', '420', '421', '439', '405', '404']
_tv_category = ['403', '402', '435', '438', '404', '405']
# 标签
_labels = {
0: "",
4: "中字",
6: "国配",
}
def __init__(self, indexer: CommentedMap):
if indexer:
self._indexerid = indexer.get('id')
self._domain = indexer.get('domain')
self._searchurl = self._searchurl % self._domain
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
if not mtype:
categories = []
elif mtype == MediaType.TV:
categories = self._tv_category
else:
categories = self._movie_category
params = {
"keyword": keyword,
"categories": categories,
"pageNumber": int(page) + 1,
"pageSize": self._size,
"visible": 1
}
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}"
},
cookies=self._cookie,
proxies=self._proxy,
referer=f"{self._domain}browse",
timeout=30
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:
results = res.json().get('data', {}).get("data") or []
for result in results:
torrent = {
'title': result.get('name'),
'description': result.get('smallDescr'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.format_timestamp(result.get('createdDate')),
'size': result.get('size'),
'seeders': result.get('status', {}).get("seeders"),
'peers': result.get('status', {}).get("leechers"),
'grabs': result.get('status', {}).get("timesCompleted"),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")),
'page_url': self._pageurl % (self._domain, result.get('id')),
'imdbid': self.__find_imdbid(result.get('imdb')),
'labels': [self._labels.get(result.get('labels') or 0)] if result.get('labels') else []
}
torrents.append(torrent)
elif res is not None:
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
return False, torrents
@staticmethod
def __find_imdbid(imdb: str) -> str:
if imdb:
m = re.search(r"tt\d+", imdb)
if m:
return m.group(0)
return ""
@staticmethod
def __get_downloadvolumefactor(discount: str) -> float:
discount_dict = {
"FREE": 0,
"PERCENT_50": 0.5,
"PERCENT_70": 0.3,
"_2X_FREE": 0,
"_2X_PERCENT_50": 0.5
}
if discount:
return discount_dict.get(discount, 1)
return 1
@staticmethod
def __get_uploadvolumefactor(discount: str) -> float:
uploadvolumefactor_dict = {
"_2X": 2.0,
"_2X_FREE": 2.0,
"_2X_PERCENT_50": 2.0
}
if discount:
return uploadvolumefactor_dict.get(discount, 1)
return 1
def __get_download_url(self, torrent_id: str) -> str:
url = self._downloadurl % self._domain
params = {
'method': 'post',
'params': {
'id': torrent_id
},
'result': 'data'
}
# base64编码
base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')
return f"[{base64_str}]{url}"

View File

@@ -1,4 +1,3 @@
import json
from pathlib import Path
from typing import Optional, Tuple, Union, Any, List, Generator
@@ -26,7 +25,7 @@ class JellyfinModule(_ModuleBase):
"""
# 定时重连
if not self.jellyfin.is_inactive():
self.jellyfin = Jellyfin()
self.jellyfin.reconnect()
def stop(self):
pass
@@ -41,7 +40,7 @@ class JellyfinModule(_ModuleBase):
# Jellyfin认证
return self.jellyfin.authenticate(name, password)
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
"""
解析Webhook报文体
:param body: 请求体
@@ -49,7 +48,7 @@ class JellyfinModule(_ModuleBase):
:param args: 请求参数
:return: 字典解析为消息时需要包含title、text、image
"""
return self.jellyfin.get_webhook_message(json.loads(body))
return self.jellyfin.get_webhook_message(body)
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
"""
@@ -83,32 +82,34 @@ class JellyfinModule(_ModuleBase):
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
return self.jellyfin.refresh_root_library()
self.jellyfin.refresh_root_library()
def media_statistic(self) -> schemas.Statistic:
def media_statistic(self) -> List[schemas.Statistic]:
"""
媒体数量统计
"""
media_statistic = self.jellyfin.get_medias_count()
user_count = self.jellyfin.get_user_count()
return schemas.Statistic(
return [schemas.Statistic(
movie_count=media_statistic.get("MovieCount") or 0,
tv_count=media_statistic.get("SeriesCount") or 0,
episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=user_count or 0
)
)]
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "jellyfin":
return None
librarys = self.jellyfin.get_librarys()
if not librarys:
return []
@@ -120,10 +121,12 @@ class JellyfinModule(_ModuleBase):
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
媒体库项目列表
"""
if server != "jellyfin":
return None
items = self.jellyfin.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
@@ -140,10 +143,13 @@ class JellyfinModule(_ModuleBase):
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
def mediaserver_tv_episodes(self, server: str,
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
"""
获取剧集信息
"""
if server != "jellyfin":
return None
seasoninfo = self.jellyfin.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []

View File

@@ -33,6 +33,13 @@ class Jellyfin(metaclass=Singleton):
return False
return True if not self.user else False
def reconnect(self):
"""
重连
"""
self.user = self.get_user()
self.serverid = self.get_server_id()
def __get_jellyfin_librarys(self) -> List[dict]:
"""
获取Jellyfin媒体库的信息
@@ -387,7 +394,7 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Library/Refresh出错" + str(e))
return False
def get_webhook_message(self, message: dict) -> WebhookEventInfo:
def get_webhook_message(self, body: any) -> Optional[WebhookEventInfo]:
"""
解析Jellyfin报文
{
@@ -450,9 +457,21 @@ class Jellyfin(metaclass=Singleton):
"UserId": "9783d2432b0d40a8a716b6aa46xxxxx"
}
"""
if not body:
return None
try:
message = json.loads(body)
except Exception as e:
logger.debug(f"解析Jellyfin Webhook报文出错" + str(e))
return None
if not message:
return None
logger.info(f"接收到jellyfin webhook{message}")
eventType = message.get('NotificationType')
if not eventType:
return None
eventItem = WebhookEventInfo(
event=message.get('NotificationType', ''),
event=eventType,
channel="jellyfin"
)
eventItem.item_id = message.get('ItemId')

View File

@@ -29,9 +29,9 @@ class PlexModule(_ModuleBase):
"""
# 定时重连
if not self.plex.is_inactive():
self.plex = Plex()
self.plex.reconnect()
def webhook_parser(self, body: Any, form: Any, args: Any) -> WebhookEventInfo:
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
"""
解析Webhook报文体
:param body: 请求体
@@ -39,7 +39,7 @@ class PlexModule(_ModuleBase):
:param args: 请求参数
:return: 字典解析为消息时需要包含title、text、image
"""
return self.plex.get_webhook_message(form.get("payload"))
return self.plex.get_webhook_message(form)
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
"""
@@ -77,7 +77,7 @@ class PlexModule(_ModuleBase):
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> Optional[bool]:
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
@@ -93,24 +93,26 @@ class PlexModule(_ModuleBase):
target_path=file_path
)
]
return self.plex.refresh_library_by_items(items)
self.plex.refresh_library_by_items(items)
def media_statistic(self) -> schemas.Statistic:
def media_statistic(self) -> List[schemas.Statistic]:
"""
媒体数量统计
"""
media_statistic = self.plex.get_medias_count()
return schemas.Statistic(
return [schemas.Statistic(
movie_count=media_statistic.get("MovieCount") or 0,
tv_count=media_statistic.get("SeriesCount") or 0,
episode_count=media_statistic.get("EpisodeCount") or 0,
user_count=1
)
)]
def mediaserver_librarys(self) -> List[schemas.MediaServerLibrary]:
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "plex":
return None
librarys = self.plex.get_librarys()
if not librarys:
return []
@@ -122,10 +124,12 @@ class PlexModule(_ModuleBase):
path=library.get("path")
) for library in librarys]
def mediaserver_items(self, library_id: str) -> Generator:
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
媒体库项目列表
"""
if server != "plex":
return None
items = self.plex.get_items(library_id)
for item in items:
yield schemas.MediaServerItem(
@@ -142,10 +146,13 @@ class PlexModule(_ModuleBase):
path=item.get("path"),
)
def mediaserver_tv_episodes(self, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
def mediaserver_tv_episodes(self, server: str,
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
"""
获取剧集信息
"""
if server != "plex":
return None
seasoninfo = self.plex.get_tv_episodes(item_id=item_id)
if not seasoninfo:
return []

View File

@@ -38,6 +38,17 @@ class Plex(metaclass=Singleton):
return False
return True if not self._plex else False
def reconnect(self):
"""
重连
"""
try:
self._plex = PlexServer(self._host, self._token)
self._libraries = self._plex.library.sections()
except Exception as e:
self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}")
def get_librarys(self):
"""
获取媒体服务器所有媒体库列表
@@ -278,7 +289,7 @@ class Plex(metaclass=Singleton):
if hasattr(lib, "locations") and lib.locations:
for location in lib.locations:
if is_subpath(path, Path(location)):
return lib.key, location
return lib.key, str(path)
except Exception as err:
logger.error(f"查找媒体库出错:{err}")
return "", ""
@@ -353,7 +364,7 @@ class Plex(metaclass=Singleton):
logger.error(f"获取媒体库列表出错:{err}")
yield {}
def get_webhook_message(self, message_str: str) -> WebhookEventInfo:
def get_webhook_message(self, form: any) -> Optional[WebhookEventInfo]:
"""
解析Plex报文
eventItem 字段的含义
@@ -457,9 +468,21 @@ class Plex(metaclass=Singleton):
}
}
"""
message = json.loads(message_str)
if not form:
return None
payload = form.get("payload")
if not payload:
return None
try:
message = json.loads(payload)
except Exception as e:
logger.debug(f"解析plex webhook出错{str(e)}")
return None
eventType = message.get('event')
if not eventType:
return None
logger.info(f"接收到plex webhook{message}")
eventItem = WebhookEventInfo(event=message.get('event', ''), channel="plex")
eventItem = WebhookEventInfo(event=eventType, channel="plex")
if message.get('Metadata'):
if message.get('Metadata', {}).get('type') == 'episode':
eventItem.item_type = "TV"

View File

@@ -34,7 +34,7 @@ class QbittorrentModule(_ModuleBase):
"""
# 定时重连
if self.qbittorrent.is_inactive():
self.qbittorrent = Qbittorrent()
self.qbittorrent.reconnect()
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:

View File

@@ -35,6 +35,12 @@ class Qbittorrent(metaclass=Singleton):
return False
return True if not self.qbc else False
def reconnect(self):
"""
重连
"""
self.qbc = self.__login_qbittorrent()
def __login_qbittorrent(self) -> Optional[Client]:
"""
连接qbittorrent

View File

@@ -0,0 +1,85 @@
from typing import Optional, Union, List, Tuple, Any
from app.core.context import MediaInfo, Context
from app.log import logger
from app.modules import _ModuleBase, checkMessage
from app.modules.synologychat.synologychat import SynologyChat
from app.schemas import MessageChannel, CommingMessage, Notification
class SynologyChatModule(_ModuleBase):
synologychat: SynologyChat = None
def init_module(self) -> None:
self.synologychat = SynologyChat()
def stop(self):
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "synologychat"
def message_parser(self, body: Any, form: Any,
args: Any) -> Optional[CommingMessage]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param body: 请求体
:param form: 表单
:param args: 参数
:return: 渠道、消息体
"""
try:
message: dict = form
if not message:
return None
# 校验token
token = message.get("token")
if not token or not self.synologychat.check_token(token):
return None
# 文本
text = message.get("text")
# 用户ID
user_id = int(message.get("user_id"))
# 获取用户名
user_name = message.get("username")
if text and user_id:
logger.info(f"收到SynologyChat消息userid={user_id}, username={user_name}, text={text}")
return CommingMessage(channel=MessageChannel.SynologyChat,
userid=user_id, username=user_name, text=text)
except Exception as err:
logger.debug(f"解析SynologyChat消息失败{err}")
return None
@checkMessage(MessageChannel.SynologyChat)
def post_message(self, message: Notification) -> None:
"""
发送消息
:param message: 消息体
:return: 成功或失败
"""
self.synologychat.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
@checkMessage(MessageChannel.SynologyChat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
"""
发送媒体信息选择列表
:param message: 消息体
:param medias: 媒体列表
:return: 成功或失败
"""
return self.synologychat.send_meidas_msg(title=message.title, medias=medias,
userid=message.userid)
@checkMessage(MessageChannel.SynologyChat)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
"""
发送种子信息选择列表
:param message: 消息体
:param torrents: 种子列表
:return: 成功或失败
"""
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)

View File

@@ -0,0 +1,203 @@
import json
import re
from typing import Optional, List
from urllib.parse import quote
from threading import Lock
from app.core.config import settings
from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
lock = Lock()
class SynologyChat(metaclass=Singleton):
def __init__(self):
self._req = RequestUtils(content_type="application/x-www-form-urlencoded")
self._webhook_url = settings.SYNOLOGYCHAT_WEBHOOK
self._token = settings.SYNOLOGYCHAT_TOKEN
if self._webhook_url:
self._domain = StringUtils.get_base_url(self._webhook_url)
def check_token(self, token: str) -> bool:
return True if token == self._token else False
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:user_id: 发送消息的目标用户ID为空则发给管理员
"""
if not title and not text:
logger.error("标题和内容不能同时为空")
return False
if not self._webhook_url or not self._token:
return False
try:
# 拼装消息内容
titles = str(title).split('\n')
if len(titles) > 1:
title = titles[0]
if not text:
text = "\n".join(titles[1:])
else:
text = f"%s\n%s" % ("\n".join(titles[1:]), text)
if text:
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
else:
caption = title
payload_data = {'text': quote(caption)}
if image:
payload_data['file_url'] = quote(image)
if userid:
payload_data['user_ids'] = [int(userid)]
else:
userids = self.__get_bot_users()
if not userids:
logger.error("SynologyChat机器人没有对任何用户可见")
return False
payload_data['user_ids'] = userids
return self.__send_request(payload_data)
except Exception as msg_e:
logger.error(f"SynologyChat发送消息错误{str(msg_e)}")
return False
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
"""
发送列表类消息
"""
if not medias:
return False
if not self._webhook_url or not self._token:
return False
try:
if not title or not isinstance(medias, list):
return False
index, image, caption = 1, "", "*%s*" % title
for media in medias:
if not image:
image = media.get_message_image()
if media.vote_average:
caption = "%s\n%s. <%s|%s>\n_%s%s_" % (caption,
index,
media.detail_link,
media.title_year,
f"类型:{media.type.value}",
f"评分:{media.vote_average}")
else:
caption = "%s\n%s. <%s|%s>\n_%s_" % (caption,
index,
media.detail_link,
media.title_year,
f"类型:{media.type.value}")
index += 1
if userid:
userids = [int(userid)]
else:
userids = self.__get_bot_users()
payload_data = {
"text": quote(caption),
"user_ids": userids
}
return self.__send_request(payload_data)
except Exception as msg_e:
logger.error(f"SynologyChat发送消息错误{str(msg_e)}")
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
"""
发送列表消息
"""
if not self._webhook_url or not self._token:
return None
if not torrents:
return False
try:
index, caption = 1, "*%s*" % title
for context in torrents:
torrent = context.torrent_info
site_name = torrent.site_name
meta = MetaInfo(torrent.title, torrent.description)
link = torrent.page_url
title = f"{meta.season_episode} " \
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
title = re.sub(r"\s+", " ", title).strip()
free = torrent.volume_factor
seeder = f"{torrent.seeders}"
description = torrent.description
caption = f"{caption}\n{index}.【{site_name}】<{link}|{title}> " \
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
f"_{description}_"
index += 1
if userid:
userids = [int(userid)]
else:
userids = self.__get_bot_users()
payload_data = {
"text": quote(caption),
"user_ids": userids
}
return self.__send_request(payload_data)
except Exception as msg_e:
logger.error(f"SynologyChat发送消息错误{str(msg_e)}")
return False
def __get_bot_users(self):
"""
查询机器人可见的用户列表
"""
if not self._domain or not self._token:
return []
req_url = f"{self._domain}" \
f"/webapi/entry.cgi?api=SYNO.Chat.External&method=user_list&version=2&token=" \
f"{self._token}"
ret = self._req.get_res(url=req_url)
if ret and ret.status_code == 200:
users = ret.json().get("data", {}).get("users", []) or []
return [user.get("user_id") for user in users]
else:
return []
def __send_request(self, payload_data):
"""
发送消息请求
"""
payload = f"payload={json.dumps(payload_data)}"
ret = self._req.post_res(url=self._webhook_url, data=payload)
if ret and ret.status_code == 200:
result = ret.json()
if result:
errno = result.get('error', {}).get('code')
errmsg = result.get('error', {}).get('errors')
if not errno:
return True
logger.error(f"SynologyChat返回错误{errno}-{errmsg}")
return False
else:
logger.error(f"SynologyChat返回{ret.text}")
return False
elif ret is not None:
logger.error(f"SynologyChat请求失败错误码{ret.status_code},错误原因:{ret.reason}")
return False
else:
logger.error(f"SynologyChat请求失败未获取到返回信息")
return False

View File

@@ -52,7 +52,7 @@ class Telegram(metaclass=Singleton):
定义线程函数来运行 infinity_polling
"""
try:
_bot.infinity_polling(long_polling_timeout=10)
_bot.infinity_polling(long_polling_timeout=30, logger_level=None)
except Exception as err:
logger.error(f"Telegram消息接收服务异常{err}")

View File

@@ -12,6 +12,7 @@ from app.schemas.types import MediaType
from app.utils.common import retry
from app.utils.dom import DomUtils
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class TmdbScraper:
@@ -121,8 +122,25 @@ class TmdbScraper:
except Exception as e:
logger.error(f"{file_path} 刮削失败:{e}")
@staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
def __get_chinese_name(self, person: dict):
"""
获取TMDB别名中的中文名
"""
if not person.get("id"):
return ""
try:
personinfo = self.tmdb.get_person_detail(person.get("id"))
if personinfo:
also_known_as = personinfo.get("also_known_as") or []
if also_known_as:
for name in also_known_as:
if name and StringUtils.is_chinese(name):
return name
except Exception as err:
logger.error(f"获取人物中文名失败:{err}")
return person.get("name") or ""
def __gen_common_nfo(self, mediainfo: MediaInfo, doc, root):
"""
生成公共NFO
"""
@@ -155,18 +173,19 @@ class TmdbScraper:
xoutline.appendChild(doc.createCDATASection(mediainfo.overview or ""))
# 导演
for director in mediainfo.directors:
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
# 获取中文名
cn_name = self.__get_chinese_name(director)
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
# 演员
for actor in mediainfo.actors:
# 获取中文名
cn_name = self.__get_chinese_name(actor)
xactor = DomUtils.add_node(doc, root, "actor")
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
DomUtils.add_node(doc, xactor, "name", cn_name)
DomUtils.add_node(doc, xactor, "type", "Actor")
DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "")
DomUtils.add_node(doc, xactor, "order", actor.get("order") if actor.get("order") is not None else "")
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
DomUtils.add_node(doc, xactor, "thumb", actor.get('image'))
DomUtils.add_node(doc, xactor, "profile", actor.get('profile'))
# 风格
genres = mediainfo.genres or []
for genre in genres:
@@ -307,14 +326,18 @@ class TmdbScraper:
directors = episodeinfo.get("crew") or []
for director in directors:
if director.get("known_for_department") == "Directing":
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
# 获取中文名
cn_name = self.__get_chinese_name(director)
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
# 演员
actors = episodeinfo.get("guest_stars") or []
for actor in actors:
if actor.get("known_for_department") == "Acting":
# 获取中文名
cn_name = self.__get_chinese_name(actor)
xactor = DomUtils.add_node(doc, root, "actor")
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
DomUtils.add_node(doc, xactor, "name", cn_name)
DomUtils.add_node(doc, xactor, "type", "Actor")
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
# 保存文件
@@ -336,6 +359,8 @@ class TmdbScraper:
logger.info(f"图片已保存:{file_path}")
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
except RequestException as err:
raise err
except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{err}")

View File

@@ -1136,6 +1136,26 @@ class TmdbHelper:
def get_person_detail(self, person_id: int) -> dict:
"""
获取人物详情
{
"adult": false,
"also_known_as": [
"Michael Chen",
"Chen He",
"陈赫"
],
"biography": "陈赫xxx",
"birthday": "1985-11-09",
"deathday": null,
"gender": 2,
"homepage": "https://movie.douban.com/celebrity/1313841/",
"id": 1397016,
"imdb_id": "nm4369305",
"known_for_department": "Acting",
"name": "Chen He",
"place_of_birth": "FuzhouFujian ProvinceChina",
"popularity": 9.228,
"profile_path": "/2Bk39zVuoHUNHtpZ7LVg7OgkDd4.jpg"
}
"""
if not self.person:
return {}

View File

@@ -30,6 +30,7 @@ class TMDb(object):
self.__class__._session = requests.Session() if session is None else session
self._remaining = 40
self._reset = None
self._timeout = 15
self.obj_cached = obj_cached
if os.environ.get(self.TMDB_LANGUAGE) is None:
os.environ[self.TMDB_LANGUAGE] = "en-US"
@@ -131,12 +132,14 @@ class TMDb(object):
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
def cached_request(self, method, url, data, json):
return requests.request(method, url, data=data, json=json, proxies=self.proxies)
return requests.request(method, url, data=data, json=json,
timeout=self._timeout, proxies=self.proxies)
def cache_clear(self):
return self.cached_request.cache_clear()
def _request_obj(self, action, params="", call_cached=True, method="GET", data=None, json=None, key=None):
def _request_obj(self, action, params="", call_cached=True,
method="GET", data=None, json=None, key=None):
if self.api_key is None or self.api_key == "":
raise TMDbException("No API key found.")
@@ -151,7 +154,8 @@ class TMDb(object):
if self.cache and self.obj_cached and call_cached and method != "POST":
req = self.cached_request(method, url, data, json)
else:
req = self.__class__._session.request(method, url, data=data, json=json, proxies=self.proxies)
req = self.__class__._session.request(method, url, data=data, json=json,
timeout=self._timeout, proxies=self.proxies)
headers = req.headers

View File

@@ -34,7 +34,7 @@ class TransmissionModule(_ModuleBase):
"""
# 定时重连
if not self.transmission.is_inactive():
self.transmission = Transmission()
self.transmission.reconnect()
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
@@ -131,7 +131,7 @@ class TransmissionModule(_ModuleBase):
title=torrent.name,
path=Path(torrent.download_dir) / torrent.name,
hash=torrent.hashString,
tags=torrent.labels
tags=",".join(torrent.labels or [])
))
elif status == TorrentStatus.DOWNLOADING:
# 获取正在下载的任务

View File

@@ -56,6 +56,12 @@ class Transmission(metaclass=Singleton):
return False
return True if not self.trc else False
def reconnect(self):
"""
重连
"""
self.trc = self.__login_transmission()
def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,
tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]:
"""

View File

@@ -0,0 +1,551 @@
import time
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import eventmanager
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.transferhistory_oper import TransferHistoryOper
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple, Optional
from app.log import logger
from app.schemas import NotificationType, DownloadHistory
from app.schemas.types import EventType
class AutoClean(_PluginBase):
# 插件名称
plugin_name = "定时清理媒体库"
# 插件描述
plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。"
# 插件图标
plugin_icon = "clean.png"
# 主题色
plugin_color = "#3377ed"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "autoclean_"
# 加载顺序
plugin_order = 23
# 可使用的用户级别
auth_level = 2
# 私有属性
_enabled = False
# 任务执行间隔
_cron = None
_type = None
_onlyonce = False
_notify = False
_cleantype = None
_cleanuser = None
_cleandate = None
_downloadhis = None
_transferhis = None
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
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._notify = config.get("notify")
self._cleantype = config.get("cleantype")
self._cleanuser = config.get("cleanuser")
self._cleandate = config.get("cleandate")
# 加载模块
if self._enabled:
self._downloadhis = DownloadHistoryOper(self.db)
self._transferhis = TransferHistoryOper(self.db)
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._cron:
try:
self._scheduler.add_job(func=self.__clean,
trigger=CronTrigger.from_crontab(self._cron),
name="定时清理媒体库")
except Exception as err:
logger.error(f"定时任务配置错误:{err}")
if self._onlyonce:
logger.info(f"定时清理媒体库服务启动,立即运行一次")
self._scheduler.add_job(func=self.__clean, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="定时清理媒体库")
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"onlyonce": False,
"cron": self._cron,
"cleantype": self._cleantype,
"enabled": self._enabled,
"cleanuser": self._cleanuser,
"cleandate": self._cleandate,
"notify": self._notify,
})
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def __clean(self):
"""
定时清理媒体库
"""
if not self._cleandate:
logger.error("未配置清理媒体库时间,停止运行")
return
# 清理日期
current_time = datetime.now()
days_ago = current_time - timedelta(days=int(self._cleandate))
clean_date = days_ago.strftime("%Y-%m-%d")
# 查询用户清理日期之后的下载历史
if not self._cleanuser:
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date)
logger.info(f'获取到日期 {clean_date} 之后的下载历史 {len(downloadhis_list)}')
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list)
else:
for userid in str(self._cleanuser).split(","):
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
userid=userid)
logger.info(
f'获取到用户 {userid} 日期 {clean_date} 之后的下载历史 {len(downloadhis_list)}')
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list, userid=userid)
def __clean_history(self, date: str, downloadhis_list: List[DownloadHistory], userid: str = None):
"""
清理下载历史、转移记录
"""
if not downloadhis_list:
logger.warn(f"未获取到日期 {date} 之后的下载记录,停止运行")
return
# 读取历史记录
history = self.get_data('history') or []
# 创建一个字典来保存分组结果
downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list)
# 遍历DownloadHistory对象列表
for downloadhis in downloadhis_list:
# 获取type和tmdbid的值
dtype = downloadhis.type
tmdbid = downloadhis.tmdbid
# 将DownloadHistory对象添加到对应分组的列表中
downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis)
# 输出分组结果
for key, downloadhis_list in downloadhis_grouped_dict.items():
logger.info(f"开始清理 {key}")
del_transferhis_cnt = 0
del_media_name = downloadhis_list[0].title
del_media_user = downloadhis_list[0].userid
del_media_type = downloadhis_list[0].type
del_media_year = downloadhis_list[0].year
del_media_season = downloadhis_list[0].seasons
del_media_episode = downloadhis_list[0].episodes
del_image = downloadhis_list[0].image
for downloadhis in downloadhis_list:
if not downloadhis.download_hash:
logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash跳过处理')
continue
# 根据hash获取转移记录
transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash)
if not transferhis_list:
logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理")
continue
for history in transferhis_list:
# 册除媒体库文件
if str(self._cleantype == "dest") or str(self._cleantype == "all"):
TransferChain(self.db).delete_files(Path(history.dest))
# 删除记录
self._transferhis.delete(history.id)
# 删除源文件
if str(self._cleantype == "src") or str(self._cleantype == "all"):
TransferChain(self.db).delete_files(Path(history.src))
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
{
"src": history.src
}
)
# 累加删除数量
del_transferhis_cnt += len(transferhis_list)
# 发送消息
if self._notify:
self.post_message(
mtype=NotificationType.MediaServer,
title="【定时清理媒体库任务完成】",
text=f"清理媒体名称 {del_media_name}\n"
f"下载媒体用户 {del_media_user}\n"
f"删除历史记录 {del_transferhis_cnt}",
userid=userid)
history.append({
"type": del_media_type,
"title": del_media_name,
"year": del_media_year,
"season": del_media_season,
"episode": del_media_episode,
"image": del_image,
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
})
# 保存历史
self.save_data("history", history)
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': 'onlyonce',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '开启通知',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '0 0 ? ? ?'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'cleantype',
'label': '清理方式',
'items': [
{'title': '媒体库文件', 'value': 'dest'},
{'title': '源文件', 'value': 'src'},
{'title': '所有文件', 'value': 'all'},
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cleandate',
'label': '清理媒体日期',
'placeholder': '清理多少天之前的下载记录(天)'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cleanuser',
'label': '清理下载用户',
'placeholder': '多个用户,分割'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"notify": False,
"cleantype": "dest",
"cron": "",
"cleanuser": "",
"cleandate": 30
}
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('del_time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
htype = history.get("type")
title = history.get("title")
year = history.get("year")
season = history.get("season")
episode = history.get("episode")
image = history.get("image")
del_time = history.get("del_time")
if season:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'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'集:{episode}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
else:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'年份:{year}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
contents.append(
{
'component': 'VCard',
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': image,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': sub_contents
}
]
}
]
}
)
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))

View File

@@ -14,6 +14,7 @@ from ruamel.yaml import CommentedMap
from app import schemas
from app.core.config import settings
from app.core.event import EventManager, eventmanager, Event
from app.db.models.site import Site
from app.helper.browser import PlaywrightHelper
from app.helper.cloudflare import under_challenge
from app.helper.module import ModuleHelper
@@ -85,11 +86,18 @@ class AutoSignIn(_PluginBase):
self._onlyonce = config.get("onlyonce")
self._notify = config.get("notify")
self._queue_cnt = config.get("queue_cnt") or 5
self._sign_sites = config.get("sign_sites")
self._login_sites = config.get("login_sites")
self._sign_sites = config.get("sign_sites") or []
self._login_sites = config.get("login_sites") or []
self._retry_keyword = config.get("retry_keyword")
self._clean = config.get("clean")
# 过滤掉已删除的站点
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")]
self._sign_sites = [site.get("id") for site in all_sites if site.get("id") in self._sign_sites]
self._login_sites = [site.get("id") for site in all_sites if site.get("id") in self._login_sites]
# 保存配置
self.__update_config()
# 加载模块
if self._enabled or self._onlyonce:
@@ -237,8 +245,8 @@ class AutoSignIn(_PluginBase):
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 站点的可选项
site_options = [{"title": site.get("name"), "value": site.get("id")}
for site in self.sites.get_indexers()]
site_options = [{"title": site.name, "value": site.id}
for site in Site.list_order_by_pri(self.db)]
return [
{
'component': 'VForm',
@@ -592,11 +600,6 @@ class AutoSignIn(_PluginBase):
# 今日没数据
if not today_history or self._clean:
logger.info(f"今日 {today}{type},开始{type}已选站点")
# 过滤删除的站点
if type == "签到":
self._sign_sites = [site.get("id") for site in do_sites if site]
if type == "登录":
self._login_sites = [site.get("id") for site in do_sites if site]
if self._clean:
# 关闭开关
self._clean = False
@@ -946,30 +949,25 @@ class AutoSignIn(_PluginBase):
site_id = event.event_data.get("site_id")
config = self.get_config()
if config:
sign_sites = config.get("sign_sites")
if sign_sites:
if isinstance(sign_sites, str):
sign_sites = [sign_sites]
self._sign_sites = self.__remove_site_id(config.get("sign_sites") or [], site_id)
self._login_sites = self.__remove_site_id(config.get("login_sites") or [], site_id)
# 保存配置
self.__update_config()
# 删除对应站点
if site_id:
sign_sites = [site for site in sign_sites if int(site) != int(site_id)]
else:
# 清空
sign_sites = []
def __remove_site_id(self, do_sites, site_id):
if do_sites:
if isinstance(do_sites, str):
do_sites = [do_sites]
# 若无站点,则停止
if len(sign_sites) == 0:
self._enabled = False
# 删除对应站点
if site_id:
do_sites = [site for site in do_sites if int(site) != int(site_id)]
else:
# 清空
do_sites = []
# 保存配置
self.update_config(
{
"enabled": self._enabled,
"notify": self._notify,
"cron": self._cron,
"onlyonce": self._onlyonce,
"queue_cnt": self._queue_cnt,
"sign_sites": sign_sites
}
)
# 若无站点,则停止
if len(do_sites) == 0:
self._enabled = False
return do_sites

View File

@@ -131,130 +131,130 @@ class BestFilmVersion(_PluginBase):
拼装插件配置页面需要返回两块数据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': 'notify',
'label': '发送通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'only_once',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'webhook_enabled',
'label': 'Webhook',
}
}
]
}
]
},
{
'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': {
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式两者只能选其一'
'Webhook需要在媒体服务器设置发送Webhook报文。'
'Plex使用主动获取时建议执行周期设置大于1小时'
'收藏Api调用Plex官网接口有频率限制。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": False,
"cron": "*/30 * * * *",
"webhook_enabled": False,
"only_once": False
}
{
'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': 'only_once',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'webhook_enabled',
'label': 'Webhook',
}
}
]
}
]
},
{
'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': {
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式两者只能选其一'
'Webhook需要在媒体服务器设置发送Webhook报文。'
'Plex使用主动获取时建议执行周期设置大于1小时'
'收藏Api调用Plex官网接口有频率限制。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": False,
"cron": "*/30 * * * *",
"webhook_enabled": False,
"only_once": False
}
def get_page(self) -> List[dict]:
"""
@@ -386,81 +386,85 @@ class BestFilmVersion(_PluginBase):
# 读取历史记录
history = self.get_data('history') or []
all_item = []
# 媒体服务器类型,多个以,分隔
if not settings.MEDIASERVER:
return
media_servers = settings.MEDIASERVER.split(',')
# 读取收藏
if settings.MEDIASERVER == 'jellyfin':
self.jellyfin_get_items(all_item)
elif settings.MEDIASERVER == 'emby':
self.emby_get_items(all_item)
else:
resp = self.plex_get_watchlist()
if not resp:
return
all_item.extend(resp)
all_items = {}
for media_server in media_servers:
if media_server == 'jellyfin':
all_items['jellyfin'] = self.jellyfin_get_items()
elif media_server == 'emby':
all_items['emby'] = self.emby_get_items()
else:
all_items['plex'] = self.plex_get_watchlist()
def function(y, x):
return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1]
# all_item 根据电影名去重
result = reduce(function, all_item, [])
for data in result:
# 检查缓存
if data.get('Name') in caches:
continue
# 获取详情
if settings.MEDIASERVER == 'jellyfin':
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
elif settings.MEDIASERVER == 'emby':
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
else:
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
logger.info(f'BestFilmVersion插件 item打印 {item_info_resp}')
if not item_info_resp:
continue
# 只接受Movie类型
if data.get('Type') != 'Movie':
continue
# 获取tmdb_id
media_info_ids = item_info_resp.get('ExternalUrls')
if not media_info_ids:
continue
for media_info_id in media_info_ids:
if 'TheMovieDb' != media_info_id.get('Name'):
# 处理所有结果
for server, all_item in all_items.items():
# all_item 根据电影名去重
result = reduce(function, all_item, [])
for data in result:
# 检查缓存
if data.get('Name') in caches:
continue
tmdb_find_id = str(media_info_id.get('Url')).split('/')
tmdb_find_id.reverse()
tmdb_id = tmdb_find_id[0]
# 识别媒体信息
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")}tmdbID{tmdb_id}')
# 获取详情
if server == 'jellyfin':
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
elif server == 'emby':
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
else:
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
logger.info(f'BestFilmVersion插件 item打印 {item_info_resp}')
if not item_info_resp:
continue
# 添加订阅
self.subscribechain.add(mtype=MediaType.MOVIE,
title=mediainfo.title,
year=mediainfo.year,
tmdbid=mediainfo.tmdb_id,
best_version=True,
username="收藏洗版",
exist_ok=True)
# 加入缓存
caches.append(data.get('Name'))
# 存储历史记录
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
history.append({
"title": mediainfo.title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# 只接受Movie类型
if data.get('Type') != 'Movie':
continue
# 获取tmdb_id
media_info_ids = item_info_resp.get('ExternalUrls')
if not media_info_ids:
continue
for media_info_id in media_info_ids:
if 'TheMovieDb' != media_info_id.get('Name'):
continue
tmdb_find_id = str(media_info_id.get('Url')).split('/')
tmdb_find_id.reverse()
tmdb_id = tmdb_find_id[0]
# 识别媒体信息
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")}tmdbID{tmdb_id}')
continue
# 添加订阅
self.subscribechain.add(mtype=MediaType.MOVIE,
title=mediainfo.title,
year=mediainfo.year,
tmdbid=mediainfo.tmdb_id,
best_version=True,
username="收藏洗版",
exist_ok=True)
# 加入缓存
caches.append(data.get('Name'))
# 存储历史记录
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
history.append({
"title": mediainfo.title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# 保存历史记录
self.save_data('history', history)
# 保存缓存
@@ -468,13 +472,14 @@ class BestFilmVersion(_PluginBase):
finally:
lock.release()
def jellyfin_get_items(self, all_item):
def jellyfin_get_items(self) -> List[dict]:
# 获取所有user
users_url = "{HOST}Users?&apikey={APIKEY}"
users = self.get_users(Jellyfin().get_data(users_url))
if not users:
logger.info(f"bestfilmversion/users_url: {users_url}")
return
return []
all_items = []
for user in users:
# 根据加入日期 降序排序
url = "{HOST}Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
@@ -490,14 +495,16 @@ class BestFilmVersion(_PluginBase):
resp = self.get_items(Jellyfin().get_data(url))
if not resp:
continue
all_item.extend(resp)
all_items.extend(resp)
return all_items
def emby_get_items(self, all_item):
def emby_get_items(self) -> List[dict]:
# 获取所有user
get_users_url = "{HOST}Users?&api_key={APIKEY}"
users = self.get_users(Emby().get_data(get_users_url))
if not users:
return
return []
all_items = []
for user in users:
# 根据加入日期 降序排序
url = "{HOST}emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
@@ -512,7 +519,8 @@ class BestFilmVersion(_PluginBase):
resp = self.get_items(Emby().get_data(url))
if not resp:
continue
all_item.extend(resp)
all_items.extend(resp)
return all_items
@staticmethod
def get_items(resp: Response):
@@ -538,7 +546,7 @@ class BestFilmVersion(_PluginBase):
return []
@staticmethod
def plex_get_watchlist():
def plex_get_watchlist() -> List[dict]:
# 根据加入日期 降序排序
url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \
f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \

View File

@@ -43,7 +43,7 @@ class BrushFlow(_PluginBase):
# 加载顺序
plugin_order = 21
# 可使用的用户级别
auth_level = 3
auth_level = 2
# 私有属性
siteshelper = None
@@ -120,6 +120,9 @@ class BrushFlow(_PluginBase):
self.save_data("statistic", {})
# 清除种子记录
self.save_data("torrents", {})
# 关闭一次性开关
self._clear_task = False
self.__update_config()
# 停止现有任务
self.stop_service()
@@ -729,6 +732,7 @@ class BrushFlow(_PluginBase):
"enabled": False,
"notify": True,
"onlyonce": False,
"clear_task": False,
"freeleech": "free"
}
@@ -1218,7 +1222,8 @@ class BrushFlow(_PluginBase):
"seed_inactivetime": self._seed_inactivetime,
"up_speed": self._up_speed,
"dl_speed": self._dl_speed,
"save_path": self._save_path
"save_path": self._save_path,
"clear_task": self._clear_task
})
def brush(self):
@@ -1265,12 +1270,6 @@ class BrushFlow(_PluginBase):
f"{task.get('site_name')}{task.get('title')}" for task in task_info.values()
]:
continue
# 保种体积GB 促销
if self._disksize \
and (torrents_size + torrent.size) > float(self._disksize) * 1024**3:
logger.warn(f"当前做种体积 {StringUtils.str_filesize(torrents_size)} "
f"已超过保种体积 {self._disksize},停止新增任务")
break
# 促销
if self._freeleech and torrent.downloadvolumefactor != 0:
continue
@@ -1349,6 +1348,12 @@ class BrushFlow(_PluginBase):
logger.warn(f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)} "
f"已达到最大值 {self._maxdlspeed} KB/s暂时停止新增任务")
break
# 保种体积GB
if self._disksize \
and (torrents_size + torrent.size) > float(self._disksize) * 1024**3:
logger.warn(f"当前做种体积 {StringUtils.str_filesize(torrents_size)} "
f"已超过保种体积 {self._disksize},停止新增任务")
break
# 添加下载任务
hash_string = self.__download(torrent=torrent)
if not hash_string:
@@ -1767,19 +1772,22 @@ class BrushFlow(_PluginBase):
"""
发送删除种子的消息
"""
if self._notify:
self.chain.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"【刷流任务删种】",
text=f"站点:{site_name}\n"
f"标题{torrent_title}\n"
f"原因{reason}"
))
if not self._notify:
return
self.chain.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"【刷流任务删种】",
text=f"站点{site_name}\n"
f"标题{torrent_title}\n"
f"原因:{reason}"
))
def __send_add_message(self, torrent: TorrentInfo):
"""
发送添加下载的消息
"""
if not self._notify:
return
msg_text = ""
if torrent.site_name:
msg_text = f"站点:{torrent.site_name}"
@@ -1819,25 +1827,29 @@ class BrushFlow(_PluginBase):
def __get_downloader_info(self) -> schemas.DownloaderInfo:
"""
获取下载器实时信息
获取下载器实时信息(所有下载器)
"""
if self._downloader == "qbittorrent":
# 调用Qbittorrent API查询实时信息
ret_info = schemas.DownloaderInfo()
# Qbittorrent
if self.qb:
info = self.qb.transfer_info()
return schemas.DownloaderInfo(
download_speed=info.get("dl_info_speed"),
upload_speed=info.get("up_info_speed"),
download_size=info.get("dl_info_data"),
upload_size=info.get("up_info_data")
)
else:
if info:
ret_info.download_speed += info.get("dl_info_speed")
ret_info.upload_speed += info.get("up_info_speed")
ret_info.download_size += info.get("dl_info_data")
ret_info.upload_size += info.get("up_info_data")
# Transmission
if self.tr:
info = self.tr.transfer_info()
return schemas.DownloaderInfo(
download_speed=info.download_speed,
upload_speed=info.upload_speed,
download_size=info.current_stats.downloaded_bytes,
upload_size=info.current_stats.uploaded_bytes
)
if info:
ret_info.download_speed += info.download_speed
ret_info.upload_speed += info.upload_speed
ret_info.download_size += info.current_stats.downloaded_bytes
ret_info.upload_size += info.current_stats.uploaded_bytes
return ret_info
def __get_downloading_count(self) -> int:
"""

View File

@@ -4,7 +4,7 @@ from typing import List, Tuple, Dict, Any
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import TransferInfo
@@ -183,7 +183,7 @@ class ChineseSubFinder(_PluginBase):
pass
@eventmanager.register(EventType.TransferComplete)
def download(self, event):
def download(self, event: Event):
"""
调用ChineseSubFinder下载字幕
"""

View File

@@ -1,12 +1,17 @@
import os
import subprocess
import time
import zipfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Tuple, Dict, Any
import pytz
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from python_hosts import Hosts, HostsEntry
from requests import Response
from app.core.config import settings
from app.log import logger
@@ -79,20 +84,25 @@ class CloudflareSpeedTest(_PluginBase):
if self.get_state() or self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self.get_state() and self._cron:
logger.info(f"Cloudflare CDN优选服务启动周期{self._cron}")
self._scheduler.add_job(func=self.__cloudflareSpeedTest,
trigger=CronTrigger.from_crontab(self._cron),
name="Cloudflare优选")
try:
if self.get_state() and self._cron:
logger.info(f"Cloudflare CDN优选服务启动周期{self._cron}")
self._scheduler.add_job(func=self.__cloudflareSpeedTest,
trigger=CronTrigger.from_crontab(self._cron),
name="Cloudflare优选")
if self._onlyonce:
logger.info(f"Cloudflare CDN优选服务启动立即运行一次")
self._scheduler.add_job(func=self.__cloudflareSpeedTest, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="Cloudflare优选")
# 关闭一次性开关
self._onlyonce = False
self.__update_config()
if self._onlyonce:
logger.info(f"Cloudflare CDN优选服务启动立即运行一次")
self._scheduler.add_job(func=self.__cloudflareSpeedTest, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="Cloudflare优选")
# 关闭一次性开关
self._onlyonce = False
self.__update_config()
except Exception as err:
logger.error(f"Cloudflare CDN优选服务出错{str(err)}")
self.systemmessage.put(f"Cloudflare CDN优选服务出错{str(err)}")
return
# 启动任务
if self._scheduler.get_jobs():
@@ -142,13 +152,35 @@ class CloudflareSpeedTest(_PluginBase):
if err_flag:
logger.info("正在进行CLoudflare CDN优选请耐心等待")
# 执行优选命令,-dd不测速
cf_command = f'cd {self._cf_path} && chmod a+x {self._binary_name} && ./{self._binary_name} {self._additional_args} -o {self._result_file}' + (
f' -f {self._cf_ipv4}' if self._ipv4 else '') + (f' -f {self._cf_ipv6}' if self._ipv6 else '')
if SystemUtils.is_windows():
cf_command = f'cd \"{self._cf_path}\" && CloudflareST {self._additional_args} -o \"{self._result_file}\"' + (
f' -f \"{self._cf_ipv4}\"' if self._ipv4 else '') + (f' -f \"{self._cf_ipv6}\"' if self._ipv6 else '')
else:
cf_command = f'cd {self._cf_path} && chmod a+x {self._binary_name} && ./{self._binary_name} {self._additional_args} -o {self._result_file}' + (
f' -f {self._cf_ipv4}' if self._ipv4 else '') + (f' -f {self._cf_ipv6}' if self._ipv6 else '')
logger.info(f'正在执行优选命令 {cf_command}')
os.system(cf_command)
if SystemUtils.is_windows():
process = subprocess.Popen(cf_command, shell=True)
# 执行命令后无法退出 采用异步和设置超时方案
# 设置超时时间为120秒
if cf_command.__contains__("-dd"):
time.sleep(120)
else:
time.sleep(600)
# 如果没有在120秒内完成任务那么杀死该进程
if process.poll() is None:
os.system('taskkill /F /IM CloudflareST.exe')
else:
os.system(cf_command)
# 获取优选后最优ip
best_ip = SystemUtils.execute("sed -n '2,1p' " + self._result_file + " | awk -F, '{print $1}'")
if SystemUtils.is_windows():
powershell_command = f"powershell.exe -Command \"Get-Content \'{self._result_file}\' | Select-Object -Skip 1 -First 1 | Write-Output\""
logger.info(f'正在执行powershell命令 {powershell_command}')
best_ip = SystemUtils.execute(powershell_command)
best_ip = best_ip.split(',')[0]
else:
best_ip = SystemUtils.execute("sed -n '2,1p' " + self._result_file + " | awk -F, '{print $1}'")
logger.info(f"\n获取到最优ip==>[{best_ip}]")
# 替换自定义Hosts插件数据库hosts
@@ -246,7 +278,10 @@ class CloudflareSpeedTest(_PluginBase):
# 是否重新安装
if self._re_install:
install_flag = True
os.system(f'rm -rf {self._cf_path}')
if SystemUtils.is_windows():
os.system(f'rd /s /q \"{self._cf_path}\"')
else:
os.system(f'rm -rf {self._cf_path}')
logger.info(f'删除CloudflareSpeedTest目录 {self._cf_path},开始重新安装')
# 判断目录是否存在
@@ -277,7 +312,8 @@ class CloudflareSpeedTest(_PluginBase):
# 重装后数据库有版本数据,但是本地没有则重装
if not install_flag and release_version == self._version and not Path(
f'{self._cf_path}/{self._binary_name}').exists():
f'{self._cf_path}/{self._binary_name}').exists() and not Path(
f'{self._cf_path}/CloudflareST.exe').exists():
logger.warn(f"未检测到CloudflareSpeedTest本地版本重新安装")
install_flag = True
@@ -287,9 +323,11 @@ class CloudflareSpeedTest(_PluginBase):
# 检查环境、安装
if SystemUtils.is_windows():
# todo
logger.error(f"CloudflareSpeedTest暂不支持windows平台")
return False, None
# windows
cf_file_name = 'CloudflareST_windows_amd64.zip'
download_url = f'{self._release_prefix}/{release_version}/{cf_file_name}'
return self.__os_install(download_url, cf_file_name, release_version,
f"ditto -V -x -k --sequesterRsrc {self._cf_path}/{cf_file_name} {self._cf_path}")
elif SystemUtils.is_macos():
# mac
uname = SystemUtils.execute('uname -m')
@@ -317,14 +355,31 @@ class CloudflareSpeedTest(_PluginBase):
proxies = settings.PROXY
https_proxy = proxies.get("https") if proxies and proxies.get("https") else None
if https_proxy:
os.system(
f'wget -P {self._cf_path} --no-check-certificate -e use_proxy=yes -e https_proxy={https_proxy} {download_url}')
if SystemUtils.is_windows():
self.__get_windows_cloudflarest(download_url, proxies)
else:
os.system(
f'wget -P {self._cf_path} --no-check-certificate -e use_proxy=yes -e https_proxy={https_proxy} {download_url}')
else:
os.system(f'wget -P {self._cf_path} https://ghproxy.com/{download_url}')
if SystemUtils.is_windows():
self.__get_windows_cloudflarest(download_url, proxies)
else:
os.system(f'wget -P {self._cf_path} https://ghproxy.com/{download_url}')
# 判断是否下载好安装包
if Path(f'{self._cf_path}/{cf_file_name}').exists():
try:
if SystemUtils.is_windows():
with zipfile.ZipFile(f'{self._cf_path}/{cf_file_name}', 'r') as zip_ref:
# 解压ZIP文件中的所有文件到指定目录
zip_ref.extractall(self._cf_path)
if Path(f'{self._cf_path}\\CloudflareST.exe').exists():
logger.info(f"CloudflareSpeedTest安装成功当前版本{release_version}")
return True, release_version
else:
logger.error(f"CloudflareSpeedTest安装失败请检查")
os.system(f'rd /s /q \"{self._cf_path}\"')
return False, None
# 解压
os.system(f'{unzip_command}')
# 删除压缩包
@@ -338,23 +393,42 @@ class CloudflareSpeedTest(_PluginBase):
return False, None
except Exception as err:
# 如果升级失败但是有可执行文件CloudflareST则可继续运行反之停止
if Path(f'{self._cf_path}/{self._binary_name}').exists():
if Path(f'{self._cf_path}/{self._binary_name}').exists() or \
Path(f'{self._cf_path}\\CloudflareST.exe').exists():
logger.error(f"CloudflareSpeedTest安装失败{str(err)},继续使用现版本运行")
return True, None
else:
logger.error(f"CloudflareSpeedTest安装失败{str(err)},无可用版本,停止运行")
os.removedirs(self._cf_path)
if SystemUtils.is_windows():
os.system(f'rd /s /q \"{self._cf_path}\"')
else:
os.removedirs(self._cf_path)
return False, None
else:
# 如果升级失败但是有可执行文件CloudflareST则可继续运行反之停止
if Path(f'{self._cf_path}/{self._binary_name}').exists():
if Path(f'{self._cf_path}/{self._binary_name}').exists() or \
Path(f'{self._cf_path}\\CloudflareST.exe').exists():
logger.warn(f"CloudflareSpeedTest安装失败存在可执行版本继续运行")
return True, None
else:
logger.error(f"CloudflareSpeedTest安装失败无可用版本停止运行")
os.removedirs(self._cf_path)
if SystemUtils.is_windows():
os.system(f'rd /s /q \"{self._cf_path}\"')
else:
os.removedirs(self._cf_path)
return False, None
def __get_windows_cloudflarest(self, download_url, proxies):
response = Response()
try:
response = requests.get(download_url, stream=True, proxies=proxies if proxies else None)
except requests.exceptions.RequestException as e:
logger.error(f"CloudflareSpeedTest下载失败{str(e)}")
if response.status_code == 200:
with open(f'{self._cf_path}\\CloudflareST_windows_amd64.zip', 'wb') as file:
for chunk in response.iter_content(chunk_size=8192):
file.write(chunk)
@staticmethod
def __get_release_version():
"""

View File

@@ -12,10 +12,11 @@ from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
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.metainfo import MetaInfo
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
@@ -74,6 +75,7 @@ class DirMonitor(_PluginBase):
transferhis = None
downloadhis = None
transferchian = None
tmdbchain = None
_observer = []
_enabled = False
_notify = False
@@ -93,7 +95,7 @@ class DirMonitor(_PluginBase):
self.transferhis = TransferHistoryOper(self.db)
self.downloadhis = DownloadHistoryOper(self.db)
self.transferchian = TransferChain(self.db)
self.tmdbchain = TmdbChain(self.db)
# 清空配置
self._dirconf = {}
@@ -237,13 +239,8 @@ class DirMonitor(_PluginBase):
logger.info(f"{event_path} 已整理过")
return
# 上级目录元数据
meta = MetaInfo(title=file_path.parent.name)
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=file_path.stem)
# 合并元数据
file_meta.merge(meta)
# 元数据
file_meta = MetaInfoPath(file_path)
if not file_meta.name:
logger.error(f"{file_path.name} 无法识别有效信息")
return
@@ -279,6 +276,13 @@ class DirMonitor(_PluginBase):
# 更新媒体图片
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
# 获取downloadhash
download_hash = self.get_download_hash(src=str(file_path))
@@ -287,12 +291,13 @@ class DirMonitor(_PluginBase):
path=file_path,
transfer_type=self._transfer_type,
target=target,
meta=file_meta)
meta=file_meta,
episodes_info=episodes_info)
if not transferinfo:
logger.error("文件转移模块运行失败")
return
if not transferinfo.target_path:
if not transferinfo.success:
# 转移失败
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
# 新增转移失败历史记录
@@ -343,7 +348,7 @@ class DirMonitor(_PluginBase):
}
"""
# 发送消息汇总
media_list = self._medias.get(mediainfo.title_year + " " + meta.season) or {}
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:
@@ -384,7 +389,7 @@ class DirMonitor(_PluginBase):
],
"time": datetime.now()
}
self._medias[mediainfo.title_year + " " + meta.season] = media_list
self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
# 汇总刷新媒体库
if settings.REFRESH_MEDIASERVER:
@@ -598,7 +603,7 @@ class DirMonitor(_PluginBase):
'rows': 5,
'placeholder': '每一行一个目录,支持两种配置方式:\n'
'监控目录\n'
'监控目录:转移目的目录'
'监控目录:转移目的目录(需同时在媒体库目录中配置该目的目录)'
}
}
]

View File

@@ -0,0 +1,320 @@
from apscheduler.schedulers.background import BackgroundScheduler
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.core.config import settings
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple, Optional, Union
from app.log import logger
from app.schemas import NotificationType, TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, MessageChannel
from app.utils.string import StringUtils
class DownloadingMsg(_PluginBase):
# 插件名称
plugin_name = "下载进度推送"
# 插件描述
plugin_desc = "定时推送正在下载进度。"
# 插件图标
plugin_icon = "downloadmsg.png"
# 主题色
plugin_color = "#3DE75D"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "downloading_"
# 加载顺序
plugin_order = 22
# 可使用的用户级别
auth_level = 2
# 私有属性
_enabled = False
# 任务执行间隔
_seconds = None
_type = None
_adminuser = None
_downloadhis = None
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
if config:
self._enabled = config.get("enabled")
self._seconds = config.get("seconds") or 300
self._type = config.get("type") or 'admin'
self._adminuser = config.get("adminuser")
# 加载模块
if self._enabled:
self._downloadhis = DownloadHistoryOper(self.db)
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._seconds:
try:
self._scheduler.add_job(func=self.__downloading,
trigger='interval',
seconds=int(self._seconds),
name="下载进度推送")
except Exception as err:
logger.error(f"定时任务配置错误:{err}")
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def __downloading(self):
"""
定时推送正在下载进度
"""
# 正在下载种子
torrents = DownloadChain(self.db).list_torrents(status=TorrentStatus.DOWNLOADING)
if not torrents:
logger.info("当前没有正在下载的任务!")
return
# 推送用户
if self._type == "admin" or self._type == "both":
if not self._adminuser:
logger.error("未配置管理员用户")
return
for userid in str(self._adminuser).split(","):
self.__send_msg(torrents=torrents, userid=userid)
if self._type == "user" or self._type == "both":
user_torrents = {}
# 根据正在下载种子hash获取下载历史
for torrent in torrents:
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
if not downloadhis:
logger.warn(f"种子 {torrent.hash} 未获取到MoviePilot下载历史无法推送下载进度")
continue
if not downloadhis.userid:
logger.debug(f"种子 {torrent.hash} 未获取到下载用户记录,无法推送下载进度")
continue
user_torrent = user_torrents.get(downloadhis.userid) or []
user_torrent.append(torrent)
user_torrents[downloadhis.userid] = user_torrent
if not user_torrents or not user_torrents.keys():
logger.warn("未获取到用户下载记录,无法推送下载进度")
return
# 推送用户下载任务进度
for userid in list(user_torrents.keys()):
if not userid:
continue
# 如果用户是管理员,无需重复推送
if self._type == "admin" or self._type == "both" and self._adminuser and userid in str(
self._adminuser).split(","):
logger.debug("管理员已推送")
continue
user_torrent = user_torrents.get(userid)
if not user_torrent:
logger.warn(f"未获取到用户 {userid} 下载任务")
continue
self.__send_msg(torrents=user_torrent,
userid=userid)
if self._type == "all":
self.__send_msg(torrents=torrents)
def __send_msg(self, torrents: Optional[List[Union[TransferTorrent, DownloadingTorrent]]], userid: str = None):
"""
发送消息
"""
title = f"{len(torrents)} 个任务正在下载:"
messages = []
index = 1
channel_value = None
for torrent in torrents:
year = None
name = None
se = None
ep = None
# 先查询下载记录,没有再识别
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
if downloadhis:
name = downloadhis.title
year = downloadhis.year
se = downloadhis.seasons
ep = downloadhis.episodes
if not channel_value:
channel_value = downloadhis.channel
else:
try:
context = MediaChain(self.db).recognize_by_title(title=torrent.title)
if not context or not context.media_info:
continue
media_info = context.media_info
year = media_info.year
name = media_info.title
if media_info.number_of_seasons:
se = f"S{str(media_info.number_of_seasons).rjust(2, '0')}"
if media_info.number_of_episodes:
ep = f"E{str(media_info.number_of_episodes).rjust(2, '0')}"
except Exception as e:
print(str(e))
# 拼装标题
if year:
media_name = "%s (%s) %s%s" % (name, year, se, ep)
elif name:
media_name = "%s %s%s" % (name, se, ep)
else:
media_name = torrent.title
messages.append(f"{index}. {media_name}\n"
f"{torrent.title} "
f"{StringUtils.str_filesize(torrent.size)} "
f"{round(torrent.progress, 1)}%")
index += 1
# 用户消息渠道
if channel_value:
channel = next(
(channel for channel in MessageChannel.__members__.values() if channel.value == channel_value), None)
else:
channel = None
self.post_message(mtype=NotificationType.Download,
channel=channel,
title=title,
text="\n".join(messages),
userid=userid)
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,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'seconds',
'label': '执行间隔',
'placeholder': '单位(秒)'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'adminuser',
'label': '管理员用户',
'placeholder': '多个用户,分割'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'type',
'label': '推送类型',
'items': [
{'title': '管理员', 'value': 'admin'},
{'title': '下载用户', 'value': 'user'},
{'title': '管理员和下载用户', 'value': 'both'},
{'title': '所有用户', 'value': 'all'}
]
}
}
]
}
]
}
]
}
], {
"enabled": False,
"seconds": 300,
"adminuser": "",
"type": "admin"
}
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))

View File

@@ -12,6 +12,9 @@ from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.helper.sites import SitesHelper
from app.core.event import eventmanager
from app.db.models.site import Site
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.modules.qbittorrent import Qbittorrent
@@ -19,6 +22,7 @@ from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper
from app.schemas import NotificationType
from app.schemas.types import EventType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -100,7 +104,7 @@ class IYUUAutoSeed(_PluginBase):
self._cron = config.get("cron")
self._token = config.get("token")
self._downloaders = config.get("downloaders")
self._sites = config.get("sites")
self._sites = config.get("sites") or []
self._notify = config.get("notify")
self._nolabels = config.get("nolabels")
self._nopaths = config.get("nopaths")
@@ -109,6 +113,11 @@ class IYUUAutoSeed(_PluginBase):
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
# 过滤掉已删除的站点
self._sites = [site.get("id") for site in self.sites.get_indexers() if
not site.get("public") and site.get("id") in self._sites]
self.__update_config()
# 停止现有任务
self.stop_service()
@@ -166,8 +175,8 @@ class IYUUAutoSeed(_PluginBase):
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 站点的可选项
site_options = [{"title": site.get("name"), "value": site.get("id")}
for site in self.sites.get_indexers()]
site_options = [{"title": site.name, "value": site.id}
for site in Site.list_order_by_pri(self.db)]
return [
{
'component': 'VForm',
@@ -425,10 +434,6 @@ class IYUUAutoSeed(_PluginBase):
return
logger.info("开始辅种任务 ...")
# 排除已删除站点
self._sites = [site.get("id") for site in self.sites.get_indexers() if
site.get("id") in self._sites]
# 计数器初始化
self.total = 0
self.realtotal = 0
@@ -979,3 +984,31 @@ class IYUUAutoSeed(_PluginBase):
self._scheduler = None
except Exception as e:
print(str(e))
@eventmanager.register(EventType.SiteDeleted)
def site_deleted(self, event):
"""
删除对应站点选中
"""
site_id = event.event_data.get("site_id")
config = self.get_config()
if config:
sites = config.get("sites")
if sites:
if isinstance(sites, str):
sites = [sites]
# 删除对应站点
if site_id:
sites = [site for site in sites if int(site) != int(site_id)]
else:
# 清空
sites = []
# 若无站点,则停止
if len(sites) == 0:
self._enabled = False
self._sites = sites
# 保存配置
self.__update_config()

View File

@@ -2,7 +2,6 @@ import datetime
import json
import os
import re
import shutil
import time
from pathlib import Path
from typing import List, Tuple, Dict, Any, Optional
@@ -10,11 +9,10 @@ from typing import List, Tuple, Dict, Any, Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.transferhistory import TransferHistory
from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
@@ -23,14 +21,13 @@ from app.modules.themoviedb.tmdbv3api import Episode
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.schemas.types import NotificationType, EventType, MediaType
from app.utils.path_utils import PathUtils
class MediaSyncDel(_PluginBase):
# 插件名称
plugin_name = "媒体同步删除"
plugin_name = "媒体文件同步删除"
# 插件描述
plugin_desc = "媒体库删除媒体后同步删除历史记录、源文件和下载任务。"
plugin_desc = "同步删除历史记录、源文件和下载任务。"
# 插件图标
plugin_icon = "mediasyncdel.png"
# 主题色
@@ -58,14 +55,16 @@ class MediaSyncDel(_PluginBase):
_del_source = False
_exclude_path = None
_library_path = None
_transferchain = None
_transferhis = None
_downloadhis = None
qb = None
tr = None
def init_plugin(self, config: dict = None):
self._transferhis = TransferHistoryOper(self.db)
self._downloadhis = DownloadHistoryOper(self.db)
self._transferchain = TransferChain(self.db)
self._transferhis = self._transferchain.transferhis
self._downloadhis = self._transferchain.downloadhis
self.episode = Episode()
self.qb = Qbittorrent()
self.tr = Transmission()
@@ -188,9 +187,9 @@ class MediaSyncDel(_PluginBase):
'component': 'VSelect',
'props': {
'model': 'sync_type',
'label': '同步方式',
'label': '媒体库同步方式',
'items': [
{'title': 'webhook', 'value': 'webhook'},
{'title': 'Webhook', 'value': 'webhook'},
{'title': '日志', 'value': 'log'},
{'title': 'Scripter X', 'value': 'plugin'}
]
@@ -209,7 +208,7 @@ class MediaSyncDel(_PluginBase):
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'label': '日志检查周期',
'placeholder': '5位cron表达式留空自动'
}
}
@@ -247,7 +246,7 @@ class MediaSyncDel(_PluginBase):
'props': {
'model': 'library_path',
'rows': '2',
'label': '媒体库路径',
'label': '媒体库路径映射',
'placeholder': '媒体服务器路径:MoviePilot路径一行一个'
}
}
@@ -267,11 +266,11 @@ class MediaSyncDel(_PluginBase):
{
'component': 'VAlert',
'props': {
'text': '同步方式分为webhook、日志同步和Scripter X'
'webhook需要Emby4.8.0.45及以上开启媒体删除的webhook'
'(建议使用媒体库刮削插件覆盖元数据重新刮削剧集路径)'
'日志同步需要配置执行周期默认30分钟执行一次'
'Scripter X方式需要emby安装并配置Scripter X插件无需配置执行周期'
'text': '媒体库同步方式分为Webhook、日志同步和Scripter X'
'1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook'
'2、日志同步需要配置检查周期默认30分钟执行一次'
'3、Scripter X方式需要emby安装并配置Scripter X插件无需配置执行周期'
'4、启用该插件后非媒体服务器触发的源文件删除也会同步处理下载器中的下载任务'
}
}
]
@@ -534,7 +533,7 @@ class MediaSyncDel(_PluginBase):
episode_num=episode_num)
def __sync_del(self, media_type: str, media_name: str, media_path: str,
tmdb_id: int, season_num: int, episode_num: int):
tmdb_id: int, season_num: str, episode_num: str):
"""
执行删除逻辑
"""
@@ -587,10 +586,7 @@ class MediaSyncDel(_PluginBase):
if self._del_source:
# 1、直接删除源文件
if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
source_name = os.path.basename(transferhis.src)
source_path = str(transferhis.src).replace(source_name, "")
self.delete_media_file(filedir=source_path,
filename=source_name)
self._transferchain.delete_files(Path(transferhis.src))
if transferhis.download_hash:
try:
# 2、判断种子是否被删除完
@@ -654,16 +650,20 @@ class MediaSyncDel(_PluginBase):
self.save_data("history", history)
def __get_transfer_his(self, media_type: str, media_name: str, media_path: str,
tmdb_id: int, season_num: int, episode_num: int):
tmdb_id: int, season_num: str, episode_num: str):
"""
查询转移记录
"""
# 季数
if season_num:
if season_num and season_num.isdigit():
season_num = str(season_num).rjust(2, '0')
else:
season_num = None
# 集数
if episode_num:
if episode_num and episode_num.isdigit():
episode_num = str(episode_num).rjust(2, '0')
else:
episode_num = None
# 类型
mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
@@ -718,19 +718,21 @@ class MediaSyncDel(_PluginBase):
"""
# 读取历史记录
history = self.get_data('history') or []
# 媒体服务器类型
media_server = settings.MEDIASERVER
last_time = self.get_data("last_time")
del_medias = []
if media_server == 'emby':
del_medias = self.parse_emby_log(last_time)
elif media_server == 'jellyfin':
del_medias = self.parse_jellyfin_log(last_time)
elif media_server == 'plex':
# TODO plex解析日志
# 媒体服务器类型,多个以,分隔
if not settings.MEDIASERVER:
return
media_servers = settings.MEDIASERVER.split(',')
for media_server in media_servers:
if media_server == 'emby':
del_medias.extend(self.parse_emby_log(last_time))
elif media_server == 'jellyfin':
del_medias.extend(self.parse_jellyfin_log(last_time))
elif media_server == 'plex':
# TODO plex解析日志
return
if not del_medias:
logger.error("未解析到已删除媒体信息")
@@ -825,10 +827,7 @@ class MediaSyncDel(_PluginBase):
if self._del_source:
# 1、直接删除源文件
if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
source_name = os.path.basename(transferhis.src)
source_path = str(transferhis.src).replace(source_name, "")
self.delete_media_file(filedir=source_path,
filename=source_name)
self._transferchain.delete_files(Path(transferhis.src))
if transferhis.download_hash:
try:
# 2、判断种子是否被删除完
@@ -1187,42 +1186,6 @@ class MediaSyncDel(_PluginBase):
return del_medias
@staticmethod
def delete_media_file(filedir: str, filename: str):
"""
删除媒体文件,空目录也会被删除
"""
filedir = os.path.normpath(filedir).replace("\\", "/")
file = os.path.join(filedir, filename)
try:
if not os.path.exists(file):
return False, f"{file} 不存在"
os.remove(file)
nfoname = f"{os.path.splitext(filename)[0]}.nfo"
nfofile = os.path.join(filedir, nfoname)
if os.path.exists(nfofile):
os.remove(nfofile)
# 检查空目录并删除
if re.findall(r"^S\d{2}|^Season", os.path.basename(filedir), re.I):
# 当前是季文件夹,判断并删除
seaon_dir = filedir
if seaon_dir.count('/') > 1 and not PathUtils.get_dir_files(seaon_dir, exts=settings.RMT_MEDIAEXT):
shutil.rmtree(seaon_dir)
# 媒体文件夹
media_dir = os.path.dirname(seaon_dir)
else:
media_dir = filedir
# 检查并删除媒体文件夹,非根目录且目录大于二级,且没有媒体文件时才会删除
if media_dir != '/' \
and media_dir.count('/') > 1 \
and not re.search(r'[a-zA-Z]:/$', media_dir) \
and not PathUtils.get_dir_files(media_dir, exts=settings.RMT_MEDIAEXT):
shutil.rmtree(media_dir)
return True, f"{file} 删除成功"
except Exception as e:
logger.error("删除源文件失败:%s" % str(e))
return True, f"{file} 删除失败"
def get_state(self):
return self._enabled
@@ -1239,21 +1202,25 @@ class MediaSyncDel(_PluginBase):
except Exception as e:
logger.error("退出插件失败:%s" % str(e))
@eventmanager.register(EventType.MediaDeleted)
def remote_sync_del(self, event: Event):
@eventmanager.register(EventType.DownloadFileDeleted)
def downloadfile_del_sync(self, event: Event):
"""
媒体库同步删除
下载文件删除处理事件
"""
if event:
logger.info("收到命令,开始执行媒体库同步删除 ...")
self.post_message(channel=event.event_data.get("channel"),
title="开始媒体库同步删除 ...",
userid=event.event_data.get("user"))
self.sync_del_by_log()
if event:
self.post_message(channel=event.event_data.get("channel"),
title="媒体库同步删除完成!", userid=event.event_data.get("user"))
if not self._enabled:
return
if not event:
return
event_data = event.event_data
src = event_data.get("src")
if not src:
return
# 查询下载hash
download_hash = self._downloadhis.get_hash_by_fullpath(src)
if download_hash:
self.handle_torrent(src=src, torrent_hash=download_hash)
else:
logger.warn(f"未查询到文件 {src} 对应的下载记录")
@staticmethod
def get_tmdbimage_url(path: str, prefix="w500"):

View File

@@ -325,6 +325,9 @@ class MessageForward(_PluginBase):
logger.info(f"转发消息 {title} 成功")
return True
else:
if ret_json.get('errcode') == 81013:
return False
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
logger.info("token已过期正在重新刷新token重试")

View File

@@ -3,6 +3,7 @@ import os
import sqlite3
from datetime import datetime
from app.core.config import settings
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.plugindata_oper import PluginDataOper
from app.db.transferhistory_oper import TransferHistoryOper
@@ -157,15 +158,16 @@ class NAStoolSync(_PluginBase):
# 替换value
if isinstance(plugin_value, str):
plugin_value = json.loads(plugin_value)
if str(plugin_value.get("to_download")).isdigit() and int(
plugin_value.get("to_download")) == int(sub_downloaders[0]):
plugin_value["to_download"] = sub_downloaders[1]
_value: dict = json.loads(plugin_value)
elif isinstance(plugin_value, dict):
if str(plugin_value.get("to_download")).isdigit() and int(
plugin_value.get("to_download")) == int(sub_downloaders[0]):
plugin_value["to_download"] = sub_downloaders[1]
# 替换辅种记录
if str(plugin_id) == "IYUUAutoSeed":
if isinstance(plugin_value, str):
plugin_value = json.loads(plugin_value)
plugin_value: list = json.loads(plugin_value)
if not isinstance(plugin_value, list):
plugin_value = [plugin_value]
for value in plugin_value:
@@ -213,6 +215,7 @@ class NAStoolSync(_PluginBase):
mtorrent = history[9]
mdesc = history[10]
msite = history[11]
mdate = history[12]
# 处理站点映射
if self._site:
@@ -234,7 +237,9 @@ class NAStoolSync(_PluginBase):
download_hash=mdownload_hash,
torrent_name=mtorrent,
torrent_description=mdesc,
torrent_site=msite
torrent_site=msite,
userid=settings.SUPERUSER,
date=mdate
)
cnt += 1
if cnt % 100 == 0:
@@ -358,7 +363,8 @@ class NAStoolSync(_PluginBase):
DOWNLOAD_ID,
TORRENT,
DESC,
SITE
SITE,
DATE
FROM
DOWNLOAD_HISTORY
WHERE

View File

@@ -0,0 +1,204 @@
from pathlib import Path
from typing import Any, List, Dict, Tuple
from requests import RequestException
from app.chain.tmdb import TmdbChain
from app.core.event import eventmanager, Event
from app.helper.nfo import NfoReader
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import TransferInfo, MediaInfo
from app.schemas.types import EventType, MediaType
from app.utils.common import retry
from app.utils.http import RequestUtils
class PersonMeta(_PluginBase):
# 插件名称
plugin_name = "演职人员刮削"
# 插件描述
plugin_desc = "刮削演职人员图片以及中文名称。"
# 插件图标
plugin_icon = "actor.png"
# 主题色
plugin_color = "#E66E72"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "personmeta_"
# 加载顺序
plugin_order = 24
# 可使用的用户级别
auth_level = 1
# 私有属性
tmdbchain = None
_enabled = False
_metadir = ""
def init_plugin(self, config: dict = None):
self.tmdbchain = TmdbChain(self.db)
if config:
self._enabled = config.get("enabled")
self._metadir = config.get("metadir")
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': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'metadir',
'label': '人物元数据目录',
'placeholder': '/metadata/people'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"metadir": ""
}
def get_page(self) -> List[dict]:
pass
@eventmanager.register(EventType.TransferComplete)
def scrap_rt(self, event: Event):
"""
根据事件实时刮削演员信息
"""
if not self._enabled:
return
# 下载人物头像
if not self._metadir:
logger.warning("人物元数据目录未配置,无法下载人物头像")
return
# 事件数据
mediainfo: MediaInfo = event.event_data.get("mediainfo")
transferinfo: TransferInfo = event.event_data.get("transferinfo")
if not mediainfo or not transferinfo:
return
# 文件路径
filepath = transferinfo.target_path
if not filepath:
return
# 电影
if mediainfo.type == MediaType.MOVIE:
# nfo文件
nfofile = filepath.with_name("movie.nfo")
if not nfofile.exists():
nfofile = filepath.parent / f"{filepath.stem}.nfo"
if not nfofile.exists():
logger.warning(f"电影nfo文件不存在{nfofile}")
return
else:
# nfo文件
nfofile = filepath.parent.with_name("tvshow.nfo")
if not nfofile.exists():
logger.warning(f"剧集nfo文件不存在{nfofile}")
return
# 读取nfo文件
nfo = NfoReader(nfofile)
# 读取演员信息
actors = nfo.get_elements("actor") or []
for actor in actors:
# 演员ID
actor_id = actor.find("id").text
if not actor_id:
continue
# 演员名称
actor_name = actor.find("name").text
# 查询演员详情
actor_info = self.tmdbchain.person_detail(actor_id)
if not actor_info:
continue
# 演员头像
actor_image = actor_info.get("profile_path")
if not actor_image:
continue
# 计算保存路径
image_path = Path(self._metadir) / f"{actor_name}-tmdb-{actor_id}" / f"folder{Path(actor_image).suffix}"
if image_path.exists():
continue
# 下载图片
self.download_image(f"https://image.tmdb.org/t/p/original{actor_image}", image_path)
@staticmethod
@retry(RequestException, logger=logger)
def download_image(image_url: str, path: Path):
"""
下载图片,保存到指定路径
"""
try:
logger.info(f"正在下载演职人员图片:{image_url} ...")
r = RequestUtils().get_res(url=image_url, raise_exception=True)
if r:
path.write_bytes(r.content)
logger.info(f"图片已保存:{path}")
else:
logger.info(f"图片下载失败,请检查网络连通性:{image_url}")
except RequestException as err:
raise err
except Exception as err:
logger.error(f"图片下载失败:{err}")
def stop_service(self):
"""
退出插件
"""
pass

View File

@@ -549,7 +549,7 @@ class RssSubscribe(_PluginBase):
logger.error(f"未获取到RSS数据{url}")
return
# 过滤规则
filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules)
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
# 解析数据
for result in results:
try:
@@ -593,7 +593,8 @@ class RssSubscribe(_PluginBase):
if self._filter:
result = self.chain.filter_torrents(
rule_string=filter_rule,
torrent_list=[torrentinfo]
torrent_list=[torrentinfo],
mediainfo=mediainfo
)
if not result:
logger.info(f"{title} {description} 不匹配过滤规则")

View File

@@ -15,6 +15,7 @@ from app import schemas
from app.core.config import settings
from app.core.event import Event
from app.core.event import eventmanager
from app.db.models.site import Site
from app.helper.browser import PlaywrightHelper
from app.helper.module import ModuleHelper
from app.helper.sites import SitesHelper
@@ -84,6 +85,11 @@ class SiteStatistic(_PluginBase):
self._statistic_type = config.get("statistic_type") or "all"
self._statistic_sites = config.get("statistic_sites") or []
# 过滤掉已删除的站点
self._statistic_sites = [site.get("id") for site in self.sites.get_indexers() if
not site.get("public") and site.get("id") in self._statistic_sites]
self.__update_config()
if self._enabled or self._onlyonce:
# 加载模块
self._site_schema = ModuleHelper.load('app.plugins.sitestatistic.siteuserinfo',
@@ -177,8 +183,8 @@ class SiteStatistic(_PluginBase):
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 站点的可选项
site_options = [{"title": site.get("name"), "value": site.get("id")}
for site in self.sites.get_indexers()]
site_options = [{"title": site.name, "value": site.id}
for site in Site.list_order_by_pri(self.db)]
return [
{
'component': 'VForm',
@@ -1047,10 +1053,6 @@ class SiteStatistic(_PluginBase):
else:
refresh_sites = [site for site in self.sites.get_indexers() if
site.get("id") in self._statistic_sites]
# 过滤掉已删除的站点
self._statistic_sites = [site.get("id") for site in refresh_sites if site]
self.__update_config()
if not refresh_sites:
return

View File

@@ -73,15 +73,19 @@ class SpeedLimiter(_PluginBase):
try:
# 总带宽
self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000
# 自动限速开关
if self._bandwidth > 0:
# 自动限速开关
self._auto_limit = True
else:
self._auto_limit = False
except Exception as e:
logger.error(f"智能限速上行带宽设置错误:{str(e)}")
self._bandwidth = 0
# 限速服务开关
self._limit_enabled = True if self._play_up_speed or self._play_down_speed or self._auto_limit else False
self._limit_enabled = True if (self._play_up_speed
or self._play_down_speed
or self._auto_limit) else False
self._allocation_ratio = config.get("allocation_ratio") or ""
# 不限速地址
self._unlimited_ips["ipv4"] = config.get("ipv4") or ""
@@ -383,78 +387,86 @@ class SpeedLimiter(_PluginBase):
return
# 当前播放的总比特率
total_bit_rate = 0
# 查询播放中会话
playing_sessions = []
if settings.MEDIASERVER == "emby":
req_url = "{HOST}emby/Sessions?api_key={APIKEY}"
try:
res = Emby().get_data(req_url)
if res and res.status_code == 200:
sessions = res.json()
for session in sessions:
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
playing_sessions.append(session)
except Exception as e:
logger.error(f"获取Emby播放会话失败{str(e)}")
# 计算有效比特率
for session in playing_sessions:
# 设置了不限速范围则判断session ip是否在不限速范围内
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
elif settings.MEDIASERVER == "jellyfin":
req_url = "{HOST}Sessions?api_key={APIKEY}"
try:
res = Jellyfin().get_data(req_url)
if res and res.status_code == 200:
sessions = res.json()
for session in sessions:
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
playing_sessions.append(session)
except Exception as e:
logger.error(f"获取Jellyfin播放会话失败{str(e)}")
# 计算有效比特率
for session in playing_sessions:
# 设置了不限速范围则判断session ip是否在不限速范围内
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
for media_stream in media_streams:
total_bit_rate += int(media_stream.get("BitRate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
for media_stream in media_streams:
total_bit_rate += int(media_stream.get("BitRate") or 0)
elif settings.MEDIASERVER == "plex":
_plex = Plex().get_plex()
if _plex:
sessions = _plex.sessions()
for session in sessions:
bitrate = sum([m.bitrate or 0 for m in session.media])
playing_sessions.append({
"type": session.TAG,
"bitrate": bitrate,
"address": session.player.address
})
# 媒体服务器类型,多个以,分隔
if not settings.MEDIASERVER:
return
media_servers = settings.MEDIASERVER.split(',')
# 查询所有媒体服务器状态
for media_server in media_servers:
# 查询播放中会话
playing_sessions = []
if media_server == "emby":
req_url = "{HOST}emby/Sessions?api_key={APIKEY}"
try:
res = Emby().get_data(req_url)
if res and res.status_code == 200:
sessions = res.json()
for session in sessions:
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
playing_sessions.append(session)
except Exception as e:
logger.error(f"获取Emby播放会话失败{str(e)}")
continue
# 计算有效比特率
for session in playing_sessions:
# 设置了不限速范围则判断session ip是否在不限速范围内
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
if not self.__allow_access(self._unlimited_ips, session.get("address")) \
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
elif media_server == "jellyfin":
req_url = "{HOST}Sessions?api_key={APIKEY}"
try:
res = Jellyfin().get_data(req_url)
if res and res.status_code == 200:
sessions = res.json()
for session in sessions:
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
playing_sessions.append(session)
except Exception as e:
logger.error(f"获取Jellyfin播放会话失败{str(e)}")
continue
# 计算有效比特率
for session in playing_sessions:
# 设置了不限速范围则判断session ip是否在不限速范围内
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
for media_stream in media_streams:
total_bit_rate += int(media_stream.get("BitRate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
for media_stream in media_streams:
total_bit_rate += int(media_stream.get("BitRate") or 0)
elif media_server == "plex":
_plex = Plex().get_plex()
if _plex:
sessions = _plex.sessions()
for session in sessions:
bitrate = sum([m.bitrate or 0 for m in session.media])
playing_sessions.append({
"type": session.TAG,
"bitrate": bitrate,
"address": session.player.address
})
# 计算有效比特率
for session in playing_sessions:
# 设置了不限速范围则判断session ip是否在不限速范围内
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
if not self.__allow_access(self._unlimited_ips, session.get("address")) \
and session.get("type") == "Video":
total_bit_rate += int(session.get("bitrate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("address")) \
and session.get("type") == "Video":
total_bit_rate += int(session.get("bitrate") or 0)
# 未设置不限速范围则默认不限速内网ip
elif not IpUtils.is_private_ip(session.get("address")) \
and session.get("type") == "Video":
total_bit_rate += int(session.get("bitrate") or 0)
if total_bit_rate:
# 开启智能限速计算上传限速
@@ -471,17 +483,13 @@ class SpeedLimiter(_PluginBase):
self.__set_limiter(limit_type="未播放", upload_limit=self._noplay_up_speed,
download_limit=self._noplay_down_speed)
def __calc_limit(self, total_bit_rate):
def __calc_limit(self, total_bit_rate: float) -> float:
"""
计算智能上传限速
"""
residual_bandwidth = (self._bandwidth - total_bit_rate)
if residual_bandwidth < 0:
play_up_speed = 10
else:
play_up_speed = round(residual_bandwidth / 8 / 1024, 2)
return play_up_speed
if not self._bandwidth:
return 10
return round((self._bandwidth - total_bit_rate) / 8 / 1024, 2)
def __set_limiter(self, limit_type: str, upload_limit: float, download_limit: float):
"""
@@ -564,7 +572,7 @@ class SpeedLimiter(_PluginBase):
logger.error(f"设置限速失败:{str(e)}")
@staticmethod
def __allow_access(allow_ips, ip):
def __allow_access(allow_ips: dict, ip: str) -> bool:
"""
判断IP是否合法
:param allow_ips: 充许的IP范围 {"ipv4":, "ipv6":}

View File

@@ -644,7 +644,7 @@ class TorrentRemover(_PluginBase):
return None
if self._torrentstates and torrent.state not in self._torrentstates:
return None
if self._torrentcategorys and torrent.category not in self._torrentcategorys:
if self._torrentcategorys and (not torrent.category or torrent.category not in self._torrentcategorys):
return None
return {
"id": torrent.hash,
@@ -731,20 +731,34 @@ class TorrentRemover(_PluginBase):
remove_torrents.append(item)
# 处理辅种
if self._samedata and remove_torrents:
remove_ids = [t.get("id") for t in remove_torrents]
remove_torrents_plus = []
for remove_torrent in remove_torrents:
name = remove_torrent.get("name")
size = remove_torrent.get("size")
for torrent in torrents:
if downloader == "qbittorrent":
item_plus = self.__get_qb_torrent(torrent)
plus_id = torrent.hash
plus_name = torrent.name
plus_size = torrent.size
plus_site = StringUtils.get_url_sld(torrent.tracker)
else:
item_plus = self.__get_tr_torrent(torrent)
if not item_plus:
continue
if item_plus.get("name") == name \
and item_plus.get("size") == size \
and item_plus.get("id") not in [t.get("id") for t in remove_torrents]:
remove_torrents_plus.append(item_plus)
remove_torrents.extend(remove_torrents_plus)
plus_id = torrent.hashString
plus_name = torrent.name
plus_size = torrent.total_size
plus_site = torrent.trackers[0].get("sitename") if torrent.trackers else ""
# 比对名称和大小
if plus_name == name \
and plus_size == size \
and plus_id not in remove_ids:
remove_torrents_plus.append(
{
"id": plus_id,
"name": plus_name,
"site": plus_site,
"size": plus_size
}
)
if remove_torrents_plus:
remove_torrents.extend(remove_torrents_plus)
return remove_torrents

View File

@@ -1,10 +1,12 @@
import logging
from datetime import datetime, timedelta
from typing import List
import pytz
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
from app import schemas
from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain
from app.chain.mediaserver import MediaServerChain
@@ -40,57 +42,153 @@ class Scheduler(metaclass=Singleton):
def __init__(self):
# 数据库连接
self._db = SessionFactory()
# 各服务的运行状态
self._jobs = {
"cookiecloud": {
"func": CookieCloudChain(self._db).process,
"running": False,
},
"mediaserver_sync": {
"func": MediaServerChain(self._db).sync,
"running": False,
},
"subscribe_tmdb": {
"func": SubscribeChain(self._db).check,
"running": False,
},
"subscribe_search": {
"func": SubscribeChain(self._db).search,
"running": False,
},
"subscribe_refresh": {
"func": SubscribeChain(self._db).refresh,
"running": False,
},
"transfer": {
"func": TransferChain(self._db).process,
"running": False,
}
}
# 调试模式不启动定时服务
if settings.DEV:
return
# CookieCloud定时同步
if settings.COOKIECLOUD_INTERVAL:
self._scheduler.add_job(CookieCloudChain(self._db).process,
"interval",
minutes=settings.COOKIECLOUD_INTERVAL,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
name="同步CookieCloud站点")
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=settings.COOKIECLOUD_INTERVAL,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
kwargs={
'job_id': 'cookiecloud'
}
)
# 媒体服务器同步
if settings.MEDIASERVER_SYNC_INTERVAL:
self._scheduler.add_job(MediaServerChain(self._db).sync, "interval",
hours=settings.MEDIASERVER_SYNC_INTERVAL,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
name="同步媒体服务器")
self._scheduler.add_job(
self.start,
"interval",
id="mediaserver_sync",
name="同步媒体服务器",
hours=settings.MEDIASERVER_SYNC_INTERVAL,
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
kwargs={
'job_id': 'mediaserver_sync'
}
)
# 新增订阅时搜索5分钟检查一次
self._scheduler.add_job(SubscribeChain(self._db).search, "interval",
minutes=5, kwargs={'state': 'N'})
self._scheduler.add_job(
self.start,
"interval",
minutes=5,
kwargs={
'job_id': 'subscribe_search',
'state': 'N'
}
)
# 检查更新订阅TMDB数据每隔6小时
self._scheduler.add_job(SubscribeChain(self._db).check, "interval", hours=6)
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_tmdb",
name="订阅元数据更新",
hours=6,
kwargs={
'job_id': 'subscribe_tmdb'
}
)
# 订阅状态每隔24小时搜索一次
if settings.SUBSCRIBE_SEARCH:
self._scheduler.add_job(SubscribeChain(self._db).search, "interval",
hours=24, kwargs={'state': 'R'}, name="订阅搜索")
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_search",
name="订阅搜索",
hours=24,
kwargs={
'job_id': 'subscribe_search',
'state': 'R'
}
)
if settings.SUBSCRIBE_MODE == "spider":
# 站点首页种子定时刷新模式
triggers = TimerUtils.random_scheduler(num_executions=30)
for trigger in triggers:
self._scheduler.add_job(SubscribeChain(self._db).refresh, "cron",
hour=trigger.hour, minute=trigger.minute, name="订阅刷新")
self._scheduler.add_job(
self.start,
"cron",
id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}",
name="订阅刷新",
hour=trigger.hour,
minute=trigger.minute,
kwargs={
'job_id': 'subscribe_refresh'
})
else:
# RSS订阅模式
if not settings.SUBSCRIBE_RSS_INTERVAL:
settings.SUBSCRIBE_RSS_INTERVAL = 30
elif settings.SUBSCRIBE_RSS_INTERVAL < 5:
settings.SUBSCRIBE_RSS_INTERVAL = 5
self._scheduler.add_job(SubscribeChain(self._db).refresh, "interval",
minutes=settings.SUBSCRIBE_RSS_INTERVAL, name="订阅刷新")
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=settings.SUBSCRIBE_RSS_INTERVAL,
kwargs={
'job_id': 'subscribe_refresh'
}
)
# 下载器文件转移每5分钟
if settings.DOWNLOADER_MONITOR:
self._scheduler.add_job(TransferChain(self._db).process, "interval", minutes=5, name="下载文件整理")
self._scheduler.add_job(
self.start,
"interval",
id="transfer",
name="下载文件整理",
minutes=5,
kwargs={
'job_id': 'transfer'
}
)
# 公共定时服务
self._scheduler.add_job(SchedulerChain(self._db).scheduler_job, "interval", minutes=10)
self._scheduler.add_job(
SchedulerChain(self._db).scheduler_job,
"interval",
minutes=10
)
# 打印服务
logger.debug(self._scheduler.print_jobs())
@@ -98,11 +196,54 @@ class Scheduler(metaclass=Singleton):
# 启动定时服务
self._scheduler.start()
def list(self):
def start(self, job_id: str, *args, **kwargs):
"""
启动定时服务
"""
# 处理job_id格式
job = self._jobs.get(job_id)
if not job:
return
if job.get("running"):
logger.warning(f"定时任务 {job_id} 正在运行 ...")
return
self._jobs[job_id]["running"] = True
try:
job["func"](*args, **kwargs)
except Exception as e:
logger.error(f"定时任务 {job_id} 执行失败:{e}")
self._jobs[job_id]["running"] = False
def list(self) -> List[schemas.ScheduleInfo]:
"""
当前所有任务
"""
return self._scheduler.get_jobs()
# 返回计时任务
schedulers = []
# 去重
added = []
jobs = self._scheduler.get_jobs()
# 按照下次运行时间排序
jobs.sort(key=lambda x: x.next_run_time)
for job in jobs:
if job.name not in added:
added.append(job.name)
else:
continue
job_id = job.id.split("|")[0]
if not self._jobs.get(job_id):
continue
# 任务状态
status = "正在运行" if self._jobs[job_id].get("running") else "等待"
# 下次运行时间
next_run = TimerUtils.time_difference(job.next_run_time)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=job.name,
status=status,
next_run=next_run
))
return schedulers
def stop(self):
"""

View File

@@ -36,6 +36,12 @@ class DownloadHistory(BaseModel):
torrent_description: Optional[str] = None
# 站点
torrent_site: Optional[str] = None
# 下载用户
userid: Optional[str] = None
# 下载渠道
channel: Optional[str] = None
# 创建时间
date: Optional[str] = None
# 备注
note: Optional[str] = None

View File

@@ -51,3 +51,5 @@ class NotificationSwitch(BaseModel):
telegram: Optional[bool] = False
# Slack开关
slack: Optional[bool] = False
# SynologyChat开关
synologychat: Optional[bool] = False

View File

@@ -35,6 +35,8 @@ class TransferInfo(BaseModel):
"""
文件转移结果信息
"""
# 是否成功标志
success: bool = True
# 转移⼁路径
path: Optional[Path] = None
# 转移后路径

View File

@@ -34,8 +34,8 @@ class EventType(Enum):
DownloadAdded = "download.added"
# 删除历史记录
HistoryDeleted = "history.deleted"
# 删除媒体库文件
MediaDeleted = "media.deleted"
# 删除下载源文件
DownloadFileDeleted = "downloadfile.deleted"
# 用户外来消息
UserMessage = "user.message"
# 通知消息
@@ -48,7 +48,7 @@ class SystemConfigKey(Enum):
UserInstalledPlugins = "UserInstalledPlugins"
# 搜索结果
SearchResults = "SearchResults"
# 索站点范围
# 索站点范围
IndexerSites = "IndexerSites"
# 订阅站点范围
RssSites = "RssSites"
@@ -60,10 +60,14 @@ class SystemConfigKey(Enum):
CustomReleaseGroups = "CustomReleaseGroups"
# 自定义识别词
CustomIdentifiers = "CustomIdentifiers"
# 过滤规则
FilterRules = "FilterRules"
# 搜索优先级规则
SearchFilterRules = "SearchFilterRules"
# 订阅优先级规则
SubscribeFilterRules = "SubscribeFilterRules"
# 洗版规则
FilterRules2 = "FilterRules2"
BestVersionFilterRules = "BestVersionFilterRules"
# 默认过滤规则
DefaultFilterRules = "DefaultFilterRules"
# 转移屏蔽词
TransferExcludeWords = "TransferExcludeWords"
@@ -105,3 +109,4 @@ class MessageChannel(Enum):
Wechat = "微信"
Telegram = "Telegram"
Slack = "Slack"
SynologyChat = "SynologyChat"

View File

@@ -172,39 +172,3 @@ class RequestUtils:
cookiesList.append(cookies)
return cookiesList
return cookie_dict
class WebUtils:
@staticmethod
def get_location(ip: str):
"""
https://api.mir6.com/api/ip
{
"code": 200,
"msg": "success",
"data": {
"ip": "240e:97c:2f:1::5c",
"dec": "47925092370311863177116789888333643868",
"country": "中国",
"countryCode": "CN",
"province": "广东省",
"city": "广州市",
"districts": "",
"idc": "",
"isp": "中国电信",
"net": "数据中心",
"zipcode": "510000",
"areacode": "020",
"protocol": "IPv6",
"location": "中国[CN] 广东省 广州市",
"myip": "125.89.7.89",
"time": "2023-09-01 17:28:23"
}
}
"""
try:
r = RequestUtils().get_res(f"https://api.mir6.com/api/ip?ip={ip}&type=json")
if r:
return r.json().get("data", {}).get("location") or ''
except Exception as err:
return str(err)

View File

@@ -1,155 +0,0 @@
import os
class PathUtils:
@staticmethod
def get_dir_files(in_path, exts="", filesize=0, episode_format=None):
"""
获得目录下的媒体文件列表List ,按后缀、大小、格式过滤
"""
if not in_path:
return []
if not os.path.exists(in_path):
return []
ret_list = []
if os.path.isdir(in_path):
for root, dirs, files in os.walk(in_path):
for file in files:
cur_path = os.path.join(root, file)
# 检查路径是否合法
if PathUtils.is_invalid_path(cur_path):
continue
# 检查格式匹配
if episode_format and not episode_format.match(file):
continue
# 检查后缀
if exts and os.path.splitext(file)[-1].lower() not in exts:
continue
# 检查文件大小
if filesize and os.path.getsize(cur_path) < filesize:
continue
# 命中
if cur_path not in ret_list:
ret_list.append(cur_path)
else:
# 检查路径是否合法
if PathUtils.is_invalid_path(in_path):
return []
# 检查后缀
if exts and os.path.splitext(in_path)[-1].lower() not in exts:
return []
# 检查格式
if episode_format and not episode_format.match(os.path.basename(in_path)):
return []
# 检查文件大小
if filesize and os.path.getsize(in_path) < filesize:
return []
ret_list.append(in_path)
return ret_list
@staticmethod
def get_dir_level1_files(in_path, exts=""):
"""
查询目录下的文件(只查询一级)
"""
ret_list = []
if not os.path.exists(in_path):
return []
for file in os.listdir(in_path):
path = os.path.join(in_path, file)
if os.path.isfile(path):
if not exts or os.path.splitext(file)[-1].lower() in exts:
ret_list.append(path)
return ret_list
@staticmethod
def get_dir_level1_medias(in_path, exts=""):
"""
根据后缀,返回目录下所有的文件及文件夹列表(只查询一级)
"""
ret_list = []
if not os.path.exists(in_path):
return []
if os.path.isdir(in_path):
for file in os.listdir(in_path):
path = os.path.join(in_path, file)
if os.path.isfile(path):
if not exts or os.path.splitext(file)[-1].lower() in exts:
ret_list.append(path)
else:
ret_list.append(path)
else:
ret_list.append(in_path)
return ret_list
@staticmethod
def is_invalid_path(path):
"""
判断是否不能处理的路径
"""
if not path:
return True
if path.find('/@Recycle/') != -1 or path.find('/#recycle/') != -1 or path.find('/.') != -1 or path.find(
'/@eaDir') != -1:
return True
return False
@staticmethod
def is_path_in_path(path1, path2):
"""
判断两个路径是否包含关系 path1 in path2
"""
if not path1 or not path2:
return False
path1 = os.path.normpath(path1).replace("\\", "/")
path2 = os.path.normpath(path2).replace("\\", "/")
if path1 == path2:
return True
path = os.path.dirname(path2)
while True:
if path == path1:
return True
path = os.path.dirname(path)
if path == os.path.dirname(path):
break
return False
@staticmethod
def get_bluray_dir(path):
"""
判断是否蓝光原盘目录,是则返回原盘的根目录,否则返回空
"""
if not path or not os.path.exists(path):
return None
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "BDMV", "index.bdmv")):
return path
elif os.path.normpath(path).endswith("BDMV") \
and os.path.exists(os.path.join(path, "index.bdmv")):
return os.path.dirname(path)
elif os.path.normpath(path).endswith("STREAM") \
and os.path.exists(os.path.join(os.path.dirname(path), "index.bdmv")):
return PathUtils.get_parent_paths(path, 2)
else:
# 电视剧原盘下会存在多个目录形如Spider Man 2021/DIsc1, Spider Man 2021/Disc2
for level1 in PathUtils.get_dir_level1_medias(path):
if os.path.exists(os.path.join(level1, "BDMV", "index.bdmv")):
return path
return None
else:
if str(os.path.splitext(path)[-1]).lower() in [".m2ts", ".ts"] \
and os.path.normpath(os.path.dirname(path)).endswith("STREAM") \
and os.path.exists(os.path.join(PathUtils.get_parent_paths(path, 2), "index.bdmv")):
return PathUtils.get_parent_paths(path, 3)
else:
return None
@staticmethod
def get_parent_paths(path, level: int = 1):
"""
获取父目录路径level为向上查找的层数
"""
for lv in range(0, level):
path = os.path.dirname(path)
return path

View File

@@ -17,19 +17,18 @@ class SiteUtils:
if html.xpath("//input[@type='password']"):
return False
# 是否存在登出和用户面板等链接
xpaths = ['//a[contains(@href, "logout")'
' or contains(@data-url, "logout")'
' or contains(@href, "mybonus") '
' or contains(@onclick, "logout")'
' or contains(@href, "usercp")]',
'//form[contains(@action, "logout")]']
xpaths = [
'//a[contains(@href, "logout")'
' or contains(@data-url, "logout")'
' or contains(@href, "mybonus") '
' or contains(@onclick, "logout")'
' or contains(@href, "usercp")]',
'//form[contains(@action, "logout")]',
'//div[@class="user-info-side"]'
]
for xpath in xpaths:
if html.xpath(xpath):
return True
user_info_div = html.xpath('//div[@class="user-info-side"]')
if user_info_div:
return True
return False
@classmethod

View File

@@ -61,7 +61,9 @@ class SystemUtils:
移动
"""
try:
# 当前目录改名
temp = src.replace(src.parent / dest.name)
# 移动到目标目录
shutil.move(temp, dest)
return 0, ""
except Exception as err:
@@ -74,7 +76,11 @@ class SystemUtils:
硬链接
"""
try:
dest.hardlink_to(src)
# link到当前目录并改名
tmp_path = (src.parent / dest.name).with_suffix(".mp")
tmp_path.hardlink_to(src)
# 移动到目标目录
shutil.move(tmp_path, dest)
return 0, ""
except Exception as err:
print(str(err))
@@ -106,7 +112,7 @@ class SystemUtils:
if directory.is_file():
return [directory]
if not min_filesize:
min_filesize = 0
@@ -122,6 +128,36 @@ class SystemUtils:
return files
@staticmethod
def exits_files(directory: Path, extensions: list, min_filesize: int = 0) -> bool:
"""
判断目录下是否存在指定扩展名的文件
:return True存在 False不存在
"""
if not min_filesize:
min_filesize = 0
if not directory.exists():
return False
if directory.is_file():
return True
if not min_filesize:
min_filesize = 0
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:
return True
return False
@staticmethod
def list_sub_files(directory: Path, extensions: list) -> List[Path]:
"""
@@ -311,6 +347,7 @@ class SystemUtils:
# 创建 Docker 客户端
client = docker.DockerClient(base_url='tcp://127.0.0.1:38379')
# 获取当前容器的 ID
container_id = None
with open('/proc/self/mountinfo', 'r') as f:
data = f.read()
index_resolv_conf = data.find("resolv.conf")
@@ -318,6 +355,12 @@ class SystemUtils:
index_second_slash = data.rfind("/", 0, index_resolv_conf)
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
container_id = data[index_first_slash:index_second_slash]
if len(container_id) < 20:
index_resolv_conf = data.find("/sys/fs/cgroup/devices")
if index_resolv_conf != -1:
index_second_slash = data.rfind(" ", 0, index_resolv_conf)
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
container_id = data[index_first_slash:index_second_slash]
if not container_id:
return False, "获取容器ID失败"
# 重启当前容器

90
app/utils/web.py Normal file
View File

@@ -0,0 +1,90 @@
from typing import Optional
from app.utils.http import RequestUtils
class WebUtils:
@staticmethod
def get_location(ip: str):
"""
查询IP所属地
"""
return WebUtils.get_location1(ip) or WebUtils.get_location2(ip)
@staticmethod
def get_location1(ip: str):
"""
https://api.mir6.com/api/ip
{
"code": 200,
"msg": "success",
"data": {
"ip": "240e:97c:2f:1::5c",
"dec": "47925092370311863177116789888333643868",
"country": "中国",
"countryCode": "CN",
"province": "广东省",
"city": "广州市",
"districts": "",
"idc": "",
"isp": "中国电信",
"net": "数据中心",
"zipcode": "510000",
"areacode": "020",
"protocol": "IPv6",
"location": "中国[CN] 广东省 广州市",
"myip": "125.89.7.89",
"time": "2023-09-01 17:28:23"
}
}
"""
try:
r = RequestUtils().get_res(f"https://api.mir6.com/api/ip?ip={ip}&type=json")
if r:
return r.json().get("data", {}).get("location") or ''
except Exception as err:
print(str(err))
return ""
@staticmethod
def get_location2(ip: str):
"""
https://whois.pconline.com.cn/ipJson.jsp?json=true&ip=
{
"ip": "122.8.12.22",
"pro": "上海市",
"proCode": "310000",
"city": "上海市",
"cityCode": "310000",
"region": "",
"regionCode": "0",
"addr": "上海市 铁通",
"regionNames": "",
"err": ""
}
"""
try:
r = RequestUtils().get_res(f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip}")
if r:
return r.json().get("addr") or ''
except Exception as err:
print(str(err))
return ""
@staticmethod
def get_bing_wallpaper() -> Optional[str]:
"""
获取Bing每日壁纸
"""
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
resp = RequestUtils(timeout=5).get_res(url)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
for image in result.get('images') or []:
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
except Exception as err:
print(str(err))
return None

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
APP_VERSION = 'v1.2.1'
APP_VERSION = 'v1.2.7'