Compare commits

...

411 Commits
v1.8.6 ... v1

Author SHA1 Message Date
jxxghp
1336b2136d Merge pull request #4340 from jtcymc/main 2025-05-25 07:59:41 +08:00
shaw
b20e21e700 fix(SearchChain): with 关闭线程池
- 使用 with 语句管理 ThreadPoolExecutor,确保线程池正确关闭
2025-05-25 00:50:34 +08:00
jxxghp
c27ab4a4c7 v1.9.19
- 默认关闭自动升级
2025-05-12 16:41:57 +08:00
jxxghp
d9e6532325 更新 version.py 2025-04-10 14:55:24 +08:00
jxxghp
049f16ba01 Merge pull request #4130 from cddjr/fix_v1_mteam 2025-04-10 14:36:21 +08:00
景大侠
6541458326 backport: 适配馒头API变动 2025-04-10 14:18:04 +08:00
jxxghp
9f2912426b Merge pull request #2833 from wikrin/main 2024-10-10 22:49:12 +08:00
Attente
fde33d267a fix: 修正重复的特殊字符
将重复的特殊字符 `—`[U+2014](https://symbl.cc/cn/2014/) 修改为 `―`[U+2015](https://symbl.cc/cn/2015/)
2024-10-10 22:23:43 +08:00
jxxghp
ef7f0afa37 v1.9.17
- 修复115扫码登录问题
- 索引站点新增支持 `PTLGS`
2024-09-18 17:52:40 +08:00
jxxghp
bea77a8243 fix 115 2024-09-18 13:39:39 +08:00
jxxghp
b984b83870 v1.9.16
- 修复了有些情况下新增目录类型为全部时不生效的问题
2024-09-08 13:11:59 +08:00
jxxghp
2153ad48db v1.9.15
- 修复部分通知消息查看详细链接错误的问题
- 修复了麒麟无法索引综艺的问题
- 修复了插件无升级提示图标的问题
2024-08-28 15:01:46 +08:00
jxxghp
c9c43fde74 Merge pull request #2638 from Linvery/main 2024-08-13 12:41:51 +08:00
Linvery
e2c9742f64 fix: 解决推送消息错误的url路径 2024-08-13 12:40:33 +08:00
jxxghp
3d459a40f7 - 仅调整了插件页面的UI 2024-08-13 11:46:01 +08:00
jxxghp
5675cd5b11 v1.9.13
- 修复已知问题
2024-07-30 06:36:30 +08:00
jxxghp
74a4d0bd66 Merge pull request #2614 from InfinityPacer/main 2024-07-30 06:33:00 +08:00
InfinityPacer
2b8c313019 refactor(event): 事件处理调整为深复制,避免多线程环境下数据异常 2024-07-29 23:18:58 +08:00
jxxghp
62fb6b80a3 Merge pull request #2612 from audichuang/main 2024-07-28 18:48:06 +08:00
audichuang
eea86528d8 Modifying the Bilingual Subtitle Matching Specification 2024-07-28 18:07:28 +08:00
jxxghp
84e6abb659 Merge pull request #2600 from InfinityPacer/main 2024-07-23 19:45:18 +08:00
InfinityPacer
da2c755b6d fix(Plugin): 重置插件时初始化调整为reload,以保留默认配置 2024-07-23 19:41:29 +08:00
jxxghp
51f39be9bc Merge pull request #2590 from Akimio521/main 2024-07-20 20:15:04 +08:00
Akimio521
21b762e75c perfect(releasegroup):完善anime字幕组 2024-07-20 20:05:05 +08:00
jxxghp
54095074b6 v1.9.12
- 新增认证站点:`ROUSI`
- 修复Telegram部分消息格式异常问题
2024-07-16 08:07:24 +08:00
jxxghp
33525730b5 fix #2515 2024-07-16 08:04:48 +08:00
jxxghp
71260f04b5 Merge pull request #2560 from InfinityPacer/main 2024-07-12 17:03:55 +08:00
InfinityPacer
e2acec321d fix tips 2024-07-12 16:48:44 +08:00
InfinityPacer
74a462a09f fix SitesHelper import tips 2024-07-12 16:30:06 +08:00
jxxghp
ad9e1a5da6 Merge pull request #2552 from BrettDean/main 2024-07-11 21:26:04 +08:00
Dean
d90e3c29a5 优化微信文本消息发送:支持长文本分块发送 2024-07-11 20:30:09 +08:00
jxxghp
19165eff75 Merge pull request #2537 from InfinityPacer/main 2024-07-09 11:01:53 +08:00
jxxghp
52d0703812 v1.9.11
- 支持环境变量配置DOH域名和DNS服务器
- 问题修复
2024-07-09 08:09:58 +08:00
InfinityPacer
1431a5e82a fix #2518 移除不必要的debug日志 2024-07-09 01:40:37 +08:00
jxxghp
23fe643526 Merge pull request #2534 from InfinityPacer/main 2024-07-08 12:23:16 +08:00
jxxghp
545b3c0482 Merge pull request #2527 from s0urcelab/main 2024-07-08 12:22:50 +08:00
InfinityPacer
f102119eef fix #2526 Backdrop优先调整为取art 2024-07-07 23:03:34 +08:00
s0urce
9bb3d707c9 feat: history query add title field 2024-07-07 18:27:06 +08:00
jxxghp
b892ef50dc Merge pull request #2526 from InfinityPacer/main 2024-07-07 16:49:11 +08:00
InfinityPacer
41e2907168 fix jxxghp/MoviePilot-Plugins#38 2024-07-07 16:32:37 +08:00
jxxghp
14e28ed693 Merge pull request #2518 from InfinityPacer/main 2024-07-07 08:58:39 +08:00
InfinityPacer
79393c21ff feat: 支持插件进行私钥认证 2024-07-06 20:03:49 +08:00
InfinityPacer
cafa4d217c feat: 增加指定的仓库Github token 2024-07-06 16:07:46 +08:00
jxxghp
2b9e69b112 Merge pull request #2515 from BrettDean/main 2024-07-06 07:50:59 +08:00
Dean
3ffcea70a7 Fixed parsing of Telegram entities 2024-07-06 01:44:51 +08:00
jxxghp
ffc72ba6fe fix #2508 2024-07-05 17:03:00 +08:00
jxxghp
848becd946 Merge pull request #2506 from Akimio521/main 2024-07-05 14:44:50 +08:00
Akimio521
71fe96d7f9 feat: 添加 DOH 解析服务器列表至配置文件实现自定义 DOH 服务器 2024-07-05 13:55:48 +08:00
jxxghp
35c7238ede Merge pull request #2503 from Akimio521/main 2024-07-05 11:31:21 +08:00
Akimio521
3578204508 style 2024-07-05 10:52:39 +08:00
Akimio521
c11cf17f62 style:app.core.config.settings 2024-07-05 10:34:57 +08:00
Akimio521
5a59652684 feat:将使用 DOH 域名解析的域名添加至 app.core.config.settings 2024-07-05 09:31:41 +08:00
jxxghp
7f5f31f143 Merge pull request #2484 from InfinityPacer/main 2024-07-02 06:12:13 +08:00
InfinityPacer
dc1cee80b1 fix plugin install and reg 2024-07-02 01:11:42 +08:00
jxxghp
92cb066748 更新 version.py 2024-07-01 21:46:07 +08:00
jxxghp
6c8ef4122b fix e5ec02e043 2024-07-01 12:23:02 +08:00
jxxghp
971b02ac8c - 重新兼容了v1.9.1之前的版本直接升级
- 索引站点新增支持`HDVBits`
- 自定义重命名新增季年份`season_year`占位符
- 修复了普通用户搜索越权问题
2024-07-01 10:46:29 +08:00
jxxghp
d4a9643f47 Merge pull request #2463 from InfinityPacer/main
处理链run_module支持raise_exception
2024-07-01 10:33:55 +08:00
InfinityPacer
e56d31fedc fix exception 2024-06-30 11:50:26 +08:00
InfinityPacer
b9d91c5cd7 feat: DoubanModule触发限流时支持立即抛出限流异常 2024-06-30 11:48:29 +08:00
InfinityPacer
57cdb57331 feat: retry支持立即抛出异常 2024-06-30 11:47:30 +08:00
InfinityPacer
0f7a7ef44f feat: 添加ImmediateException 2024-06-30 11:47:00 +08:00
InfinityPacer
6267b3f670 feat: run_module支持raise_exception 2024-06-30 11:41:00 +08:00
jxxghp
82f77b4729 Merge pull request #2456 from AisukaYuki/main 2024-06-30 09:09:36 +08:00
jxxghp
58da0ebb4f Merge pull request #2460 from thsrite/main 2024-06-30 09:08:35 +08:00
thsrite
7a43e43478 fix 删除文件未删除thumb.jpg 2024-06-29 20:12:26 +08:00
AisukaYuki
e5ec02e043 add 自定义重命名新增季年份season_year 2024-06-29 13:50:40 +08:00
jxxghp
2944c343a8 Merge pull request #2432 from InfinityPacer/main 2024-06-26 18:21:00 +08:00
InfinityPacer
940cc566c8 fix douban rate_limit tips 2024-06-26 18:17:31 +08:00
jxxghp
db7b2cdcac fix error 2024-06-26 17:42:08 +08:00
jxxghp
8111cf5dc8 - 站点索引及用户认证新增支持海胆之家 2024-06-26 16:18:14 +08:00
jxxghp
be55c7bdd9 Merge pull request #2430 from InfinityPacer/main 2024-06-26 15:55:24 +08:00
InfinityPacer
a4288aa871 fix #2428 2024-06-26 15:51:31 +08:00
jxxghp
c0f15ac7ff Merge remote-tracking branch 'origin/main' 2024-06-26 15:18:05 +08:00
jxxghp
4047d433f5 fix 2024-06-26 15:17:42 +08:00
jxxghp
91d6769d0f Merge branch 'dev' into main 2024-06-26 14:49:27 +08:00
jxxghp
ad378956bf support haidan index 2024-06-26 09:08:18 +08:00
jxxghp
9dcfb6dc1e v1.9.8-1
- 修复剧集自动刮削报错问题
2024-06-25 16:32:45 +08:00
jxxghp
2d0b21d3f2 fix #2418
fix #2421
fix #2412
2024-06-25 16:29:57 +08:00
jxxghp
3287c85300 Merge pull request #2415 from thsrite/main 2024-06-25 10:06:25 +08:00
thsrite
fd2682bc6a add 删除下载历史、删除下载文件历史 2024-06-25 10:03:29 +08:00
jxxghp
7dd1e75ad7 Merge remote-tracking branch 'origin/main' 2024-06-24 17:13:58 +08:00
jxxghp
93b8f24ec7 v1.9.8
- 修复阿里云盘无法整理备份盘的问题
- 修复手动整理时fanart图片文件不全的问题
- 修复了通过远程消息下载时不会自动分类的问题
- 修复登录失败时的提示信息
- 修复有的场景下订阅重复下载问题
2024-06-24 17:13:50 +08:00
jxxghp
1c240f9d76 Update README.md 2024-06-24 17:06:56 +08:00
jxxghp
9a2ef5fe48 Update README.md 2024-06-24 17:06:08 +08:00
jxxghp
7bd55caed7 reinit 2024-06-24 12:53:45 +08:00
jxxghp
ae36f5100a Merge pull request #2410 from jxxghp/main
fix bugs
2024-06-24 12:47:39 +08:00
jxxghp
b2efac0495 Merge pull request #2409 from jxxghp/revert-2407-dev
Revert "fix bugs"
2024-06-24 12:42:09 +08:00
jxxghp
1dced579ea Revert "fix bugs" 2024-06-24 12:41:59 +08:00
jxxghp
0deea17ef9 Merge pull request #2407 from jxxghp/dev
fix bugs
2024-06-24 12:36:37 +08:00
jxxghp
3d0c06013d fix bug 2024-06-24 09:37:11 +08:00
jxxghp
2536119f60 feat:网盘整理联动刮削 2024-06-24 09:12:26 +08:00
jxxghp
aeede861e3 fix bug 2024-06-24 08:49:20 +08:00
jxxghp
1edbfb0d2d fix bug 2024-06-24 08:08:39 +08:00
jxxghp
265724bbe9 Merge pull request #2402 from thsrite/main 2024-06-23 19:47:24 +08:00
jxxghp
2b0b190cf8 fix bug 2024-06-23 19:46:36 +08:00
thsrite
08a2b348d8 add get_by_dest 2024-06-23 19:45:08 +08:00
jxxghp
e896068bc5 fix #2400 2024-06-23 18:48:13 +08:00
jxxghp
85e5338121 fix #2340
fix 手动刮削图片不完整
2024-06-23 18:40:44 +08:00
jxxghp
5c3cd8cabc init repo 2024-06-23 09:33:27 +08:00
jxxghp
5a837a4161 v1.9.8-beta
- 文件管理支持多选,支持网盘批量整理和刮削,阿里云盘支持备份盘
2024-06-23 09:07:05 +08:00
jxxghp
1e1f80b6d9 add remote transfer 2024-06-23 09:04:08 +08:00
jxxghp
e06e00204b fix #2341 2024-06-22 21:31:10 +08:00
jxxghp
b98c0f205d fix scrape 2024-06-22 20:58:24 +08:00
jxxghp
0c266726ea fix scrap 2024-06-22 19:59:24 +08:00
jxxghp
b43e591e4c fix scrap 2024-06-22 08:32:25 +08:00
jxxghp
3d6e1335f8 更新 scraper.py 2024-06-22 06:45:17 +08:00
jxxghp
361e8dd65d fix api 2024-06-21 23:25:08 +08:00
jxxghp
de865f3cf1 fix api 2024-06-21 23:05:00 +08:00
jxxghp
37985eba25 fix api 2024-06-21 21:28:48 +08:00
jxxghp
e0a251b339 fix scrape api 2024-06-21 19:19:10 +08:00
jxxghp
f9f4d97a51 更新 media.py 2024-06-21 12:23:18 +08:00
jxxghp
6adc0e27d5 fix api 2024-06-21 12:17:30 +08:00
jxxghp
5deb0089bb fix api 2024-06-21 11:49:07 +08:00
jxxghp
bfbeae7fa7 fix api 2024-06-21 11:13:01 +08:00
jxxghp
8a98c65026 fix 2024-06-21 08:27:37 +08:00
jxxghp
0133c6e60c add upload api 2024-06-21 08:08:23 +08:00
jxxghp
ae0e171dd2 Merge pull request #2375 from InfinityPacer/main 2024-06-20 17:54:24 +08:00
InfinityPacer
9f0ed49d43 fix plugin auth_level 2024-06-20 17:44:55 +08:00
jxxghp
8df2955a67 add alipan/115 move api 2024-06-20 17:21:02 +08:00
jxxghp
ef0cd7d5c5 fix meta_nfo 2024-06-20 17:04:47 +08:00
jxxghp
463fd3761a add meta_nfo module function 2024-06-20 16:53:50 +08:00
jxxghp
4af4ad0243 fix bug 2024-06-20 15:52:52 +08:00
jxxghp
24aa64232f fix windows exe front path 2024-06-20 13:21:49 +08:00
jxxghp
9937f6792e feat:阿里云盘支持备份盘 2024-06-20 13:15:59 +08:00
jxxghp
185b72dc8d fix:优化文件管理api 2024-06-20 11:38:57 +08:00
jxxghp
0fb12c77eb fix bug 2024-06-19 18:04:00 +08:00
jxxghp
631df4c9f8 v1.9.7
- 文件管理支持阿里云盘、115网盘,新增批量认别重命名功能,以快速整理本地或网盘文件。
- 优化了资源搜索卡片视图结果太多时卡顿的问题
- 适配了M-Team Api域名变化
2024-06-19 17:20:33 +08:00
jxxghp
0da08394ae Merge pull request #2365 from InfinityPacer/main 2024-06-19 16:55:27 +08:00
InfinityPacer
6392ee627f fix 请求失败时记录debug日志 2024-06-19 16:36:31 +08:00
InfinityPacer
da6ba3fa8b feat:Plex 添加公共请求方法 2024-06-19 15:53:55 +08:00
InfinityPacer
cb0bb8a38e refactor request host 2024-06-19 15:51:57 +08:00
InfinityPacer
e1cdc51904 Merge branch 'main' of https://github.com/InfinityPacer/MoviePilot 2024-06-19 15:47:16 +08:00
jxxghp
79c57d8e4f 批量重命名进度更新 2024-06-19 15:22:05 +08:00
jxxghp
681f1eaeb5 fix m-team api path 2024-06-19 14:16:20 +08:00
InfinityPacer
de2323d67a refactor RequestUtils 2024-06-19 13:45:02 +08:00
jxxghp
9cf240b8e8 fix UserDeviceOffline tip 2024-06-19 13:42:19 +08:00
jxxghp
b93c97938c fix 2024-06-19 13:14:02 +08:00
jxxghp
41d347bcef fix 115 pan 2024-06-19 13:04:35 +08:00
jxxghp
060e2f225c fix 115 pan 2024-06-19 13:02:04 +08:00
jxxghp
7103b0334a add 115 apis 2024-06-19 07:11:26 +08:00
jxxghp
354d5977e0 fix api path 2024-06-18 19:19:32 +08:00
jxxghp
19a56f7d24 feat:文件管理批量重命名 2024-06-18 16:45:48 +08:00
jxxghp
323ad099c3 add 识别名称API 2024-06-18 13:56:12 +08:00
jxxghp
484ecf10c3 fix api 2024-06-18 13:05:11 +08:00
jxxghp
2a333add9b fix aliyunpan api 2024-06-18 12:01:53 +08:00
jxxghp
90df09e64d add aliyunpan userinfo api 2024-06-18 07:03:05 +08:00
jxxghp
53397536ce Merge pull request #2355 from InfinityPacer/main 2024-06-17 21:09:00 +08:00
InfinityPacer
f902f43c56 fix #2348 移除硬链接校验 2024-06-17 21:02:14 +08:00
jxxghp
9948db8bce add aliyun apis 2024-06-17 20:16:38 +08:00
jxxghp
1b6a06bd7b add aliyun apis 2024-06-17 19:45:39 +08:00
jxxghp
ce1db7f62b v1.9.6
- 增加了CookieCloud同步域名黑名单设定
- 调整了登录和订阅界面样式,优化了整体UI响应速度
2024-06-16 14:09:47 +08:00
jxxghp
74dbae8514 fix api 2024-06-16 09:53:23 +08:00
jxxghp
7d4ec2ddec fix api 2024-06-16 07:22:01 +08:00
jxxghp
3654b9609f fix 2024-06-16 07:10:32 +08:00
jxxghp
83e583032a add wallpapers api 2024-06-16 07:09:04 +08:00
jxxghp
35a4d77915 fix #2346 2024-06-15 21:12:16 +08:00
jxxghp
cbfb2027a8 Merge pull request #2345 from thsrite/main 2024-06-15 19:37:58 +08:00
thsrite
ce0548632e fix cookiecloud同步只同步启用的站点 && 同步域名黑名单 2024-06-15 19:33:03 +08:00
thsrite
da1f6a0997 fix cookiecloud同步只同步启用的站点 2024-06-15 19:21:20 +08:00
jxxghp
a514ec0761 v1.9.5
- 小屏幕新增`App模式`界面切换功能,配合PWA提升手机操作使用体验(默认开启,点击右上角头像切换)
- 修复了有的反向代理环境下无法新增用户的问题
- 修复了北洋园种子发布时间和标签识别问题
- 修复部分情况下订阅会重复下载的问题
2024-06-14 17:07:42 +08:00
jxxghp
851dd85fc6 rclone移动模式下删除种子文件 2024-06-14 16:53:15 +08:00
jxxghp
0270af5b19 fix 豆瓣转TMDB搜索时丢失季的问题 2024-06-14 16:13:21 +08:00
jxxghp
f8f964106a fix pubdate string 2024-06-14 12:27:26 +08:00
jxxghp
aa0f2a571c fix datestr \n 2024-06-14 11:31:04 +08:00
jxxghp
727a14864e fix #2327
fix #2261
2024-06-14 10:32:15 +08:00
jxxghp
c7e909520c fix webpush重复推送 2024-06-14 06:52:34 +08:00
jxxghp
7f40863449 Merge pull request #2330 from falling/main 2024-06-13 21:14:48 +08:00
falling
e994a9fc92 is_english_word 方法 更新 2024-06-13 21:10:20 +08:00
jxxghp
d8fe8b28e8 fix 2024-06-13 11:31:44 +08:00
jxxghp
7f4f085d4a update README.md 2024-06-13 11:27:20 +08:00
jxxghp
2052766a71 Update README.md 2024-06-13 11:12:56 +08:00
jxxghp
887fe834bd Update README.md 2024-06-13 11:11:10 +08:00
jxxghp
0d4f87a631 Update README.md 2024-06-13 11:10:19 +08:00
jxxghp
ed96241053 Merge pull request #2322 from zhu0823/main 2024-06-12 10:20:40 +08:00
zhu0823
788104d151 fix: 类型检查 2024-06-12 10:07:16 +08:00
jxxghp
f8b3dbaef5 update README.md 2024-06-11 21:57:59 +08:00
jxxghp
b66ca92d72 Update README.md 2024-06-11 13:21:16 +08:00
jxxghp
c2a80dbedd Merge pull request #2310 from InfinityPacer/main 2024-06-10 13:52:09 +08:00
InfinityPacer
95202af139 fix dashboard Plex卡片加载速度 2024-06-10 12:43:05 +08:00
jxxghp
d77ea8f0a0 - 修复榜单、订阅、目录匹配细节问题 2024-06-10 09:55:20 +08:00
jxxghp
bbba9813a2 Merge pull request #2307 from InfinityPacer/main 2024-06-10 09:50:26 +08:00
jxxghp
220cbc3072 fix #2291 2024-06-10 09:49:31 +08:00
InfinityPacer
fcbdef5e66 fix 插件重载找不到__init__.py的场景及部分细节调整 2024-06-10 09:45:56 +08:00
jxxghp
e2e1c7642d fix 订阅重置 & TMDB电视剧榜单 2024-06-10 09:38:12 +08:00
jxxghp
33813ecf1d Merge pull request #2302 from xcehnz/main 2024-06-09 16:03:18 +08:00
xcehnz
ef656fcc67 fix 同目录优先无效 2024-06-09 15:41:48 +08:00
jxxghp
8fe7e015dd Merge pull request #2299 from thsrite/main 2024-06-09 12:54:27 +08:00
thsrite
7132fdbb26 fix plugin command args 2024-06-09 12:41:42 +08:00
jxxghp
0f57b39345 fix webpush switch 2024-06-09 11:15:35 +08:00
jxxghp
d13b5622c7 remove js/css cache 2024-06-09 08:04:53 +08:00
jxxghp
b5eaba26da 更新 __init__.py 2024-06-08 21:05:53 +08:00
jxxghp
60007cf398 Merge pull request #2295 from thsrite/main 2024-06-08 20:47:51 +08:00
thsrite
65cc169391 fix 2024-06-08 19:56:08 +08:00
thsrite
68a9fc4a13 fix 订阅新增支持填充规则 2024-06-08 19:54:06 +08:00
jxxghp
08870a67ec v1.9.4
- 优化了硬链接的处理逻辑,兼容极空间的同时跨盘不再会自动变成复制了
- 订阅增加了重置按钮,手动删除了订阅下载过的任务或媒体库文件时,可通过重置订阅重新下载
- 消息通知新增超链接跳转功能,需在设定中维护好访问域名
- 新增WebPush通知推送功能,实现类客户端的通知提醒效果(无需第三方软件)。使用方法:
   1. 设定 -> 通知 中打开WebPush开关
   2. 将MoviePilot浏览器(Safari、Chrome)网页发送到桌面图标,打开登录时根据提示允许消息通知权限

📢:功能无效或UI出错请清理浏览器缓存(无需清理Cookie)。
2024-06-08 14:45:49 +08:00
jxxghp
518206c34a fix wechat link 2024-06-08 11:21:03 +08:00
jxxghp
e05c643a6b add notification link 2024-06-08 10:47:50 +08:00
jxxghp
748de0ff00 fix webpush link 2024-06-08 07:45:13 +08:00
jxxghp
29b94e859f 更新 config.py 2024-06-08 07:09:21 +08:00
jxxghp
ed3bd0ddef Merge pull request #2289 from InfinityPacer/main 2024-06-08 06:21:57 +08:00
InfinityPacer
3cdbdc2f78 fix mailto移除空格和尖括号 2024-06-08 00:40:14 +08:00
jxxghp
f8fbf9b5eb fix webpush 2024-06-07 21:53:48 +08:00
jxxghp
9e0751367b Merge pull request #2288 from DDS-Derek/main
Revert "feat: refactor docker http proxy"
2024-06-07 21:48:48 +08:00
jxxghp
bc689074e0 fix webpush 2024-06-07 21:47:43 +08:00
DDSRem
7e442650b0 Revert "feat: refactor docker http proxy"
This reverts commit 48a860bfd4.
2024-06-07 21:43:36 +08:00
jxxghp
0a9a391eb3 add webpush log 2024-06-07 21:31:03 +08:00
jxxghp
ea1e600474 Merge pull request #2286 from honue/main 2024-06-07 16:25:33 +08:00
honue
b0a2c1b957 重置插件时删除插件数据 2024-06-07 15:33:17 +08:00
jxxghp
624363476a Merge pull request #2284 from DDS-Derek/main 2024-06-07 13:42:51 +08:00
DDSRem
48a860bfd4 feat: refactor docker http proxy 2024-06-07 11:08:41 +08:00
jxxghp
2d4fb5d52e fix webpush switch 2024-06-07 08:15:30 +08:00
jxxghp
c0c787f7ed fix 2024-06-07 08:01:23 +08:00
jxxghp
03d6834471 fix #2264 2024-06-06 14:21:20 +08:00
jxxghp
947d0d6d4b add 订阅重置api 2024-06-06 07:56:16 +08:00
jxxghp
7611c88aa6 Merge pull request #2272 from InfinityPacer/main 2024-06-05 22:28:13 +08:00
InfinityPacer
7be262b182 fix #2265 优化硬链接的判断逻辑 2024-06-05 22:24:17 +08:00
jxxghp
a7a06a9a75 fix NotificationSwitch 2024-06-05 22:00:39 +08:00
jxxghp
6aa5a836b9 fix webpush api 2024-06-05 18:40:00 +08:00
jxxghp
efd0fc39c6 fix github proxy && add webpush api 2024-06-05 18:08:34 +08:00
jxxghp
7e1951b8e4 Merge pull request #2265 from InfinityPacer/main 2024-06-04 22:00:00 +08:00
InfinityPacer
27c6392b66 fix #667 优化历史兼容极空间硬链接逻辑 2024-06-04 21:54:37 +08:00
jxxghp
0fc7d883c0 v1.9.3
- 搜索功能全面升级,支持搜索多种类型数据,支持模糊搜索站点资源(不匹配不过滤)
- 设置目录时支持浏览选择路径
- 支持配置Github代理地址,以加快版本及插件更新下载
- 优化了插件去重显示
- 优化了目录匹配同路径优先处理逻辑
- 设定等表单中的提示信息强制显示
- 修复了个别插件安装后会消失的问题

注意:前端变化升级后清理浏览器缓存文件(无需清理cookie)
2024-06-03 17:38:31 +08:00
jxxghp
95b480af6d fix #2235 2024-06-03 12:40:35 +08:00
jxxghp
abe7795105 Merge pull request #2259 from thsrite/main 2024-06-03 09:59:52 +08:00
thsrite
74c71390c9 fix unhashable type: 'dict' 2024-06-03 09:54:32 +08:00
jxxghp
1ddd844c17 fix 2024-06-03 08:20:16 +08:00
jxxghp
de3ff2db2e remove api async 2024-06-03 08:17:53 +08:00
jxxghp
655e73f829 fix dir match 2024-06-03 08:03:40 +08:00
jxxghp
2232e51509 add GITHUB_PROXY 2024-06-03 07:09:30 +08:00
jxxghp
44f1a321d2 fix #2249 2024-06-02 21:15:58 +08:00
jxxghp
c05223846f fix api 2024-06-02 21:09:15 +08:00
jxxghp
45945bd025 feat:增加删除下载任务事件,历史记录中删除源文件时主程序会同步删除种子,同时会发出该事件(以便处理辅种等) 2024-06-01 07:47:00 +08:00
jxxghp
acff7e0610 fix log 2024-05-31 20:03:48 +08:00
jxxghp
e97ae488fd fix 线上插件去重 2024-05-31 16:03:27 +08:00
jxxghp
a7689e1e10 fix 2024-05-31 15:08:22 +08:00
jxxghp
9a4d537543 Merge pull request #2237 from hotlcc/develop-20240531 2024-05-31 15:05:22 +08:00
Allen
1b09bb8d22 去掉主动创建下载目录的逻辑,解耦下载器,避免在容器环境当下载器目录与MP映射不一致时导致的目录权限异常 2024-05-31 15:00:47 +08:00
jxxghp
13832a51e0 add listdir api 2024-05-31 13:55:36 +08:00
jxxghp
a09b2fa88a fix log 2024-05-31 09:10:26 +08:00
jxxghp
6361f8654c fix README 2024-05-30 14:13:15 +08:00
jxxghp
db4bda3b73 Merge remote-tracking branch 'origin/main' 2024-05-30 12:39:02 +08:00
jxxghp
3f557ee43c fix README 2024-05-30 12:38:55 +08:00
jxxghp
9e7e0a8730 Merge pull request #2223 from thsrite/main 2024-05-30 10:42:16 +08:00
thsrite
07de1eaa0d fix 插件版本比较 2024-05-30 10:02:26 +08:00
jxxghp
c872043bf4 Merge pull request #2226 from InfinityPacer/main 2024-05-30 06:33:48 +08:00
InfinityPacer
7ed194a62c fix 优先加载子模块 2024-05-30 00:58:20 +08:00
jxxghp
882da68903 Merge pull request #2224 from InfinityPacer/main 2024-05-29 23:06:31 +08:00
InfinityPacer
2798700f71 fix 插件重载时,支持reload一级子模块 2024-05-29 23:01:58 +08:00
thsrite
34e70adabb fix 插件库相同ID的插件保留版本号最大版本 2024-05-29 20:40:04 +08:00
jxxghp
fe999aa346 fix dir match 2024-05-29 17:30:00 +08:00
jxxghp
f7ca4abb01 fix dir match 2024-05-29 17:28:49 +08:00
jxxghp
8a4202cee5 fix dir match 2024-05-29 17:16:35 +08:00
jxxghp
55a85b87dd fix README 2024-05-29 16:47:03 +08:00
jxxghp
3470f96e39 feat:系统错误时发出事件 2024-05-29 16:29:47 +08:00
jxxghp
74980911fe feat:系统错误时发出事件 2024-05-29 16:28:17 +08:00
jxxghp
4c5366f8b4 fix 订阅类型错误日志 2024-05-29 13:41:19 +08:00
jxxghp
8eb89eec86 fix message 2024-05-28 15:39:17 +08:00
jxxghp
cfd7208cda Merge remote-tracking branch 'origin/main' 2024-05-28 12:25:39 +08:00
jxxghp
0c6684a572 fix #2208 下载历史错误数据兼容 2024-05-28 12:25:33 +08:00
jxxghp
f0692b2fb8 更新 __init__.py 2024-05-28 11:50:11 +08:00
jxxghp
c29ee4fb07 Merge pull request #2203 from BrettDean/main 2024-05-28 11:49:07 +08:00
jxxghp
dd40ef54c0 fix #1838 2024-05-28 08:16:51 +08:00
Dean
84d5e2a6b3 fix: Plex刷新媒体库无用 2024-05-28 02:11:42 +08:00
jxxghp
7defcff0e5 v1.9.2
- 修复了目录匹配时同路径优先无效的问题
- 修复了文件管理默认路径还在使用旧变量的问题
- 修复了会删除空媒体库目录的问题
- 修复了TR下载器整理后会覆盖任务原有标签的问题
- 修复了Windows打包,并默认内置了几个主要的插件库
- 优化了资源搜索页面剧集的过滤使用体验
- 硬链接模式下,如果硬链接失败(实际为复制)会发送通知提醒,同时历史记录的整理方式会显示为`复制`
- 消息类型新增了插件消息,专用于需要发送消息类的插件选择使用
2024-05-27 12:46:58 +08:00
jxxghp
d9e767f87d fix 新增消息类型显示 2024-05-27 12:31:48 +08:00
jxxghp
2b82173fba fix windows build 2024-05-27 12:27:15 +08:00
jxxghp
1425b15333 fix #2192 2024-05-27 11:16:41 +08:00
jxxghp
8d82d0f4fd Merge remote-tracking branch 'origin/main' 2024-05-27 10:26:21 +08:00
jxxghp
d352f09d4e fix #2193 2024-05-27 10:26:09 +08:00
jxxghp
aebd121939 Merge pull request #2195 from hotlcc/develop-20240527
Develop 20240527
2024-05-27 10:13:39 +08:00
jxxghp
81eed0d06d fix windows build 2024-05-27 10:11:41 +08:00
Allen
bacb7aaeb4 模块管理种新增根据模块id精确获取模块的方法,以便在插件等场景精确获取qb/tr等由系统统一维护的模块,而不需要插件各自为政 2024-05-27 10:09:18 +08:00
Allen
b238c6ad11 消息类型增加插件消息 2024-05-27 10:06:59 +08:00
jxxghp
5c8b843030 fix 目录健康检查 2024-05-27 08:46:44 +08:00
jxxghp
58acc62e16 feat:硬链接转复制时发系统通知提醒 2024-05-27 08:11:44 +08:00
jxxghp
ca5a240fc4 fix 同路径优先 2024-05-27 07:52:36 +08:00
jxxghp
dd5887d18d fix 同路径优先 2024-05-26 13:28:32 +08:00
jxxghp
97669405d0 fix #2185 2024-05-26 13:15:34 +08:00
jxxghp
bf2ea271b6 fix 硬链接检测 2024-05-26 12:48:52 +08:00
jxxghp
afd91bf760 v1.9.2-beta
- 修复了个别情况下剧集会超出下载范围的问题
- 修复了手动整理目的目录、订阅保存目录未去重显示的问题
- 手动整理下拉选择目的目录时,元数据刮削选项会自动跟随目录设定打开或关闭
- 当指定目的目录的情况下,会尝试查找是否有对应的目录设定,并应用目录的分类和刮削配置
2024-05-26 10:03:52 +08:00
jxxghp
7e982eaf4d fix 订阅整季重复下载 2024-05-26 09:34:50 +08:00
jxxghp
5f13824aa6 fix #2184 手动选择下载/媒体库目录时尝试查找对应的配置并做分类(如果查询不到则不自动分类) 2024-05-26 08:39:59 +08:00
jxxghp
9ca8e3f4a8 更新 category.py 2024-05-25 09:28:48 +08:00
jxxghp
9b749035c9 fix windows build 2024-05-25 07:21:26 +08:00
jxxghp
b8e09a6b06 fix 2024-05-25 07:14:24 +08:00
jxxghp
4bb95d519d v1.9.1-1
- 目前的目录设定结构已经可以做到按二级分类精细化设定目录,动漫一级分类已没有意义,现已取消,同时解决了动漫分类错误的问题。如动漫需单独一级目录,可在目录设定中对电影/电视剧下的动漫二级分类单独调定目录,并将优先级调高。 - 索引站点支持ToSky。 - 仪表板支持一个插件实现多个组件。
2024-05-25 06:37:28 +08:00
jxxghp
04280021b4 fix 动漫分类 2024-05-24 17:21:03 +08:00
jxxghp
355dad9205 Merge pull request #2167 from hotlcc/develop-20240524-插件支持多仪表板组件 2024-05-24 15:49:00 +08:00
Allen
a6714d3712 一个插件支持透出多个仪表板控件,并兼容历史 2024-05-24 14:54:46 +08:00
jxxghp
fe53819a81 fix directory helper 2024-05-24 13:26:20 +08:00
jxxghp
6965415c52 v1.9.1
- 回退了多线程优先级过滤,以暂时解决过滤器偶发报错的问题(搜索慢的过滤规则层数减一减吧)
- 发现推荐改为使用TTL缓存,解决榜单长时间不刷新的问题
- 修复了目录指定了类别时会重复创建类别文件夹的问题
- 订阅保存路径支持下拉选择已配置的下载目录
- 降低了INFO日志打印量,如需打印详细日志请设置`DEBUG`为`true`(写过多日志会降低响应速度)
- 目录设定中增加了同盘优先选项,开启后在匹配媒体库目录时优先使用同盘/同根路径目录
- 修复了一个订阅匹配的问题

注意:因媒体库目录设置结构调整,`目录监控`插件指定目的路径时不再支持自动创建下级分类目录。建议插件中不指定目的目录,而是在`设定->目录`中设定好目录结构让系统自动匹配使用(根据需要打开同盘优先选项)。实际上如果是可以通过分类来指定目录的,建议直接使用内建的下载器监控功能,目录监控仅是个插件,仅适用于不通过MoviePilot自动下载的文件监控整理。
2024-05-24 12:47:34 +08:00
jxxghp
9be671fa2c fix 82226f1956 2024-05-24 12:36:51 +08:00
jxxghp
27b4f206a1 fix 订阅刷新站点范围扩大问题 2024-05-24 12:31:11 +08:00
jxxghp
a2b0c9bd3a fix #2164 2024-05-24 11:37:26 +08:00
jxxghp
ebc46d7d3b fix #2165 2024-05-24 11:31:37 +08:00
jxxghp
eb4e4b5141 feat:新增同盘优先设置 2024-05-24 11:19:21 +08:00
jxxghp
be11ef72a9 调整INFO日志打印量 && 回滚多线程过滤 2024-05-24 10:48:56 +08:00
jxxghp
a278c80951 fix #2151 TMDB发现和趋势修改为TTL缓存 2024-05-24 09:29:51 +08:00
jxxghp
6ee6de48ff 尝试修复过滤器并发报错 2024-05-24 08:56:32 +08:00
jxxghp
671bdad77c v1.9.1-beta
- 优化了订阅匹配逻辑
- 目录设置全新改版,多目录支持更加灵活,存量目录配置会自动升级为新格式
- 手动整理时支持下拉选择已配置目录
- 元数据刮削取消了全局开关,按媒体库目录设置,目录监控等插件新增了刮削开关并需要手动打开
- 站点管理支持拖动排序
- 修复了仪表板不可见组件仍会刷新的问题

注意:涉及前端变化升级后需要清理浏览器缓存(仅清理缓存文件,无需清理cookie)
2024-05-23 19:43:08 +08:00
jxxghp
a9ff8ec96d 更新 README.md 2024-05-23 14:28:40 +08:00
jxxghp
d1678355f1 fix #2099 2024-05-23 12:45:27 +08:00
jxxghp
ea399daef9 更新 __init__.py 2024-05-23 11:46:30 +08:00
jxxghp
e1122af97c 更新 __init__.py 2024-05-23 10:05:56 +08:00
jxxghp
21861111e6 更新 download.py 2024-05-23 10:05:07 +08:00
jxxghp
bd1e83ee8a fix 2024-05-23 08:53:20 +08:00
jxxghp
43da33bc50 fix 2024-05-23 08:41:20 +08:00
jxxghp
a09a207407 fix manual_transfer api 2024-05-23 08:09:36 +08:00
jxxghp
0aa3aa8521 Merge pull request #2146 from zhu0823/main 2024-05-23 07:10:48 +08:00
jxxghp
d9c6375252 feat:目录结构重大调整,谨慎更新到dev 2024-05-22 20:02:14 +08:00
jxxghp
f1f187fc77 add media category api 2024-05-22 18:02:28 +08:00
zhu0823
99d22554a1 fix: plex最近添加类型为show的资源 2024-05-22 13:34:03 +08:00
jxxghp
4835f6c6c9 更新 subscribe.py 2024-05-21 23:17:59 +08:00
jxxghp
5be2bf0633 feat:服务器地址变量化 2024-05-21 17:31:12 +08:00
jxxghp
7c7bc0b504 fix log 2024-05-21 17:14:16 +08:00
jxxghp
e0939fee75 fix https://github.com/jxxghp/MoviePilot/issues/2134 2024-05-21 16:36:09 +08:00
jxxghp
82226f1956 fix https://github.com/jxxghp/MoviePilot/issues/2125 调整订阅匹配逻辑 2024-05-21 11:57:12 +08:00
jxxghp
cfb43b4b04 fix torrent match 2024-05-21 09:54:06 +08:00
jxxghp
ebe2795eae Merge pull request #2128 from jeblove/main 2024-05-21 06:48:54 +08:00
jeblove
f7f747278d fix: jellyfin webhook百分比数据有时为None引发报错 2024-05-20 23:47:37 +08:00
jxxghp
58f17e89b6 v1.9.0
- 支持用户自定义主题风格
- 资源搜索列表模式下支持排序
- 索引站点新增支持`YemaPT`
- 修复了某些情况下订阅查询转圈的问题
- 修复了默认洗版设置不生效的问题
- 修复了存在活动连接时无法正常关闭系统的问题
- 回退了上个版本的最后一点修改(会导致手机端添加桌面图标时没有App效果)
2024-05-20 18:24:39 +08:00
jxxghp
433ca2ec28 fix YemaPT 2024-05-20 17:41:46 +08:00
jxxghp
ffac57ad4d 支持 YemaPT 2024-05-20 16:55:36 +08:00
jxxghp
0d2a4c50d6 fix error message 2024-05-20 15:39:43 +08:00
jxxghp
02c2edc30e fix WEB页面活动时无法正常停止服务的问题 2024-05-20 11:50:49 +08:00
jxxghp
65975235d4 fix scheduler 2024-05-20 10:16:20 +08:00
jxxghp
07a6abde0e fix json exception 2024-05-20 09:13:51 +08:00
jxxghp
fa47d9adeb fix 2024-05-19 14:17:34 +08:00
jxxghp
18d08c3672 fix 2024-05-19 12:35:21 +08:00
jxxghp
cf20049b7f Merge pull request #2117 from InfinityPacer/main
fix 开启DEBUG时,LOG文件也记录DEBUG日志
2024-05-19 12:28:08 +08:00
InfinityPacer
e3ce3302da fix 开启DEBUG时,LOG文件也记录DEBUG日志 2024-05-19 12:18:43 +08:00
jxxghp
d20951e7a0 Merge pull request #2116 from InfinityPacer/main 2024-05-19 12:08:03 +08:00
InfinityPacer
8a565bb79f fix #2113 2024-05-19 11:53:18 +08:00
jxxghp
cfdc8fb2c3 Merge pull request #2115 from InfinityPacer/main 2024-05-19 11:02:19 +08:00
InfinityPacer
111f830664 fix #2104 2024-05-19 10:58:09 +08:00
jxxghp
2821d6a9dc fix #2076 2024-05-19 09:54:52 +08:00
jxxghp
495d98c2b2 fix #2086 没有站点时订阅打不开的问题 && 默认洗版不生效的问题 2024-05-19 09:52:00 +08:00
jxxghp
e1e2779e48 fix #2101 1ptba签到增加index.php,认证及种子浏览有带具体的URI,如有问题需要站点放开。 2024-05-19 09:42:22 +08:00
jxxghp
363318f4f0 智能下载增加日志打印 2024-05-19 09:19:44 +08:00
jxxghp
521b960364 catch command exception 2024-05-19 08:35:18 +08:00
jxxghp
d2bcb197eb v1.8.9
- 站点增加了独立的超时时间设置(默认15秒),可根据站点连通情况调整对应超时时间,以缩短搜索耗时
- 使用多线程加快搜索结果的优先级过滤速度
- 极大提升了非首次登录时的页面加载速度(需要重新拉取镜像该特性才会生效)
- 仪表仪的拖拽图标默认隐藏,避免影响无边框组件的显示效果
- 安装网页到桌面App时,支持前进后退操作按钮
2024-05-17 14:51:02 +08:00
jxxghp
b0f9ca52e3 fix nginx cache 2024-05-17 14:37:25 +08:00
jxxghp
01a3efd402 fix nginx cache 2024-05-17 13:58:29 +08:00
jxxghp
a50427948a feat:多线程过滤 2024-05-17 12:20:39 +08:00
jxxghp
5614f10962 fix get_dashboard 2024-05-17 10:50:03 +08:00
jxxghp
5ff80dbe89 fix get_dashboard 2024-05-17 10:49:47 +08:00
jxxghp
278835c5d4 fix 2024-05-17 07:46:27 +08:00
jxxghp
92cdd67f3a Merge pull request #2093 from thsrite/main 2024-05-16 20:53:57 +08:00
thsrite
c56b58cc56 fix 2024-05-16 20:50:42 +08:00
thsrite
8bd4c21511 fix 2024-05-16 20:49:28 +08:00
thsrite
b94f201667 fix 拆包场景下,下载通知标题展示实际下载的集数 2024-05-16 20:44:57 +08:00
jxxghp
125e9eb30a Merge pull request #2091 from happyTonakai/main
Jellyfin webhook 添加播放百分比字段
2024-05-16 20:36:24 +08:00
happyTonakai
ea09d8c8d4 Jellyfin webhook 添加播放百分比字段 2024-05-16 20:20:24 +08:00
jxxghp
de0237f348 dashboard 传递 UA 2024-05-16 17:32:35 +08:00
jxxghp
62143bf7b6 fix db 2024-05-16 16:18:22 +08:00
jxxghp
3088bbb2f8 站点独立设置超时时间 2024-05-16 14:42:10 +08:00
jxxghp
43647e59a4 fix module name 2024-05-16 14:20:18 +08:00
jxxghp
a740330e66 Update README.md 2024-05-15 19:25:53 +08:00
jxxghp
10d4766353 fix bug 2024-05-15 07:42:03 +08:00
jxxghp
0a845fe8b6 Merge remote-tracking branch 'origin/main' 2024-05-14 20:37:18 +08:00
jxxghp
90dc52bb70 v1.8.8
- 新增了系统级通知功能
- 优化了主题切换使用体验
- 修复了仪表板存储空间卡片无法移动的问题
- 修复了仪表板自动刷新组件会重复生成的问题
- 修复了插件图表在暗黑主题下字体颜色偏暗的问题
- 仪表板组件支持无边框模式
- 默认内置了几个主要的第三方插件库
- 优先级规则支持拖拽排序
2024-05-14 20:37:03 +08:00
jxxghp
3816e2fba8 Merge pull request #2073 from developer-wlj/wlj0409 2024-05-14 20:35:22 +08:00
mayun110
20d92ca577 fix: 收藏洗版 jellyfin webhook 缺少的字段 2024-05-14 20:11:49 +08:00
jxxghp
db92761964 fix ide warning 2024-05-14 18:04:10 +08:00
jxxghp
3af5870733 Merge pull request #2071 from hotlcc/develop-20240514-修复一些发现的问题 2024-05-14 18:00:08 +08:00
Allen
53d01267b8 修复模块加载时前置模块失败导致后续均无法加载的问题 2024-05-14 17:25:22 +08:00
jxxghp
5ff21641f9 内置部分第三方插件库 2024-05-14 15:27:50 +08:00
jxxghp
643f2e3e66 Merge remote-tracking branch 'origin/main' 2024-05-14 10:08:15 +08:00
jxxghp
ae839235eb feat:服务异常时推送前台消息 2024-05-14 10:08:07 +08:00
jxxghp
5af94144ce Merge pull request #2067 from thsrite/main 2024-05-14 09:48:05 +08:00
thsrite
4281692321 fix add插件热加载变量开关(保留DEV开关 2024-05-14 09:24:02 +08:00
jxxghp
bd6d6b6882 fix 2024-05-14 08:30:29 +08:00
jxxghp
fabb02a8a0 fix README 2024-05-13 20:25:52 +08:00
jxxghp
223e655b6f feat:插件API立即生效 2024-05-13 20:15:47 +08:00
jxxghp
f0cb5b3e85 Merge pull request #2059 from EkkoG/fix_min_seeders_time 2024-05-13 11:25:10 +08:00
EkkoG
7579aae823 修复订阅时没有读取 min_seeders_time 导致最少做种人数生效时间不生效 2024-05-13 11:06:49 +08:00
jxxghp
36a8b6d780 更新 download.py 2024-05-13 07:38:14 +08:00
jxxghp
0471167b74 fix dev 2024-05-12 20:12:59 +08:00
jxxghp
28b996e54b Merge pull request #2056 from DDS-Derek/bump 2024-05-12 11:17:13 +08:00
DDSRem
d0989f72a9 bump: debian bullseye to bookworm 2024-05-12 09:55:35 +08:00
jxxghp
663e61e3a1 fix message api 2024-05-11 16:24:25 +08:00
jxxghp
71b0090947 Merge remote-tracking branch 'origin/main' 2024-05-11 12:53:40 +08:00
jxxghp
8ff0f81f47 fix message api 2024-05-11 12:53:32 +08:00
jxxghp
4186613a86 Merge pull request #2051 from InfinityPacer/main 2024-05-11 06:25:50 +08:00
InfinityPacer
4e1be23317 fix 热加载增加双重防抖 2024-05-11 01:23:33 +08:00
jxxghp
0e9e626ab6 fix 2024-05-10 20:09:45 +08:00
jxxghp
3d5761157a Merge pull request #2047 from InfinityPacer/main
fix 热加载不同平台路径及插件实例化
2024-05-10 19:57:48 +08:00
InfinityPacer
c407800b30 fix 热加载不同平台路径及插件实例化 2024-05-10 19:27:44 +08:00
jxxghp
c888a37aba fix README 2024-05-10 18:29:20 +08:00
jxxghp
d43998efee fix 插件热加载控重 2024-05-10 18:27:31 +08:00
jxxghp
8813e84053 feat:DEV模式下插件文件修改后自动重新加载 2024-05-10 13:33:00 +08:00
jxxghp
cf5a746f53 fix #2037 2024-05-10 08:22:05 +08:00
jxxghp
f9f58fc559 v1.8.7
- 认证站点增加新成员:DiscFan
- 支持插件在仪表板中显示Widget,站点数据统计、站点刷流插件已支持,其余插件待开发者适配,插件开发说明:https://github.com/jxxghp/MoviePilot-Plugins
- 仪表板组件支持用户通过拖拽调整显示顺序
- 调整了热门订阅订阅人数的显示样式
2024-05-09 20:06:19 +08:00
jxxghp
f59b5b6d27 fix plugin dashboard 2024-05-09 19:12:43 +08:00
jxxghp
30b3ad4a99 fix plugin dashboard 2024-05-09 19:12:12 +08:00
jxxghp
dfb9ce7520 fix tmdb match api 2024-05-09 18:49:58 +08:00
jxxghp
6c365f552e Merge pull request #2038 from Devinaille/fix_subtitle_match 2024-05-09 11:40:03 +08:00
tianyf
a81ee7d89a 修复副标题包含【】的情况下无法匹配标题的问题 2024-05-09 11:18:00 +08:00
jxxghp
5c9039e6d0 feat:插件仪表板API 2024-05-08 20:58:28 +08:00
jxxghp
cce2e13e21 fix README.md 2024-05-08 16:46:35 +08:00
jxxghp
0da87abc71 Merge remote-tracking branch 'origin/main' 2024-05-08 15:54:56 +08:00
jxxghp
6a2eecc744 fix #2016 2024-05-08 15:54:50 +08:00
jxxghp
c049e13c1c Merge pull request #2032 from z3shan33/main 2024-05-08 13:04:13 +08:00
z3shan33
7ec49ce076 fix #2031 2024-05-08 11:09:36 +08:00
jxxghp
5be2fc35b5 Merge pull request #2026 from zhu0823/main 2024-05-07 18:31:12 +08:00
zhu0823
0b84312559 fix: 某些剧集parentThumb为None时的问题 2024-05-07 18:28:53 +08:00
jxxghp
8bb43b52bc - 优化热门订阅数据统计,热门订阅支持展示订阅人数 2024-05-07 16:19:05 +08:00
jxxghp
bd348f118c fix 订阅统计清理 2024-05-07 16:01:52 +08:00
jxxghp
4a3a3483d0 fix api 2024-05-07 12:31:24 +08:00
jxxghp
fd6314f19f fix 2024-05-06 22:48:24 +08:00
141 changed files with 6999 additions and 2402 deletions

View File

@@ -80,11 +80,13 @@ jobs:
- name: Prepare Frontend
run: |
# 下载nginx
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
Remove-Item -Path "nginx.zip"
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
# 下载前端
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
@@ -96,11 +98,31 @@ jobs:
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
New-Item -Path "nginx/logs" -ItemType Directory -Force
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
# 下载插件 jxxghp
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 thsrite
Invoke-WebRequest -Uri "https://github.com/thsrite/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 honue
Invoke-WebRequest -Uri "https://github.com/honue/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 InfinityPacer
Invoke-WebRequest -Uri "https://github.com/InfinityPacer/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载资源
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
@@ -137,6 +159,7 @@ jobs:
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
find app/plugins -name requirements.txt -exec pip install -r {} \;
- name: Prepare Frontend
run: |

4
.gitignore vendored
View File

@@ -15,4 +15,6 @@ config/user.db
config/sites/**
*.pyc
*.log
.vscode
.vscode
venv
.DS_Store

View File

@@ -1,4 +1,4 @@
FROM python:3.11.4-slim-bullseye
FROM python:3.11.4-slim-bookworm
ARG MOVIEPILOT_VERSION
ENV LANG="C.UTF-8" \
TZ="Asia/Shanghai" \
@@ -11,11 +11,12 @@ ENV LANG="C.UTF-8" \
PORT=3001 \
NGINX_PORT=3000 \
PROXY_HOST="" \
MOVIEPILOT_AUTO_UPDATE=release \
MOVIEPILOT_AUTO_UPDATE=false \
AUTH_SITE="iyuu" \
IYUU_SIGN=""
WORKDIR "/app"
RUN apt-get update -y \
&& apt-get upgrade -y \
&& apt-get -y install \
musl-dev \
nginx \
@@ -41,10 +42,6 @@ RUN apt-get update -y \
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
fi \
&& curl https://rclone.org/install.sh | bash \
&& echo "deb http://deb.debian.org/debian bookworm main" >> /etc/apt/sources.list \
&& apt-get update -y \
&& apt-get install -y --only-upgrade ca-certificates \
&& sed -i '/deb http:\/\/deb\.debian\.org\/debian bookworm main/d' /etc/apt/sources.list \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf \

267
README.md
View File

@@ -1,5 +1,14 @@
# MoviePilot
![GitHub Repo stars](https://img.shields.io/github/stars/jxxghp/MoviePilot?style=for-the-badge)
![GitHub forks](https://img.shields.io/github/forks/jxxghp/MoviePilot?style=for-the-badge)
![GitHub contributors](https://img.shields.io/github/contributors/jxxghp/MoviePilot?style=for-the-badge)
![GitHub repo size](https://img.shields.io/github/repo-size/jxxghp/MoviePilot?style=for-the-badge)
![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)
![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
@@ -7,261 +16,17 @@
发布频道https://t.me/moviepilot_channel
## 主要特性
- 前后端分离基于FastApi + Vue3前端项目地址[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)APIhttp://localhost:3001/docs
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
- 重新设计了用户界面,更加美观易用。
## 安装
## 安装使用
### 注意管理员用户不要使用弱密码如非必要不要暴露到公网。如被盗取管理账号权限将会导致站点Cookie等敏感数据泄露
访问官方Wikihttps://wiki.movie-pilot.org
### 1. **安装CookieCloud插件**
站点信息需要通过CookieCloud同步获取因此需要安装CookieCloud插件将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
### 2. **安装CookieCloud服务端可选**
通过CookieCloud可以快速同步浏览器中保存的站点数据到MoviePilot支持以下服务方式
- 使用公共CookieCloud远程服务器默认服务器地址为https://movie-pilot.org/cookiecloud
- 使用内建的本地Cookie服务`设定` - `站点` 中打开`启用本地CookieCloud服务器`将启用内建的CookieCloud提供服务服务地址为`http://localhost:${NGINX_PORT}/cookiecloud/`, Cookie数据加密保存在配置文件目录下的`cookies`文件中
- 自建服务CookieCloud服务器参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)
**声明:** 本项目不会收集用户敏感数据Cookie同步也是基于CookieCloud项目实现非本项目提供的能力。技术角度上CookieCloud采用端到端加密在个人不泄露`用户KEY``端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
### 3. **安装配套管理软件**
MoviePilot需要配套下载器和媒体服务器配合使用。
- 下载器支持qBittorrent、TransmissionQB版本号要求>= 4.3.9TR版本号要求>= 3.0推荐使用QB。
- 媒体服务器支持Jellyfin、Emby、Plex推荐使用Emby。
### 4. **安装MoviePilot**
- Docker镜像
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
```shell
docker pull jxxghp/moviepilot:latest
```
- Windows
1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases)双击运行后自动生成配置文件目录访问http://localhost:3000
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
- 群晖套件
添加套件源https://spk7.imnks.com/
- 本地运行
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
3) 执行命令:`pip install -r requirements.txt` 安装依赖
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
## 配置
大部分配置可启动后通过WEB管理界面进行配置但仍有部分配置需要通过环境变量/配置文件进行配置。
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件或通过WEB界面配置 > 默认值。
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
### 1. **环境变量**
- **❗NGINX_PORT** WEB服务端口默认`3000`可自行修改不能与API服务端口冲突
- **❗PORT** API服务端口默认`3001`可自行修改不能与WEB服务端口冲突
- **PUID**:运行程序用户的`uid`,默认`0`
- **PGID**:运行程序用户的`gid`,默认`0`
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
- **PROXY_HOST** 网络代理访问themoviedb或者重启更新需要使用代理访问格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
- **MOVIEPILOT_AUTO_UPDATE** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
- **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
| iyuu | `IYUU_SIGN`IYUU登录令牌 |
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
| audiences | `AUDIENCES_UID`用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
| hddolby | `HDDOLBY_ID`用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
| zmpt | `ZMPT_UID`用户ID<br/>`ZMPT_PASSKEY`:密钥 |
| freefarm | `FREEFARM_UID`用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
| hdfans | `HDFANS_UID`用户ID<br/>`HDFANS_PASSKEY`:密钥 |
| wintersakura | `WINTERSAKURA_UID`用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
| leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 |
| ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
| ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
| hdkyl | `HDKYL_UID`用户ID<br/>`HDKYL_PASSKEY`:密钥 |
| qingwa | `QINGWA_UID`用户ID<br/>`QINGWA_PASSKEY`:密钥 |
### 2. **环境变量 / 配置文件**
配置文件名:`app.env`,放配置文件根目录。
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
- **DOH_ENABLE** DNS over HTTPS开关`true`/`false`,默认`true`开启后会使用DOH对api.themoviedb.org等域名进行解析以减少被DNS污染的情况提升网络连通性
- **META_CACHE_EXPIRE** 元数据识别缓存过期时间小时数字型不配置或者配置为0时使用系统默认大内存模式为7天否则为3天调大该值可减少themoviedb的访问次数
- **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_****
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker镜像
---
- **TMDB_API_DOMAIN** TMDB API地址默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
- **TMDB_IMAGE_DOMAIN** TMDB图片地址默认`image.tmdb.org`可配置为其它中转代理以加速TMDB图片显示`static-mdb.v.geilijiasu.com`
- **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类,且受豆瓣控流限制
- **FANART_ENABLE** Fanart开关`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
- **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
- **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
---
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
- **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true`
- **SEARCH_MULTIPLE_NAME** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
- **SUBSCRIBE_STATISTIC_SHARE** 是否匿名分享订阅数据,用于统计和展示用户热门订阅,`true`/`false`,默认`true`
- **PLUGIN_STATISTIC_SHARE** 是否匿名分享插件安装统计数据,用于统计和显示插件下载安装次数,`true`/`false`,默认`true`
---
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
---
- **MOVIE_RENAME_FORMAT** 电影重命名格式基于jinjia2语法
`MOVIE_RENAME_FORMAT`支持的配置项:
> `title` TMDB/豆瓣中的标题
> `en_title` TMDB中的英文标题 (暂不支持豆瓣)
> `original_title` TMDB/豆瓣中的原语种标题
> `name` 从文件名中识别的名称(同时存在中英文时,优先使用中文)
> `en_name`:从文件名中识别的英文名称(可能为空)
> `original_name` 原文件名(包括文件外缀)
> `year` 年份
> `resourceType`:资源类型
> `effect`:特效
> `edition` 版本(资源类型+特效)
> `videoFormat` 分辨率
> `releaseGroup` 制作组/字幕组
> `customization` 自定义占位符
> `videoCodec` 视频编码
> `audioCodec` 音频编码
> `tmdbid` TMDB ID非TMDB识别源时为空
> `imdbid` IMDB ID可能为空
> `doubanid`豆瓣ID非豆瓣识别源时为空
> `part`:段/节
> `fileExt`:文件扩展名
> `customization`:自定义占位符
`MOVIE_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
```
- **TV_RENAME_FORMAT** 电视剧重命名格式基于jinjia2语法
`TV_RENAME_FORMAT`额外支持的配置项:
> `season` 季号
> `episode` 集号
> `season_episode` 季集 SxxExx
> `episode_title` 集标题
`TV_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
```
### 3. **优先级规则**
- 仅支持使用内置规则进行排列组合,通过设置多层规则来实现优先级顺序匹配
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
- 不符合过滤规则所有层级规则的资源将不会被选中
### 4. **插件扩展**
- **PLUGIN_MARKET** 插件市场仓库地址仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork或者查看频道置顶了解更多第三方插件仓库。
## 使用
### 1. **WEB后台管理**
- 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项默认用户admin默认端口3000
> ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务。
### 2. **站点维护**
- 通过CookieCloud同步快速添加站点不需要使用的站点可在WEB管理界面中禁用或删除无法同步的站点也可手动新增。
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
### 3. **文件整理**
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关且仅会处理通过MoviePilot添加下载的任务。
- 下载器监控默认轮循间隔为5分钟如果是使用qbittorrent可在 `QB设置`->`下载完成时运行外部程序` 处填入:`curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot" `实现无需等待轮循下载完成后立即整理入库地址、端口和token按实际调整curl也可更换为wget
- 使用`目录监控`等插件实现更灵活的自动整理。
### 4. **通知交互**
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/``VoceChat`的Webhook地址相对路径为`/api/v1/message/?token=moviepilot`其中moviepilot为设置的`API_TOKEN`。
### 5. **订阅与搜索**
- 通过MoviePilot管理后台搜索和订阅。
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等。
### 6. **其他**
- 通过设置媒体服务器Webhook指向MoviePilot相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`可实现通过MoviePilot发送播放通知以及配合各类插件实现播放限速等功能。
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
### **注意**
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
- 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
```nginx configuration
location / {
proxy_pass http://ip:port;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
- 反代使用ssl时需要开启`http2`,否则会导致日志加载时间过长或不可用。以`Nginx`为例:
```nginx configuration
server {
listen 443 ssl;
http2 on;
# ...
}
```
- 新建的企业微信应用需要固定公网IP的代理才能收到消息代理添加以下代码
```nginx configuration
location /cgi-bin/gettoken {
proxy_pass https://qyapi.weixin.qq.com;
}
location /cgi-bin/message/send {
proxy_pass https://qyapi.weixin.qq.com;
}
location /cgi-bin/menu/create {
proxy_pass https://qyapi.weixin.qq.com;
}
```
- 部分插件功能基于文件系统监控实现(如`目录监控`等需在宿主机上不是docker容器内执行以下命令并重启
```shell
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/f2654b09-26f3-464f-a0af-1de3f97832ee)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/fcb87529-56dd-43df-8337-6e34b8582819)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/bfa77c71-510a-46a6-9c1e-cf98cb101e3a)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/51cafd09-e38c-47f9-ae62-1e83ab8bf89b)
## 贡献者
<a href="https://github.com/jxxghp/MoviePilot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=jxxghp/MoviePilot" />
</a>

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
filebrowser, transfer, mediaserver, bangumi
local, transfer, mediaserver, bangumi, aliyun, u115
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -20,8 +20,9 @@ api_router.include_router(system.router, prefix="/system", tags=["system"])
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
api_router.include_router(download.router, prefix="/download", tags=["download"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
api_router.include_router(local.router, prefix="/local", tags=["local"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
api_router.include_router(aliyun.router, prefix="/aliyun", tags=["aliyun"])
api_router.include_router(u115.router, prefix="/u115", tags=["115"])

198
app/api/endpoints/aliyun.py Normal file
View File

@@ -0,0 +1,198 @@
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.aliyun import AliyunHelper
from app.helper.progress import ProgressHelper
from app.schemas.types import ProgressKey
router = APIRouter()
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data, errmsg = AliyunHelper().generate_qrcode()
if qrcode_data:
return schemas.Response(success=True, data=qrcode_data)
return schemas.Response(success=False, message=errmsg)
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
def check(ck: str, t: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
二维码登录确认
"""
if not ck or not t:
return schemas.Response(success=False, message="参数错误")
data, errmsg = AliyunHelper().check_login(ck, t)
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
@router.get("/userinfo", summary="查询用户信息", response_model=schemas.Response)
def userinfo(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询用户信息
"""
aliyunhelper = AliyunHelper()
# 查询用户信息返回
info = aliyunhelper.user_info()
if info:
return schemas.Response(success=True, data=info)
return schemas.Response(success=False)
@router.post("/list", summary="所有目录和文件(阿里云盘)", response_model=List[schemas.FileItem])
def list_aliyun(fileitem: schemas.FileItem,
sort: str = 'updated_at',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件夹信息
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
if not fileitem.fileid:
return []
if not fileitem.path:
path = "/"
else:
path = fileitem.path
if sort == "time":
sort = "updated_at"
if fileitem.type == "file":
fileitem = AliyunHelper().detail(drive_id=fileitem.drive_id, file_id=fileitem.fileid, path=path)
if fileitem:
return [fileitem]
return []
return AliyunHelper().list(drive_id=fileitem.drive_id,
parent_file_id=fileitem.fileid,
path=path,
order_by=sort)
@router.post("/mkdir", summary="创建目录(阿里云盘)", response_model=schemas.Response)
def mkdir_aliyun(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.fileid or not name:
return schemas.Response(success=False)
result = AliyunHelper().create_folder(drive_id=fileitem.drive_id, parent_file_id=fileitem.fileid,
name=name, path=fileitem.path)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录(阿里云盘)", response_model=schemas.Response)
def delete_aliyun(fileitem: schemas.FileItem,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.fileid:
return schemas.Response(success=False)
result = AliyunHelper().delete(drive_id=fileitem.drive_id, file_id=fileitem.fileid)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/download", summary="下载文件(阿里云盘)")
def download_aliyun(fileid: str,
drive_id: str = None,
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not fileid:
return schemas.Response(success=False)
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
if url:
# 重定向
return Response(status_code=302, headers={"Location": url})
raise HTTPException(status_code=500, detail="下载文件出错")
@router.post("/rename", summary="重命名文件或目录(阿里云盘)", response_model=schemas.Response)
def rename_aliyun(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.fileid or not new_name:
return schemas.Response(success=False)
result = AliyunHelper().rename(drive_id=fileitem.drive_id, file_id=fileitem.fileid, name=new_name)
if result:
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = list_aliyun(fileitem=fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper()
progress.start(ProgressKey.BatchRename)
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(value=handled / total * 100,
text=f"正在处理 {sub_file.name} ...",
key=ProgressKey.BatchRename)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(f"{fileitem.path}{sub_file.name}")
meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta)
if not mediainfo:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
ret: schemas.Response = rename_aliyun(fileitem=sub_file,
new_name=Path(new_path).name,
recursive=False)
if not ret.success:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/image", summary="读取图片(阿里云盘)", response_model=schemas.Response)
def image_aliyun(fileid: str, drive_id: str = None, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not fileid:
return schemas.Response(success=False)
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
if url:
# 重定向
return Response(status_code=302, headers={"Location": url})
raise HTTPException(status_code=500, detail="下载图片出错")

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
@@ -5,10 +6,10 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.core.config import settings
from app.core.security import verify_token, verify_uri_token
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.helper.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.utils.system import SystemUtils
@@ -35,7 +36,7 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic)
def statistic2(_: str = Depends(verify_uri_token)) -> Any:
def statistic2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
@@ -47,7 +48,8 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询存储空间信息
"""
total_storage, free_storage = SystemUtils.space_usage(settings.LIBRARY_PATHS)
library_dirs = DirectoryHelper().get_library_dirs()
total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
return schemas.Storage(
total_storage=total_storage,
used_storage=total_storage - free_storage
@@ -55,7 +57,7 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/storage2", summary="存储空间API_TOKEN", response_model=schemas.Storage)
def storage2(_: str = Depends(verify_uri_token)) -> Any:
def storage2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询存储空间信息 API_TOKEN认证?token=xxx
"""
@@ -75,6 +77,10 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询下载器信息
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_download_dirs()
_, free_space = SystemUtils.space_usage([Path(d.path) for d in download_dirs if d.path])
# 下载器信息
downloader_info = schemas.DownloaderInfo()
transfer_infos = DashboardChain().downloader_info()
if transfer_infos:
@@ -83,12 +89,12 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
downloader_info.upload_speed += transfer_info.upload_speed
downloader_info.download_size += transfer_info.download_size
downloader_info.upload_size += transfer_info.upload_size
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
downloader_info.free_space = free_space
return downloader_info
@router.get("/downloader2", summary="下载器信息API_TOKEN", response_model=schemas.DownloaderInfo)
def downloader2(_: str = Depends(verify_uri_token)) -> Any:
def downloader2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
@@ -104,7 +110,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/schedule2", summary="后台服务API_TOKEN", response_model=List[schemas.ScheduleInfo])
def schedule2(_: str = Depends(verify_uri_token)) -> Any:
def schedule2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
@@ -130,7 +136,7 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/cpu2", summary="获取当前CPU使用率API_TOKEN", response_model=int)
def cpu2(_: str = Depends(verify_uri_token)) -> Any:
def cpu2(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前CPU使用率 API_TOKEN认证?token=xxx
"""
@@ -146,7 +152,7 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/memory2", summary="获取当前内存使用量和使用率API_TOKEN", response_model=List[int])
def memory2(_: str = Depends(verify_uri_token)) -> Any:
def memory2(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前内存使用率 API_TOKEN认证?token=xxx
"""

View File

@@ -46,7 +46,9 @@ def download(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
"download_id": did
})
@@ -74,7 +76,9 @@ def add(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
"download_id": did
})

View File

@@ -1,189 +0,0 @@
import shutil
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends
from starlette.responses import FileResponse, Response
from app import schemas
from app.core.config import settings
from app.core.security import verify_token
from app.log import logger
from app.utils.system import SystemUtils
router = APIRouter()
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
@router.get("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
def list_path(path: str,
sort: str = 'time',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param path: 目录路径
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
# 返回结果
ret_items = []
if not path or path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
basename=partition
))
return ret_items
else:
path = "/"
else:
if not SystemUtils.is_windows() and not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.error(f"目录不存在:{path}")
return []
# 如果是文件
if path_obj.is_file():
ret_items.append(schemas.FileItem(
type="file",
path=str(path_obj).replace("\\", "/"),
name=path_obj.name,
basename=path_obj.stem,
extension=path_obj.suffix[1:],
size=path_obj.stat().st_size,
modify_time=path_obj.stat().st_mtime,
))
return ret_items
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
basename=item.stem,
modify_time=item.stat().st_mtime,
))
# 遍历所有文件,不含子目录
for item in SystemUtils.list_sub_files(path_obj,
settings.RMT_MEDIAEXT
+ settings.RMT_SUBEXT
+ IMAGE_TYPES
+ [".nfo"]):
ret_items.append(schemas.FileItem(
type="file",
path=str(item).replace("\\", "/"),
name=item.name,
basename=item.stem,
extension=item.suffix[1:],
size=item.stat().st_size,
modify_time=item.stat().st_mtime,
))
# 排序
if sort == 'time':
ret_items.sort(key=lambda x: x.modify_time, reverse=True)
else:
ret_items.sort(key=lambda x: x.name, reverse=False)
return ret_items
@router.get("/mkdir", summary="创建目录", response_model=schemas.Response)
def mkdir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not path:
return schemas.Response(success=False)
path_obj = Path(path)
if path_obj.exists():
return schemas.Response(success=False)
path_obj.mkdir(parents=True, exist_ok=True)
return schemas.Response(success=True)
@router.get("/delete", summary="删除文件或目录", response_model=schemas.Response)
def delete(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not path:
return schemas.Response(success=False)
path_obj = Path(path)
if not path_obj.exists():
return schemas.Response(success=True)
if path_obj.is_file():
path_obj.unlink()
else:
shutil.rmtree(path_obj, ignore_errors=True)
return schemas.Response(success=True)
@router.get("/download", summary="下载文件或目录")
def download(path: str, token: str) -> Any:
"""
下载文件或目录
"""
if not path:
return schemas.Response(success=False)
# 认证token
if not verify_token(token):
return None
path_obj = Path(path)
if not path_obj.exists():
return schemas.Response(success=False)
if path_obj.is_file():
# 做为文件流式下载
return FileResponse(path_obj)
else:
# 做为压缩包下载
shutil.make_archive(base_name=path_obj.stem, format="zip", root_dir=path_obj)
reponse = Response(content=path_obj.read_bytes(), media_type="application/zip")
# 删除压缩包
Path(f"{path_obj.stem}.zip").unlink()
return reponse
@router.get("/rename", summary="重命名文件或目录", response_model=schemas.Response)
def rename(path: str, new_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not path or not new_name:
return schemas.Response(success=False)
path_obj = Path(path)
if not path_obj.exists():
return schemas.Response(success=False)
path_obj.rename(path_obj.parent / new_name)
return schemas.Response(success=True)
@router.get("/image", summary="读取图片")
def image(path: str, token: str) -> Any:
"""
读取图片
"""
if not path:
return None
# 认证token
if not verify_token(token):
return None
path_obj = Path(path)
if not path_obj.exists():
return None
if not path_obj.is_file():
return None
# 判断是否图片文件
if path_obj.suffix.lower() not in IMAGE_TYPES:
return None
return Response(content=path_obj.read_bytes(), media_type="image/jpeg")

273
app/api/endpoints/local.py Normal file
View File

@@ -0,0 +1,273 @@
import shutil
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.progress import ProgressHelper
from app.log import logger
from app.schemas.types import ProgressKey
from app.utils.system import SystemUtils
router = APIRouter()
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
@router.post("/list", summary="所有目录和文件(本地)", response_model=List[schemas.FileItem])
def list_local(fileitem: schemas.FileItem,
sort: str = 'time',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
# 返回结果
ret_items = []
path = fileitem.path
if not fileitem.path or fileitem.path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
basename=partition
))
return ret_items
else:
path = "/"
else:
if SystemUtils.is_windows():
path = path.lstrip("/")
elif not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.warn(f"目录不存在:{path}")
return []
# 如果是文件
if path_obj.is_file():
ret_items.append(schemas.FileItem(
type="file",
path=str(path_obj).replace("\\", "/"),
name=path_obj.name,
basename=path_obj.stem,
extension=path_obj.suffix[1:],
size=path_obj.stat().st_size,
modify_time=path_obj.stat().st_mtime,
))
return ret_items
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
basename=item.stem,
modify_time=item.stat().st_mtime,
))
# 遍历所有文件,不含子目录
for item in SystemUtils.list_sub_files(path_obj,
settings.RMT_MEDIAEXT
+ settings.RMT_SUBEXT
+ IMAGE_TYPES
+ [".nfo"]):
ret_items.append(schemas.FileItem(
type="file",
path=str(item).replace("\\", "/"),
name=item.name,
basename=item.stem,
extension=item.suffix[1:],
size=item.stat().st_size,
modify_time=item.stat().st_mtime,
))
# 排序
if sort == 'time':
ret_items.sort(key=lambda x: x.modify_time, reverse=True)
else:
ret_items.sort(key=lambda x: x.name, reverse=False)
return ret_items
@router.get("/listdir", summary="所有目录(本地,不含文件)", response_model=List[schemas.FileItem])
def list_local_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录
"""
# 返回结果
ret_items = []
if not path or path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
children=[]
))
return ret_items
else:
path = "/"
else:
if not SystemUtils.is_windows() and not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.warn(f"目录不存在:{path}")
return []
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
children=[]
))
return ret_items
@router.post("/mkdir", summary="创建目录(本地)", response_model=schemas.Response)
def mkdir_local(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.path:
return schemas.Response(success=False)
path_obj = Path(fileitem.path) / name
if path_obj.exists():
return schemas.Response(success=False)
path_obj.mkdir(parents=True, exist_ok=True)
return schemas.Response(success=True)
@router.post("/delete", summary="删除文件或目录(本地)", response_model=schemas.Response)
def delete_local(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.path:
return schemas.Response(success=False)
path_obj = Path(fileitem.path)
if not path_obj.exists():
return schemas.Response(success=True)
if path_obj.is_file():
path_obj.unlink()
else:
shutil.rmtree(path_obj, ignore_errors=True)
return schemas.Response(success=True)
@router.get("/download", summary="下载文件(本地)")
def download_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not path:
return schemas.Response(success=False)
path_obj = Path(path)
if not path_obj.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if path_obj.is_file():
# 做为文件流式下载
return FileResponse(path_obj)
else:
# 做为压缩包下载
shutil.make_archive(base_name=path_obj.stem, format="zip", root_dir=path_obj)
reponse = Response(content=path_obj.read_bytes(), media_type="application/zip")
# 删除压缩包
Path(f"{path_obj.stem}.zip").unlink()
return reponse
@router.post("/rename", summary="重命名文件或目录(本地)", response_model=schemas.Response)
def rename_local(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.path or not new_name:
return schemas.Response(success=False)
path_obj = Path(fileitem.path)
if not path_obj.exists():
return schemas.Response(success=False)
path_obj.rename(path_obj.parent / new_name)
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = list_local(fileitem=fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper()
progress.start(ProgressKey.BatchRename)
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(value=handled / total * 100,
text=f"正在处理 {sub_file.name} ...",
key=ProgressKey.BatchRename)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(sub_file.path)
meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta)
if not mediainfo:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
ret: schemas.Response = rename_local(fileitem, new_name=Path(new_path).name, recursive=False)
if not ret.success:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=True)
@router.get("/image", summary="读取图片(本地)")
def image_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not path:
return None
path_obj = Path(path)
if not path_obj.exists():
return None
if not path_obj.is_file():
return None
# 判断是否图片文件
if path_obj.suffix.lower() not in IMAGE_TYPES:
raise HTTPException(status_code=500, detail="图片读取出错")
return Response(content=path_obj.read_bytes(), media_type="image/jpeg")

View File

@@ -1,5 +1,5 @@
from datetime import timedelta
from typing import Any
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.security import OAuth2PasswordRequestForm
@@ -13,6 +13,7 @@ from app.core.config import settings
from app.core.security import get_password_hash
from app.db import get_db
from app.db.models.user import User
from app.helper.sites import SitesHelper
from app.log import logger
from app.utils.web import WebUtils
@@ -21,9 +22,9 @@ router = APIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
async def login_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
otp_password: str = Form(None)
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
otp_password: str = Form(None)
) -> Any:
"""
获取认证Token
@@ -58,17 +59,20 @@ async def login_access_token(
elif user and not user.is_active:
raise HTTPException(status_code=403, detail="用户未启用")
logger.info(f"用户 {user.name} 登录成功!")
level = SitesHelper().auth_level
return schemas.Token(
access_token=security.create_access_token(
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level
),
token_type="bearer",
super_user=user.is_superuser,
user_name=user.name,
avatar=user.avatar
avatar=user.avatar,
level=level
)
@@ -78,18 +82,9 @@ def wallpaper() -> Any:
获取登录页面电影海报
"""
if settings.WALLPAPER == "tmdb":
return tmdb_wallpaper()
elif settings.WALLPAPER == "bing":
return bing_wallpaper()
return schemas.Response(success=False)
@router.get("/bing", summary="Bing每日壁纸", response_model=schemas.Response)
def bing_wallpaper() -> Any:
"""
获取Bing每日壁纸
"""
url = WebUtils.get_bing_wallpaper()
url = TmdbChain().get_random_wallpager()
else:
url = WebUtils.get_bing_wallpaper()
if url:
return schemas.Response(
success=True,
@@ -98,15 +93,12 @@ def bing_wallpaper() -> Any:
return schemas.Response(success=False)
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
def tmdb_wallpaper() -> Any:
@router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str])
def wallpapers() -> Any:
"""
获取TMDB电影海报
获取登录页面电影海报
"""
wallpager = TmdbChain().get_random_wallpager()
if wallpager:
return schemas.Response(
success=True,
message=wallpager
)
return schemas.Response(success=False)
if settings.WALLPAPER == "tmdb":
return TmdbChain().get_trending_wallpapers()
else:
return WebUtils.get_bing_wallpapers()

View File

@@ -8,7 +8,7 @@ from app.chain.media import MediaChain
from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.core.security import verify_token, verify_apitoken
from app.schemas import MediaType
router = APIRouter()
@@ -32,7 +32,7 @@ def recognize(title: str,
@router.get("/recognize2", summary="识别种子媒体信息API_TOKEN", response_model=schemas.Context)
def recognize2(title: str,
subtitle: str = None,
_: str = Depends(verify_uri_token)) -> Any:
_: str = Depends(verify_apitoken)) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证?token=xxx
"""
@@ -55,7 +55,7 @@ def recognize_file(path: str,
@router.get("/recognize_file2", summary="识别文件媒体信息API_TOKEN", response_model=schemas.Context)
def recognize_file2(path: str,
_: str = Depends(verify_uri_token)) -> Any:
_: str = Depends(verify_apitoken)) -> Any:
"""
根据文件路径识别媒体信息 API_TOKEN认证?token=xxx
"""
@@ -97,26 +97,39 @@ def search(title: str,
return result[(page - 1) * count:page * count]
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
def scrape(path: str,
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
def scrape(fileitem: schemas.FileItem,
storage: str = "local",
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
刮削媒体信息
"""
if not path:
if not fileitem or not fileitem.path:
return schemas.Response(success=False, message="刮削路径无效")
scrape_path = Path(path)
if not scrape_path.exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 识别
chain = MediaChain()
# 识别媒体信息
scrape_path = Path(fileitem.path)
meta = MetaInfoPath(scrape_path)
mediainfo = chain.recognize_media(meta)
mediainfo = chain.recognize_by_meta(meta)
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
# 刮削
chain.scrape_metadata(path=scrape_path, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
return schemas.Response(success=True, message="刮削完成")
if storage == "local":
if not scrape_path.exists():
return schemas.Response(success=False, message="刮削路径不存在")
else:
if not fileitem.fileid:
return schemas.Response(success=False, message="刮削文件ID无效")
# 手动刮削
chain.manual_scrape(storage=storage, fileitem=fileitem, meta=meta, mediainfo=mediainfo)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
@router.get("/category", summary="查询自动分类配置", response_model=dict)
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询自动分类配置
"""
return MediaChain().media_category() or {}
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)

View File

@@ -27,7 +27,10 @@ def play_item(itemid: str) -> schemas.Response:
return schemas.Response(success=False, msg="参数错误")
if not settings.MEDIASERVER:
return schemas.Response(success=False, msg="未配置媒体服务器")
mediaserver = settings.MEDIASERVER.split(",")[0]
# 查找一个不为空的值
mediaserver = next((server for server in settings.MEDIASERVER.split(",") if server), None)
if not mediaserver:
return schemas.Response(success=False, msg="未配置媒体服务器")
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
# 重定向到play_url
if not play_url:

View File

@@ -1,13 +1,15 @@
import json
from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import Request
from pywebpush import WebPushException, webpush
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse
from app import schemas
from app.chain.message import MessageChain
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
@@ -134,6 +136,11 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
else:
for switch in switchs:
return_list.append(NotificationSwitch(**switch))
for noti in NotificationType:
if not any([x.mtype == noti.value for x in return_list]):
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True, vocechat=True))
return return_list
@@ -150,3 +157,36 @@ def set_switchs(switchs: List[NotificationSwitch],
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
return schemas.Response(success=True)
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
"""
客户端webpush通知订阅
"""
subinfo = subscription.dict()
if subinfo not in global_vars.get_subscriptions():
global_vars.push_subscription(subinfo)
logger.debug(f"通知订阅成功: {subinfo}")
return schemas.Response(success=True)
@router.post("/webpush/send", summary="发送webpush通知", response_model=schemas.Response)
def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)):
"""
发送webpush通知
"""
for sub in global_vars.get_subscriptions():
try:
webpush(
subscription_info=sub,
data=json.dumps(payload.dict()),
vapid_private_key=settings.VAPID.get("privateKey"),
vapid_claims={
"sub": settings.VAPID.get("subject")
},
)
except WebPushException as err:
logger.error(f"WebPush发送失败: {str(err)}")
continue
return schemas.Response(success=True)

View File

@@ -1,6 +1,6 @@
from typing import Any, List
from typing import Any, List, Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header
from app import schemas
from app.core.plugin import PluginManager
@@ -13,6 +13,29 @@ from app.schemas.types import SystemConfigKey
router = APIRouter()
def register_plugin_api(plugin_id: str = None):
"""
注册插件API先删除后新增
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
router.add_api_route(**api)
def remove_plugin_api(plugin_id: str):
"""
移除插件API
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> List[schemas.Plugin]:
"""
@@ -85,13 +108,19 @@ def install(plugin_id: str,
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 如果是非本地括件,或者强制安装时,则需要下载安装
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
# 下载安装
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
if not state:
# 安装失败
return schemas.Response(success=False, message=msg)
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
if not force and plugin_id in PluginManager().get_plugin_ids():
PluginHelper().install_reg(pid=plugin_id)
else:
# 插件不存在或需要强制安装,下载安装并注册插件
if repo_url:
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
# 安装失败则直接响应
if not state:
return schemas.Response(success=False, message=msg)
else:
# repo_url 为空时,也直接响应
return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置")
# 安装插件
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
@@ -101,6 +130,8 @@ def install(plugin_id: str,
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -125,20 +156,47 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
return PluginManager().get_plugin_page(plugin_id)
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
获取所有插件仪表板元信息
"""
return PluginManager().get_plugin_dashboard_meta()
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, key=None, user_agent=user_agent)
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据插件ID重置插件配置
根据插件ID重置插件配置及数据
"""
# 删除配置
PluginManager().delete_plugin_config(plugin_id)
# 删除插件所有数据
PluginManager().delete_plugin_data(plugin_id)
# 重新生效插件
PluginManager().init_plugin(plugin_id, {
"enabled": False,
"enable": False
})
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -162,6 +220,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
PluginManager().init_plugin(plugin_id, conf)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -183,9 +243,10 @@ def uninstall_plugin(plugin_id: str,
PluginManager().remove_plugin(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
# 移除插件API
remove_plugin_api(plugin_id)
return schemas.Response(success=True)
# 注册插件API
for api in PluginManager().get_plugin_apis():
router.add_api_route(**api)
# 注册全部插件API
register_plugin_api()

View File

@@ -13,7 +13,7 @@ router = APIRouter()
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询搜索结果
"""
@@ -52,6 +52,8 @@ def search_by_id(mediaid: str,
# 通过豆瓣ID识别TMDBID
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
if tmdbinfo.get('season') and not season:
season = tmdbinfo.get('season')
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area, season=season)
else:
@@ -85,13 +87,15 @@ def search_by_id(mediaid: str,
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])
async def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
return [torrent.to_dict() for torrent in torrents]
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])

View File

@@ -94,24 +94,6 @@ def update_site(
return schemas.Response(success=True)
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
def delete_site(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)
) -> Any:
"""
删除站点
"""
Site.delete(db, site_id)
# 插件站点删除
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
return schemas.Response(success=True)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
def cookie_cloud_sync(background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -141,6 +123,21 @@ def reset(db: Session = Depends(get_db),
return schemas.Response(success=True, message="站点已重置!")
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
def update_sites_priority(
priorities: List[dict],
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
批量更新站点优先级
"""
for priority in priorities:
site = Site.get(db, priority.get("id"))
if site:
site.update(db, {"pri": priority.get("pri")})
return schemas.Response(success=True)
@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response)
def update_cookie(
site_id: int,
@@ -293,3 +290,21 @@ def read_site(
detail=f"站点 {site_id} 不存在",
)
return site
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
def delete_site(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)
) -> Any:
"""
删除站点
"""
Site.delete(db, site_id)
# 插件站点删除
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
return schemas.Response(success=True)

View File

@@ -10,7 +10,7 @@ from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_uri_token
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
@@ -42,12 +42,17 @@ def read_subscribes(
subscribes = Subscribe.list(db)
for subscribe in subscribes:
if subscribe.sites:
subscribe.sites = json.loads(subscribe.sites)
try:
subscribe.sites = json.loads(str(subscribe.sites))
except json.JSONDecodeError:
subscribe.sites = []
else:
subscribe.sites = []
return subscribes
@router.get("/list", summary="查询所有订阅API_TOKEN", response_model=List[schemas.Subscribe])
def list_subscribes(_: str = Depends(verify_uri_token)) -> Any:
def list_subscribes(_: str = Depends(verify_apitoken)) -> Any:
"""
查询所有订阅 API_TOKEN认证?token=xxx
"""
@@ -166,7 +171,10 @@ def subscribe_mediaid(
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites:
result.sites = json.loads(result.sites)
try:
result.sites = json.loads(result.sites)
except json.JSONDecodeError:
result.sites = []
return result if result else Subscribe()
@@ -181,6 +189,24 @@ def refresh_subscribes(
return schemas.Response(success=True)
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
def reset_subscribes(
subid: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重置订阅
"""
subscribe = Subscribe.get(db, subid)
if subscribe:
subscribe.update(db, {
"note": "",
"lack_episode": subscribe.total_episode
})
return schemas.Response(success=True)
return schemas.Response(success=False, message="订阅不存在")
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
def check_subscribes(
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -320,7 +346,10 @@ def read_subscribe(
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
for history in historys:
if history and history.sites:
history.sites = json.loads(history.sites)
try:
history.sites = json.loads(history.sites)
except json.JSONDecodeError:
history.sites = []
return historys
@@ -342,6 +371,7 @@ def popular_subscribes(
stype: str,
page: int = 1,
count: int = 30,
min_sub: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询热门订阅
@@ -350,8 +380,13 @@ def popular_subscribes(
if subscribes:
ret_medias = []
for sub in subscribes:
# 订阅人数
count = sub.get("count")
if min_sub and count < min_sub:
continue
media = MediaInfo()
media.type = MediaType(sub.get("type"))
media.tmdb_id = sub.get("tmdbid")
# 处理标题
title = sub.get("name")
season = sub.get("season")
@@ -361,7 +396,6 @@ def popular_subscribes(
title = f"{title}{season_str}"
media.title = title
media.year = sub.get("year")
media.tmdb_id = sub.get("tmdbid")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.tvdb_id = sub.get("tvdbid")
@@ -371,6 +405,7 @@ def popular_subscribes(
media.vote_average = sub.get("vote")
media.poster_path = sub.get("poster")
media.backdrop_path = sub.get("backdrop")
media.popularity = count
ret_medias.append(media)
return [media.to_dict() for media in ret_medias]
return []
@@ -388,7 +423,10 @@ def read_subscribe(
return Subscribe()
subscribe = Subscribe.get(db, subscribe_id)
if subscribe and subscribe.sites:
subscribe.sites = json.loads(subscribe.sites)
try:
subscribe.sites = json.loads(subscribe.sites)
except json.JSONDecodeError:
subscribe.sites = []
return subscribe
@@ -401,5 +439,12 @@ def delete_subscribe(
"""
删除订阅信息
"""
Subscribe.delete(db, subscribe_id)
subscribe = Subscribe.get(db, subscribe_id)
if subscribe:
subscribe.delete(db, subscribe_id)
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
return schemas.Response(success=True)

View File

@@ -11,7 +11,7 @@ from fastapi.responses import StreamingResponse
from app import schemas
from app.chain.search import SearchChain
from app.chain.system import SystemChain
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.module import ModuleManager
from app.core.security import verify_token
from app.db.models import User
@@ -99,6 +99,8 @@ def get_progress(process_type: str, token: str):
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = progress.get(process_type)
yield 'data: %s\n\n' % json.dumps(detail)
time.sleep(0.2)
@@ -142,7 +144,7 @@ def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
@router.get("/message", summary="实时消息")
def get_message(token: str, role: str = "sys"):
def get_message(token: str, role: str = "system"):
"""
实时获取系统消息返回格式为SSE
"""
@@ -156,6 +158,8 @@ def get_message(token: str, role: str = "sys"):
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = message.get(role)
yield 'data: %s\n\n' % (detail or '')
time.sleep(3)
@@ -184,6 +188,8 @@ def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
for line in f.readlines()[-max(length, 50):]:
yield 'data: %s\n\n' % line
while True:
if global_vars.is_system_stopped():
break
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (t or '')
time.sleep(1)
@@ -280,9 +286,12 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
"""
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
modules = [{
"id": k,
"name": v.get_name(),
} for k, v in ModuleManager().get_modules().items()]
return schemas.Response(success=True, data={
"ids": module_ids
"modules": modules
})
@@ -302,6 +311,8 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
"""
if not SystemUtils.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemUtils.restart()
return schemas.Response(success=ret, message=msg)

View File

@@ -5,8 +5,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.transfer import TransferChain
from app.core.security import verify_token, verify_uri_token
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.schemas import MediaType
@@ -14,8 +16,41 @@ from app.schemas import MediaType
router = APIRouter()
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
def query_name(path: str, filetype: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询整理后的名称
:param path: 文件路径
:param filetype: 文件类型
:param _: Token校验
"""
meta = MetaInfoPath(Path(path))
mediainfo = MediaChain().recognize_media(meta)
if not mediainfo:
return schemas.Response(success=False, message="未识别到媒体信息")
new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
return schemas.Response(success=False, message="未识别到新名称")
if filetype == "dir":
parents = Path(new_path).parents
if len(parents) > 2:
new_name = parents[1].name
else:
new_name = parents[0].name
else:
new_name = Path(new_path).name
return schemas.Response(success=True, data={
"name": new_name
})
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
def manual_transfer(path: str = None,
def manual_transfer(storage: str = "local",
path: str = None,
drive_id: str = None,
fileid: str = None,
filetype: str = None,
logid: int = None,
target: str = None,
tmdbid: int = None,
@@ -28,11 +63,16 @@ def manual_transfer(path: str = None,
episode_part: str = None,
episode_offset: int = 0,
min_filesize: int = 0,
scrape: bool = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
手动转移,文件或历史记录,支持自定义剧集识别格式
:param storage: 存储类型local/aliyun/u115
:param path: 转移路径或文件
:param drive_id: 云盘ID网盘等
:param fileid: 文件ID网盘等
:param filetype: 文件类型dir/file
:param logid: 转移历史记录ID
:param target: 目标路径
:param type_name: 媒体类型、电影/电视剧
@@ -45,6 +85,7 @@ def manual_transfer(path: str = None,
:param episode_part: 剧集识别分集信息
:param episode_offset: 剧集识别偏移量
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param db: 数据库
:param _: Token校验
"""
@@ -68,10 +109,6 @@ def manual_transfer(path: str = None,
if history.dest and str(history.dest) != "None":
# 删除旧的已整理文件
transfer.delete_files(Path(history.dest))
if not target:
target = transfer.get_root_path(path=history.dest,
type_name=history.type,
category=history.category)
elif path:
in_path = Path(path)
else:
@@ -90,7 +127,11 @@ def manual_transfer(path: str = None,
)
# 开始转移
state, errormsg = transfer.manual_transfer(
storage=storage,
in_path=in_path,
drive_id=drive_id,
fileid=fileid,
filetype=filetype,
target=target,
tmdbid=tmdbid,
doubanid=doubanid,
@@ -99,6 +140,7 @@ def manual_transfer(path: str = None,
transfer_type=transfer_type,
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
force=force
)
# 失败
@@ -111,7 +153,7 @@ def manual_transfer(path: str = None,
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
def now(_: str = Depends(verify_uri_token)) -> Any:
def now(_: str = Depends(verify_apitoken)) -> Any:
"""
立即执行下载器文件整理 API_TOKEN认证?token=xxx
"""

213
app/api/endpoints/u115.py Normal file
View File

@@ -0,0 +1,213 @@
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.progress import ProgressHelper
from app.helper.u115 import U115Helper
from app.schemas.types import ProgressKey
from app.utils.http import RequestUtils
router = APIRouter()
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data = U115Helper().generate_qrcode()
if qrcode_data:
return schemas.Response(success=True, data={
'codeContent': qrcode_data
})
return schemas.Response(success=False)
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
def check(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
二维码登录确认
"""
data, errmsg = U115Helper().check_login()
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
@router.get("/storage", summary="查询存储空间信息", response_model=schemas.Response)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询存储空间信息
"""
storage_info = U115Helper().storage()
if storage_info:
return schemas.Response(success=True, data={
"total": storage_info[0],
"used": storage_info[1]
})
return schemas.Response(success=False)
@router.post("/list", summary="所有目录和文件115网盘", response_model=List[schemas.FileItem])
def list_115(fileitem: schemas.FileItem,
sort: str = 'updated_at',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
if not fileitem.fileid:
return []
if not fileitem.path:
path = "/"
else:
path = fileitem.path
if fileitem.fileid == "root":
fileid = "0"
else:
fileid = fileitem.fileid
if fileitem.type == "file":
name = Path(path).name
suffix = Path(name).suffix[1:]
return [schemas.FileItem(
fileid=fileid,
type="file",
path=path.rstrip('/'),
name=name,
extension=suffix,
pickcode=fileitem.pickcode
)]
file_list = U115Helper().list(parent_file_id=fileid, path=path)
if sort == "name":
file_list.sort(key=lambda x: x.name)
else:
file_list.sort(key=lambda x: x.modify_time, reverse=True)
return file_list
@router.post("/mkdir", summary="创建目录115网盘", response_model=schemas.Response)
def mkdir_115(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.fileid or not name:
return schemas.Response(success=False)
result = U115Helper().create_folder(parent_file_id=fileitem.fileid, name=name, path=fileitem.path)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录115网盘", response_model=schemas.Response)
def delete_115(fileitem: schemas.FileItem,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.fileid:
return schemas.Response(success=False)
result = U115Helper().delete(fileitem.fileid)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/download", summary="下载文件115网盘")
def download_115(pickcode: str,
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not pickcode:
return schemas.Response(success=False)
ticket = U115Helper().download(pickcode)
if ticket:
# 请求数据,并以文件流的方式返回
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
if res:
return Response(content=res.content, media_type="application/octet-stream")
return schemas.Response(success=False)
@router.post("/rename", summary="重命名文件或目录115网盘", response_model=schemas.Response)
def rename_115(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.fileid or not new_name:
return schemas.Response(success=False)
result = U115Helper().rename(fileitem.fileid, new_name)
if result:
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = list_115(fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper()
progress.start(ProgressKey.BatchRename)
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(value=handled / total * 100,
text=f"正在处理 {sub_file.name} ...",
key=ProgressKey.BatchRename)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(f"{fileitem.path}{sub_file.name}")
meta = MetaInfoPath(sub_path)
mediainfo = transferchain.recognize_media(meta)
if not mediainfo:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
if not new_path:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
ret: schemas.Response = rename_115(fileitem=sub_file,
new_name=Path(new_path).name,
recursive=False)
if not ret.success:
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end(ProgressKey.BatchRename)
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/image", summary="读取图片115网盘")
def image_115(pickcode: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not pickcode:
return schemas.Response(success=False)
ticket = U115Helper().download(pickcode)
if ticket:
# 请求数据获取内容编码为图片base64返回
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
if res:
content_type = res.headers.get("Content-Type")
return Response(content=res.content, media_type=content_type)
raise HTTPException(status_code=500, detail="下载图片出错")

View File

@@ -87,8 +87,8 @@ def read_current_user(
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
async def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user)):
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user)):
"""
上传用户头像
"""

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends
from app import schemas
from app.chain.webhook import WebhookChain
from app.core.security import verify_uri_token
from app.core.security import verify_apitoken
router = APIRouter()
@@ -19,7 +19,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
request: Request,
_: str = Depends(verify_uri_token)
_: str = Depends(verify_apitoken)
) -> Any:
"""
Webhook响应
@@ -32,8 +32,8 @@ async def webhook_message(background_tasks: BackgroundTasks,
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: str = Depends(verify_uri_token)) -> Any:
def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: str = Depends(verify_apitoken)) -> Any:
"""
Webhook响应
"""

View File

@@ -6,9 +6,8 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.metainfo import MetaInfo
from app.core.security import verify_uri_apikey
from app.core.security import verify_apikey
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.schemas import RadarrMovie, SonarrSeries
@@ -19,7 +18,7 @@ arr_router = APIRouter(tags=['servarr'])
@arr_router.get("/system/status", summary="系统状态")
def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
def arr_system_status(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr系统状态
"""
@@ -73,7 +72,7 @@ def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
@arr_router.get("/qualityProfile", summary="质量配置")
def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr质量配置
"""
@@ -114,14 +113,14 @@ def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
@arr_router.get("/rootfolder", summary="根目录")
def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr根目录
"""
return [
{
"id": 1,
"path": "/" if not settings.LIBRARY_PATHS else str(settings.LIBRARY_PATHS[0]),
"path": "/",
"accessible": True,
"freeSpace": 0,
"unmappedFolders": []
@@ -130,7 +129,7 @@ def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
@arr_router.get("/tag", summary="标签")
def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
def arr_tag(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr标签
"""
@@ -143,7 +142,7 @@ def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
@arr_router.get("/languageprofile", summary="语言")
def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr语言
"""
@@ -169,7 +168,7 @@ def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Rardar电影
"""
@@ -260,7 +259,7 @@ def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Rardar电影 term: `tmdb:${id}`
存在和不存在均不能返回错误
@@ -306,7 +305,7 @@ def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Rardar电影订阅
"""
@@ -334,7 +333,7 @@ def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_u
@arr_router.post("/movie", summary="新增电影订阅")
def arr_add_movie(movie: RadarrMovie,
db: Session = Depends(get_db),
_: str = Depends(verify_uri_apikey)
_: str = Depends(verify_apikey)
) -> Any:
"""
新增Rardar电影订阅
@@ -363,7 +362,7 @@ def arr_add_movie(movie: RadarrMovie,
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
删除Rardar电影订阅
"""
@@ -379,7 +378,7 @@ def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(v
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Sonarr剧集
"""
@@ -515,7 +514,7 @@ def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db
@arr_router.get("/series/lookup", summary="查询剧集")
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
@@ -604,7 +603,7 @@ def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends
@arr_router.get("/series/{tid}", summary="剧集详情")
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Sonarr剧集
"""
@@ -640,7 +639,7 @@ def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_u
@arr_router.post("/series", summary="新增剧集订阅")
def arr_add_series(tv: schemas.SonarrSeries,
db: Session = Depends(get_db),
_: str = Depends(verify_uri_apikey)) -> Any:
_: str = Depends(verify_apikey)) -> Any:
"""
新增Sonarr剧集订阅
"""
@@ -682,7 +681,7 @@ def arr_add_series(tv: schemas.SonarrSeries,
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
删除Sonarr剧集订阅
"""

View File

@@ -10,8 +10,7 @@ from ruamel.yaml import CommentedMap
from transmission_rpc import File
from app.core.config import settings
from app.core.context import Context
from app.core.context import MediaInfo, TorrentInfo
from app.core.context import Context, MediaInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.module import ModuleManager
@@ -79,6 +78,7 @@ class ChainBase(metaclass=ABCMeta):
def run_module(self, method: str, *args, **kwargs) -> Any:
"""
运行包含该方法的所有模块,然后返回结果
当kwargs包含命名参数raise_exception时如模块方法抛出异常且raise_exception为True则同步抛出异常
"""
def is_result_empty(ret):
@@ -92,8 +92,14 @@ class ChainBase(metaclass=ABCMeta):
logger.debug(f"请求模块执行:{method} ...")
result = None
modules = self.modulemanager.get_modules(method)
modules = self.modulemanager.get_running_modules(method)
for module in modules:
module_id = module.__class__.__name__
try:
module_name = module.get_name()
except Exception as err:
logger.error(f"获取模块名称出错:{str(err)}")
module_name = module_id
try:
func = getattr(module, method)
if is_result_empty(result):
@@ -111,8 +117,24 @@ class ChainBase(metaclass=ABCMeta):
# 中止继续执行
break
except Exception as err:
if kwargs.get("raise_exception"):
raise
logger.error(
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
self.messagehelper.put(title=f"{module_name}发生了错误",
message=str(err),
role="system")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "module",
"module_id": module_id,
"module_name": module_name,
"module_method": method,
"error": str(err),
"traceback": traceback.format_exc()
}
)
return result
def recognize_media(self, meta: MetaBase = None,
@@ -122,7 +144,7 @@ class ChainBase(metaclass=ABCMeta):
bangumiid: int = None,
cache: bool = True) -> Optional[MediaInfo]:
"""
识别媒体信息
识别媒体信息不含Fanart图片
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
@@ -146,7 +168,8 @@ class ChainBase(metaclass=ABCMeta):
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
mtype: MediaType = None, year: str = None, season: int = None,
raise_exception: bool = False) -> Optional[dict]:
"""
搜索和匹配豆瓣信息
:param name: 标题
@@ -154,9 +177,10 @@ class ChainBase(metaclass=ABCMeta):
:param mtype: 类型
:param year: 年份
:param season: 季
:param raise_exception: 触发速率限制时是否抛出异常
"""
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
mtype=mtype, year=year, season=season)
mtype=mtype, year=year, season=season, raise_exception=raise_exception)
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
year: str = None, season: int = None) -> Optional[dict]:
@@ -194,14 +218,15 @@ class ChainBase(metaclass=ABCMeta):
image_prefix=image_prefix, image_type=image_type,
season=season, episode=episode)
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = False) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:param mtype: 媒体类型
:return: 豆瓣信息
:param raise_exception: 触发速率限制时是否抛出异常
"""
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype)
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception)
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
"""
@@ -211,14 +236,15 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("tvdb_info", tvdbid=tvdbid)
def tmdb_info(self, tmdbid: int, mtype: MediaType) -> Optional[dict]:
def tmdb_info(self, tmdbid: int, mtype: MediaType, season: int = None) -> Optional[dict]:
"""
获取TMDB信息
:param tmdbid: int
:param mtype: 媒体类型
:param season: 季
:return: TVDB信息
"""
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype)
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season)
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
@@ -350,7 +376,8 @@ class ChainBase(metaclass=ABCMeta):
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
episodes_info: List[TmdbEpisode] = None,
scrape: bool = None) -> Optional[TransferInfo]:
"""
文件转移
:param path: 文件路径
@@ -359,12 +386,14 @@ class ChainBase(metaclass=ABCMeta):
:param transfer_type: 转移模式
:param target: 转移目标路径
:param episodes_info: 当前季的全部集信息
:param scrape: 是否刮削元数据
:return: {path, target_path, message}
"""
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
transfer_type=transfer_type, target=target, episodes_info=episodes_info)
transfer_type=transfer_type, target=target, episodes_info=episodes_info,
scrape=scrape)
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
def transfer_completed(self, hashs: str, path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
"""
转移完成后的处理
@@ -498,6 +527,21 @@ class ChainBase(metaclass=ABCMeta):
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, metainfo=metainfo,
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
def metadata_img(self, mediainfo: MediaInfo, season: int = None) -> Optional[dict]:
"""
获取图片名称和url
:param mediainfo: 媒体信息
:param season: 季号
"""
return self.run_module("metadata_img", mediainfo=mediainfo, season=season)
def media_category(self) -> Optional[Dict[str, list]]:
"""
获取媒体分类
:return: 获取二级分类配置字典项,需包括电影、电视剧
"""
return self.run_module("media_category")
def register_commands(self, commands: Dict[str, dict]) -> None:
"""
注册菜单命令

View File

@@ -6,6 +6,7 @@ import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
@@ -14,6 +15,8 @@ from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.mediaserver_oper import MediaServerOper
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
@@ -32,9 +35,12 @@ class DownloadChain(ChainBase):
self.torrent = TorrentHelper()
self.downloadhis = DownloadHistoryOper()
self.mediaserver = MediaServerOper()
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
channel: MessageChannel = None, userid: str = None, username: str = None):
channel: MessageChannel = None, userid: str = None, username: str = None,
download_episodes: str = None):
"""
发送添加下载的消息
:param meta: 元数据
@@ -43,6 +49,7 @@ class DownloadChain(ChainBase):
:param channel: 通知渠道
:param userid: 用户ID指定时精确发送对应用户
:param username: 通知显示的下载用户信息
:param download_episodes: 下载的集数
"""
msg_text = ""
if username:
@@ -69,6 +76,8 @@ class DownloadChain(ChainBase):
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
if torrent.hit_and_run:
msg_text = f"{msg_text}\nHit&Run"
if torrent.labels:
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
if torrent.description:
html_re = re.compile(r'<[^>]+>', re.S)
description = html_re.sub('', torrent.description)
@@ -80,9 +89,10 @@ class DownloadChain(ChainBase):
mtype=NotificationType.Download,
userid=userid,
title=f"{mediainfo.title_year} "
f"{meta.season_episode} 开始下载",
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
text=msg_text,
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=settings.MP_DOMAIN('/#/downloading')))
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
@@ -207,6 +217,16 @@ class DownloadChain(ChainBase):
_torrent = context.torrent_info
_media = context.media_info
_meta = context.meta_info
# 补充完整的media数据
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
doubanid=_media.douban_id, bangumiid=_media.bangumi_id)
if new_media:
_media = new_media
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
_folder_name = ""
if not torrent_file:
# 下载种子文件,得到的可能是文件也可能是磁力链
@@ -221,39 +241,35 @@ class DownloadChain(ChainBase):
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
# 下载目录
if not save_path:
if settings.DOWNLOAD_CATEGORY and _media and _media.category:
# 开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = settings.SAVE_MOVIE_PATH / _media.category
else:
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = settings.SAVE_ANIME_PATH / _media.category
else:
# 电视剧
download_dir = settings.SAVE_TV_PATH / _media.category
elif _media:
# 未开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = settings.SAVE_MOVIE_PATH
else:
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = settings.SAVE_ANIME_PATH
else:
# 电视剧
download_dir = settings.SAVE_TV_PATH
else:
# 未识别
download_dir = settings.SAVE_PATH
if save_path:
# 有自定义下载目录时,尝试匹配目录配置
dir_info = self.directoryhelper.get_download_dir(_media, to_path=Path(save_path))
else:
# 根据媒体信息查询下载目录配置
dir_info = self.directoryhelper.get_download_dir(_media)
# 拼装子目录
if dir_info:
# 一级目录
if not dir_info.media_type and dir_info.auto_category:
# 一级自动分类
download_dir = Path(dir_info.path) / _media.type.value
else:
# 一级不分类
download_dir = Path(dir_info.path)
# 二级目录
if not dir_info.category and dir_info.auto_category and _media and _media.category:
# 二级自动分类
download_dir = download_dir / _media.category
elif save_path:
# 自定义下载目录
download_dir = Path(save_path)
else:
# 未找到下载目录,且没有自定义下载目录
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
title="下载失败", role="system")
return None
# 添加下载
result: Optional[tuple] = self.download(content=content,
@@ -284,7 +300,7 @@ class DownloadChain(ChainBase):
tvdbid=_media.tvdb_id,
doubanid=_media.douban_id,
seasons=_meta.season,
episodes=_meta.episode,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
download_hash=_hash,
torrent_name=_torrent.title,
@@ -321,7 +337,8 @@ class DownloadChain(ChainBase):
self.downloadhis.add_files(files_to_add)
# 发送消息群发不带channel和userid
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, username=username)
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
username=username, download_episodes=download_episodes)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件
@@ -429,12 +446,15 @@ class DownloadChain(ChainBase):
# 如果是电影,直接下载
for context in contexts:
if context.media_info.type == MediaType.MOVIE:
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
if self.download_single(context, save_path=save_path, channel=channel,
userid=userid, username=username):
# 下载成功
logger.info(f"{context.torrent_info.title} 添加下载成功")
downloaded_list.append(context)
# 电视剧整季匹配
logger.info(f"开始匹配电视剧整季:{no_exists}")
if no_exists:
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
need_seasons: Dict[int, list] = {}
@@ -447,6 +467,7 @@ class DownloadChain(ChainBase):
if not need_seasons.get(need_mid):
need_seasons[need_mid] = []
need_seasons[need_mid].append(tv.season or 1)
logger.info(f"缺失整季:{need_seasons}")
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
for need_mid, need_season in need_seasons.items():
# 循环种子
@@ -462,23 +483,31 @@ class DownloadChain(ChainBase):
continue
# 种子的季清单
torrent_season = meta.season_list
# 没有季的默认为第1季
if not torrent_season:
torrent_season = [1]
# 种子有集的不要
if meta.episode_list:
continue
# 匹配TMDBID
if need_mid == media.tmdb_id or need_mid == media.douban_id:
# 不重复添加
if context in downloaded_list:
continue
# 种子季是需要季或者子集
if set(torrent_season).issubset(set(need_season)):
if len(torrent_season) == 1:
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
logger.info(f"开始下载种子 {torrent.title} ...")
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.warn(f"{torrent.title} 种子下载失败!")
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
continue
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
if not torrent_episodes:
continue
# 更新集数范围
@@ -489,10 +518,11 @@ class DownloadChain(ChainBase):
need_total = __get_season_episodes(need_mid, torrent_season[0])
if len(torrent_episodes) < need_total:
logger.info(
f"{meta.org_string} 解析文件集数发现不是完整合集")
f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子")
continue
else:
# 下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_file=content if isinstance(content, Path) else None,
@@ -503,21 +533,25 @@ class DownloadChain(ChainBase):
)
else:
# 下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
logger.info(f"{torrent.title} 添加下载成功")
downloaded_list.append(context)
# 更新仍需季集
need_season = __update_seasons(_mid=need_mid,
_need=need_season,
_current=torrent_season)
logger.info(f"{need_mid} 剩余需要季:{need_season}")
if not need_season:
# 全部下载完成
break
# 电视剧季内的集匹配
logger.info(f"开始电视剧完整集匹配:{no_exists}")
if no_exists:
# TMDBID列表
need_tv_list = list(no_exists)
@@ -567,19 +601,23 @@ class DownloadChain(ChainBase):
# 为需要集的子集则下载
if torrent_episodes.issubset(set(need_episodes)):
# 下载
logger.info(f"开始下载 {meta.title} ...")
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
logger.info(f"{meta.title} 添加下载成功")
downloaded_list.append(context)
# 更新仍需集数
need_episodes = __update_episodes(_mid=need_mid,
_need=need_episodes,
_sea=need_season,
_current=torrent_episodes)
logger.info(f"{need_season} 剩余需要集:{need_episodes}")
# 仍然缺失的剧集从整季中选择需要的集数文件下载仅支持QB和TR
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
if no_exists:
# TMDBID列表
no_exists_list = list(no_exists)
@@ -625,15 +663,17 @@ class DownloadChain(ChainBase):
and len(meta.season_list) == 1 \
and meta.season_list[0] == need_season:
# 检查种子看是否有需要的集
logger.info(f"开始下载种子 {torrent.title} ...")
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.info(f"{torrent.title} 种子下载失败!")
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
continue
# 种子全部集
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
logger.info(f"{torrent.site_name} - {meta.org_string} 解析文件集数:{torrent_episodes}")
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
# 选中的集
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
if not selected_episodes:
@@ -641,6 +681,7 @@ class DownloadChain(ChainBase):
continue
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
# 添加下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_file=content if isinstance(content, Path) else None,
@@ -653,6 +694,7 @@ class DownloadChain(ChainBase):
if not download_id:
continue
# 下载成功
logger.info(f"{torrent.title} 添加下载成功")
downloaded_list.append(context)
# 更新种子集数范围
begin_ep = min(torrent_episodes)
@@ -663,8 +705,10 @@ class DownloadChain(ChainBase):
_need=need_episodes,
_sea=need_season,
_current=selected_episodes)
logger.info(f"{need_season} 剩余需要集:{need_episodes}")
# 返回下载的资源,剩下没下完的
logger.info(f"成功下载种子数:{len(downloaded_list)},剩余未下载的剧集:{no_exists}")
return downloaded_list, no_exists
def get_no_exists_info(self, meta: MetaBase,
@@ -807,7 +851,9 @@ class DownloadChain(ChainBase):
channel=channel,
mtype=NotificationType.Download,
title="没有正在下载的任务!",
userid=userid))
userid=userid,
link=settings.MP_DOMAIN('#/downloading')
))
return
# 发送消息
title = f"{len(torrents)} 个任务正在下载:"
@@ -819,8 +865,13 @@ class DownloadChain(ChainBase):
f"{round(torrent.progress, 1)}%")
index += 1
self.post_message(Notification(
channel=channel, mtype=NotificationType.Download,
title=title, text="\n".join(messages), userid=userid))
channel=channel,
mtype=NotificationType.Download,
title=title,
text="\n".join(messages),
userid=userid,
link=settings.MP_DOMAIN('#/downloading')
))
def downloading(self) -> List[DownloadingTorrent]:
"""
@@ -875,4 +926,14 @@ class DownloadChain(ChainBase):
if not hash_str:
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 先查询种子
torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str])
if torrents:
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 发出下载任务删除事件,如需处理辅种,可监听该事件
self.eventmanager.send_event(EventType.DownloadDeleted, {
"hash": hash_str,
"torrents": [torrent.dict() for torrent in torrents]
})
else:
logger.info(f"没有在下载器中查询到 {hash_str} 对应的下载任务")

View File

@@ -2,17 +2,23 @@ import copy
import time
from pathlib import Path
from threading import Lock
from typing import Optional, List, Tuple
from typing import Optional, List, Tuple, Union
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import Context, MediaInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.helper.aliyun import AliyunHelper
from app.helper.u115 import U115Helper
from app.log import logger
from app.schemas.types import EventType, MediaType
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
recognize_lock = Lock()
@@ -26,6 +32,17 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 临时识别结果 {title, name, year, season, episode}
recognize_temp: Optional[dict] = None
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
season: int = None, episode: int = None) -> Optional[str]:
"""
获取NFO文件内容文本
:param meta: 元数据
:param mediainfo: 媒体信息
:param season: 季号
:param episode: 集号
"""
return self.run_module("metadata_nfo", meta=meta, mediainfo=mediainfo, season=season, episode=episode)
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
@@ -34,7 +51,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
if not mediainfo:
# 试使用辅助识别,如果有注册响应事件的话
# 试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{title} ...')
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
@@ -143,7 +160,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 识别媒体信息
mediainfo = self.recognize_media(meta=file_meta)
if not mediainfo:
# 试使用辅助识别,如果有注册响应事件的话
# 试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
@@ -220,6 +237,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
season=meta.begin_season
)
if tmdbinfo:
# 合季季后返回
tmdbinfo['season'] = meta.begin_season
break
return tmdbinfo
@@ -313,3 +332,189 @@ class MediaChain(ChainBase, metaclass=Singleton):
season=meta.begin_season
)
return None
def manual_scrape(self, storage: str, fileitem: schemas.FileItem,
meta: MetaBase = None, mediainfo: MediaInfo = None, init_folder: bool = True):
"""
手动刮削媒体信息
"""
def __list_files(_storage: str, _fileid: str, _path: str = None, _drive_id: str = None):
"""
列出下级文件
"""
if _storage == "aliyun":
return AliyunHelper().list(drive_id=_drive_id, parent_file_id=_fileid, path=_path)
elif _storage == "u115":
return U115Helper().list(parent_file_id=_fileid, path=_path)
else:
items = SystemUtils.list_sub_all(Path(_path))
return [schemas.FileItem(
type="file" if item.is_file() else "dir",
path=str(item),
name=item.name,
basename=item.stem,
extension=item.suffix[1:],
size=item.stat().st_size,
modify_time=item.stat().st_mtime
) for item in items]
def __save_file(_storage: str, _drive_id: str, _fileid: str, _path: Path, _content: Union[bytes, str]):
"""
保存或上传文件
"""
if _storage != "local":
# 写入到临时目录
temp_path = settings.TEMP_PATH / _path.name
temp_path.write_bytes(_content)
# 上传文件
logger.info(f"正在上传 {_path.name} ...")
if _storage == "aliyun":
AliyunHelper().upload(drive_id=_drive_id, parent_file_id=_fileid, file_path=temp_path)
elif _storage == "u115":
U115Helper().upload(parent_file_id=_fileid, file_path=temp_path)
logger.info(f"{_path.name} 上传完成")
else:
# 保存到本地
logger.info(f"正在保存 {_path.name} ...")
_path.write_bytes(_content)
logger.info(f"{_path} 已保存")
def __save_image(_url: str) -> Optional[bytes]:
"""
下载图片并保存
"""
try:
logger.info(f"正在下载图片:{_url} ...")
r = RequestUtils(proxies=settings.PROXY).get_res(url=_url)
if r:
return r.content
else:
logger.info(f"{_url} 图片下载失败,请检查网络连通性!")
except Exception as err:
logger.error(f"{_url} 图片下载失败:{str(err)}")
# 当前文件路径
filepath = Path(fileitem.path)
if fileitem.type == "file" \
and (not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT):
return
if not meta:
meta = MetaInfoPath(filepath)
if not mediainfo:
mediainfo = self.recognize_by_meta(meta)
if not mediainfo:
logger.warn(f"{filepath} 无法识别文件媒体信息!")
return
logger.info(f"开始刮削:{filepath} ...")
if mediainfo.type == MediaType.MOVIE:
# 电影
if fileitem.type == "file":
# 电影文件
logger.info(f"正在生成电影nfo{mediainfo.title_year} - {filepath.name}")
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if not movie_nfo:
logger.warn(f"{filepath.name} nfo文件生成失败")
return
# 保存或上传nfo文件
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.parent_fileid,
_path=filepath.with_suffix(".nfo"), _content=movie_nfo)
else:
# 电影目录
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
_drive_id=fileitem.drive_id, _path=fileitem.path)
for file in files:
self.manual_scrape(storage=storage, fileitem=file,
meta=meta, mediainfo=mediainfo,
init_folder=False)
# 生成目录内图片文件
if init_folder:
# 图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.endswith("_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = filepath / image_name
# 下载图片
content = __save_image(_url=attr_value)
# 写入nfo到根目录
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
_path=image_path, _content=content)
else:
# 电视剧
if fileitem.type == "file":
# 当前为集文件,重新识别季集
file_meta = MetaInfoPath(filepath)
if not file_meta.begin_episode:
logger.warn(f"{filepath.name} 无法识别文件集数!")
return
file_mediainfo = self.recognize_media(meta=file_meta)
if not file_mediainfo:
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
return
# 获取集的nfo文件
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
season=file_meta.begin_season, episode=file_meta.begin_episode)
if not episode_nfo:
logger.warn(f"{filepath.name} nfo生成失败")
return
# 保存或上传nfo文件
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.parent_fileid,
_path=filepath.with_suffix(".nfo"), _content=episode_nfo)
else:
# 当前为目录,处理目录内的文件
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
_drive_id=fileitem.drive_id, _path=fileitem.path)
for file in files:
self.manual_scrape(storage=storage, fileitem=file,
meta=meta, mediainfo=mediainfo,
init_folder=True if file.type == "dir" else False)
# 生成目录的nfo和图片
if init_folder:
# 识别文件夹名称
season_meta = MetaInfo(filepath.name)
if season_meta.begin_season:
# 当前目录有季号生成季nfo
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=meta.begin_season)
if not season_nfo:
logger.warn(f"无法生成电视剧季nfo文件{meta.name}")
return
# 写入nfo到根目录
nfo_path = filepath / "season.nfo"
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
_path=nfo_path, _content=season_nfo)
# TMDB季poster图片
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
if image_dict:
for image_name, image_url in image_dict.items():
image_path = filepath.with_name(image_name)
# 下载图片
content = __save_image(image_url)
# 保存图片文件到当前目录
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
_path=image_path, _content=content)
if season_meta.name:
# 当前目录有名称生成tvshow nfo 和 tv图片
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
if not tv_nfo:
logger.warn(f"无法生成电视剧nfo文件{meta.name}")
return
# 写入tvshow nfo到根目录
nfo_path = filepath / "tvshow.nfo"
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
_path=nfo_path, _content=tv_nfo)
# 生成目录图片
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
for image_name, image_url in image_dict.items():
image_path = filepath.parent.with_name(image_name)
# 下载图片
content = __save_image(image_url)
# 保存图片文件到当前目录
__save_file(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid,
_path=image_path, _content=content)
logger.info(f"{filepath.name} 刮削完成")

View File

@@ -80,6 +80,8 @@ class MediaServerChain(ChainBase):
self.dboper.empty()
# 遍历媒体服务器
for mediaserver in mediaservers:
if not mediaserver:
continue
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
for library in self.librarys(mediaserver):
# 同步黑名单 跳过

View File

@@ -520,5 +520,6 @@ class MessageChain(ChainBase):
self.post_torrents_message(Notification(
channel=channel,
title=title,
userid=userid
userid=userid,
link=settings.MP_DOMAIN('#/resource')
), torrents=items)

View File

@@ -53,12 +53,12 @@ class SearchChain(ChainBase):
}
}
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
# 保存结果
# 保存结果
bytes_results = pickle.dumps(results)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return results
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[TorrentInfo]:
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[Context]:
"""
根据标题搜索资源,不识别不过滤,直接返回站点内容
:param title: 标题,为空时返回所有站点首页内容
@@ -70,7 +70,17 @@ class SearchChain(ChainBase):
else:
logger.info(f'开始浏览资源,站点:{site} ...')
# 搜索
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
torrents = self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
torrent_info=torrent) for torrent in torrents]
# 保存结果
bytes_results = pickle.dumps(contexts)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return contexts
def last_search_results(self) -> List[Context]:
"""
@@ -102,12 +112,23 @@ class SearchChain(ChainBase):
:param filter_rule: 过滤规则,为空是使用默认过滤规则
:param area: 搜索范围title or imdbid
"""
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
执行优先级过滤
"""
return self.filter_torrents(rule_string=priority_rule,
torrent_list=torrent_list,
season_episodes=season_episodes,
mediainfo=mediainfo) or []
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
@@ -116,17 +137,19 @@ class SearchChain(ChainBase):
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
# 缺失的季集
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if no_exists and no_exists.get(mediakey):
# 过滤剧集
season_episodes = {sea: info.episodes
for sea, info in no_exists[mediainfo.tmdb_id].items()}
for sea, info in no_exists[mediakey].items()}
elif mediainfo.season:
# 豆瓣只搜索当前季
season_episodes = {mediainfo.season: []}
else:
season_episodes = None
# 搜索关键词
if keyword:
keywords = [keyword]
@@ -147,9 +170,11 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return []
# 开始新进度
self.progress.start(ProgressKey.Search)
# 匹配的资源
# 开始匹配
_match_torrents = []
# 总数
_total = len(torrents)
@@ -177,17 +202,6 @@ class SearchChain(ChainBase):
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != torrent_meta.org_string:
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(f'{mediainfo.title} 通过词表指定TMDBID匹配到资源{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
continue
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
logger.info(f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
continue
# 比对种子
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
@@ -202,25 +216,12 @@ class SearchChain(ChainBase):
key=ProgressKey.Search)
else:
_match_torrents = torrents
# 开始过滤
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
key=ProgressKey.Search)
# 过滤种子
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=_match_torrents,
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
_match_torrents = result
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用过滤规则再次过滤
# 开始过滤规则过滤
if _match_torrents:
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
@@ -229,22 +230,43 @@ class SearchChain(ChainBase):
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
logger.info(f"过滤规则过滤完成,剩余 {len(_match_torrents)} 个资源")
# 开始优先级规则/剧集过滤
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
_match_torrents = __do_filter(_match_torrents)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
logger.info(f"优先级规则/剧集过滤完成,剩余 {len(_match_torrents)} 个资源")
# 去掉mediainfo中多余的数据
mediainfo.clear()
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
media_info=mediainfo,
torrent_info=torrent) for torrent in _match_torrents]
logger.info(f"过滤完成,剩余 {len(contexts)} 个资源")
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
# 排序
self.progress.update(value=100,
self.progress.update(value=99,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
key=ProgressKey.Search)
contexts = self.torrenthelper.sort_torrents(contexts)
# 结束进度
self.progress.update(value=100,
text=f'搜索完成,共 {len(contexts)} 个资源',
key=ProgressKey.Search)
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
self.progress.end(ProgressKey.Search)
# 返回
return contexts
@@ -294,34 +316,34 @@ class SearchChain(ChainBase):
self.progress.update(value=0,
text=f"开始搜索,共 {total_num} 个站点 ...",
key=ProgressKey.Search)
# 多线程
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
all_task = []
for site in indexer_sites:
if area == "imdbid":
# 搜索IMDBID
task = executor.submit(self.search_torrents, site=site,
keywords=[mediainfo.imdb_id] if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
# 搜索标题
task = executor.submit(self.search_torrents, site=site,
keywords=keywords,
mtype=mediainfo.type if mediainfo else None,
page=page)
all_task.append(task)
# 结果集
results = []
for future in as_completed(all_task):
finish_count += 1
result = future.result()
if result:
results.extend(result)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
self.progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
key=ProgressKey.Search)
# 多线程
with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:
all_task = []
for site in indexer_sites:
if area == "imdbid":
# 搜索IMDBID
task = executor.submit(self.search_torrents, site=site,
keywords=[mediainfo.imdb_id] if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
# 搜索标题
task = executor.submit(self.search_torrents, site=site,
keywords=keywords,
mtype=mediainfo.type if mediainfo else None,
page=page)
all_task.append(task)
for future in as_completed(all_task):
finish_count += 1
result = future.result()
if result:
results.extend(result)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
self.progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
key=ProgressKey.Search)
# 计算耗时
end_time = datetime.now()
# 更新进度

View File

@@ -52,7 +52,10 @@ class SiteChain(ChainBase):
"zhuque.in": self.__zhuque_test,
"m-team.io": self.__mteam_test,
"m-team.cc": self.__mteam_test,
"ptlsp.com": self.__ptlsp_test,
"ptlsp.com": self.__indexphp_test,
"1ptba.com": self.__indexphp_test,
"star-space.net": self.__indexphp_test,
"yemapt.org": self.__yema_test,
}
def is_special_site(self, domain: str) -> bool:
@@ -73,7 +76,7 @@ class SiteChain(ChainBase):
ua=user_agent,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 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)
@@ -90,7 +93,7 @@ class SiteChain(ChainBase):
},
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 15
).get_res(url=f"{site.url}api/user/getInfo")
if user_res and user_res.status_code == 200:
user_info = user_res.json()
@@ -104,36 +107,55 @@ class SiteChain(ChainBase):
判断站点是否已经登陆m-team
"""
user_agent = site.ua or settings.USER_AGENT
url = f"{site.url}api/member/profile"
domain = StringUtils.get_url_domain(site.url)
url = f"https://api.{domain}/api/member/profile"
headers = {
"Content-Type": "application/json",
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"Authorization": site.token
}
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"x-api-key": site.apikey,
}
res = RequestUtils(
headers=headers,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 15
).post_res(url=url)
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
user_info = res.json() or {}
if user_info.get("data"):
return True, "连接成功"
return False, user_info.get("message", "鉴权已过期或无效")
else:
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __yema_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆yemapt
"""
user_agent = site.ua or settings.USER_AGENT
url = f"{site.url}api/consumer/fetchSelfDetail"
headers = {
"User-Agent": user_agent,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
}
res = RequestUtils(
headers=headers,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=url)
if res and res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("data"):
# 更新最后访问时间
res = RequestUtils(headers=headers,
timeout=60,
proxies=settings.PROXY if site.proxy else None,
referer=f"{site.url}index"
).post_res(url=urljoin(url, "api/member/updateLastBrowse"))
if res:
return True, "连接成功"
else:
return True, f"连接成功,但更新状态失败"
return False, "鉴权已过期或无效"
if user_info and user_info.get("success"):
return True, "连接成功"
return False, "Cookie已过期"
def __ptlsp_test(self, site: Site) -> Tuple[bool, str]:
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆ptlsp
判断站点是否已经登陆ptlsp/1ptba
"""
site.url = f"{site.url}index.php"
return self.__test(site)
@@ -148,7 +170,7 @@ class SiteChain(ChainBase):
:return:
"""
favicon_url = urljoin(url, "favicon.ico")
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url)
if res:
html_text = res.text
else:
@@ -160,7 +182,7 @@ class SiteChain(ChainBase):
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
@@ -188,7 +210,7 @@ class SiteChain(ChainBase):
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.message.put(f"CookieCloud同步失败 {msg}")
self.message.put(msg, title="CookieCloud同步失败", role="system")
return False, msg
# 保存Cookie或新增站点
_update_count = 0
@@ -199,7 +221,7 @@ class SiteChain(ChainBase):
indexer = self.siteshelper.get_indexer(domain)
# 数据库的站点信息
site_info = self.siteoper.get_by_domain(domain)
if site_info:
if site_info and site_info.is_active == 1:
# 站点已存在,检查站点连通性
status, msg = self.test(domain)
# 更新站点Cookie
@@ -225,6 +247,11 @@ class SiteChain(ChainBase):
self.siteoper.update_cookie(domain=domain, cookies=cookie)
_update_count += 1
elif indexer:
if settings.COOKIECLOUD_BLACKLIST and any(
StringUtils.get_url_domain(domain) == StringUtils.get_url_domain(black_domain) for black_domain
in str(settings.COOKIECLOUD_BLACKLIST).split(",")):
logger.warn(f"站点 {domain} 已在黑名单中,不添加站点")
continue
# 新增站点
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
res = RequestUtils(cookies=cookie,
@@ -276,7 +303,7 @@ class SiteChain(ChainBase):
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.message.put(f"CookieCloud同步成功, {ret_msg}")
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@@ -430,7 +457,8 @@ class SiteChain(ChainBase):
self.post_message(Notification(
channel=channel,
title="没有维护任何站点信息!",
userid=userid))
userid=userid,
link=settings.MP_DOMAIN('#/site')))
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
f"\n- 禁用站点:/site_disable [id]" \
f"\n- 启用站点:/site_enable [id]" \
@@ -448,7 +476,8 @@ class SiteChain(ChainBase):
# 发送列表
self.post_message(Notification(
channel=channel,
title=title, text="\n".join(messages), userid=userid))
title=title, text="\n".join(messages), userid=userid,
link=settings.MP_DOMAIN('#/site')))
def remote_disable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
"""

View File

@@ -2,6 +2,7 @@ import json
import random
import time
from datetime import datetime
from json import JSONDecodeError
from typing import Dict, List, Optional, Union, Tuple
from app.chain import ChainBase
@@ -15,6 +16,7 @@ from app.core.event import eventmanager, Event, EventManager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.models.subscribe import Subscribe
from app.db.site_oper import SiteOper
from app.db.subscribe_oper import SubscribeOper
from app.db.subscribehistory_oper import SubscribeHistoryOper
from app.db.systemconfig_oper import SystemConfigOper
@@ -43,6 +45,7 @@ class SubscribeChain(ChainBase):
self.message = MessageHelper()
self.systemconfig = SystemConfigOper()
self.torrenthelper = TorrentHelper()
self.siteoper = SiteOper()
def add(self, title: str, year: str,
mtype: MediaType = None,
@@ -136,15 +139,24 @@ class SubscribeChain(ChainBase):
mediainfo.bangumi_id = bangumiid
# 添加订阅
kwargs.update({
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality"),
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution"),
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect"),
'include': self.__get_default_subscribe_config(mediainfo.type, "include"),
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude"),
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version"),
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid"),
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None,
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path"),
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality") if not kwargs.get(
"quality") else kwargs.get("quality"),
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution") if not kwargs.get(
"resolution") else kwargs.get("resolution"),
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect") if not kwargs.get(
"effect") else kwargs.get("effect"),
'include': self.__get_default_subscribe_config(mediainfo.type, "include") if not kwargs.get(
"include") else kwargs.get("include"),
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude") if not kwargs.get(
"exclude") else kwargs.get("exclude"),
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version") if not kwargs.get(
"best_version") else kwargs.get("best_version"),
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid") if not kwargs.get(
"search_imdbid") else kwargs.get("search_imdbid"),
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None if not kwargs.get(
"sites") else kwargs.get("sites"),
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
"save_path") else kwargs.get("save_path")
})
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
if not sid:
@@ -166,10 +178,15 @@ class SubscribeChain(ChainBase):
else:
text = f"评分:{mediainfo.vote_average}"
# 群发
if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
text=text,
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=link))
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
@@ -240,7 +257,11 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -289,20 +310,16 @@ class SubscribeChain(ChainBase):
# 电视剧订阅处理缺失集
if meta.type == MediaType.TV:
# 实际缺失集与订阅开始结束集范围进行整合
# 实际缺失集与订阅开始结束集范围进行整合,同时剔除已下载的集数
no_exists = self.__get_subscribe_no_exits(
subscribe_name=f'{subscribe.name} {meta.season}',
no_exists=no_exists,
mediakey=mediakey,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
downloaded_episodes=self.__get_downloaded_episodes(subscribe)
)
# 打印汇总缺失集信息
if no_exists and no_exists.get(mediakey):
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 站点范围
sites = self.get_sub_sites(subscribe)
@@ -336,23 +353,17 @@ class SubscribeChain(ChainBase):
torrent_meta = context.meta_info
torrent_info = context.torrent_info
torrent_mediainfo = context.media_info
# 洗版
if not subscribe.best_version:
# 如果是电视剧过滤掉已经下载的集数
if torrent_mediainfo.type == MediaType.TV:
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 已下载过')
continue
else:
# 洗版
if subscribe.best_version:
# 洗版时,非整季不要
if torrent_mediainfo.type == MediaType.TV:
if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 洗版时,优先级小于已下载优先级的不要
# 洗版时,优先级小于等于已下载优先级的不要
if subscribe.current_priority \
and torrent_info.pri_order < subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
and torrent_info.pri_order <= subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
matched_contexts.append(context)
@@ -378,9 +389,9 @@ class SubscribeChain(ChainBase):
# 手动触发时发送系统消息
if manual:
if sid:
self.message.put(f'订阅 {subscribes[0].name} 搜索完成!')
self.message.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
else:
self.message.put('所有订阅搜索完成!')
self.message.put('所有订阅搜索完成!', title="订阅搜索", role="system")
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
mediainfo: MediaInfo, downloads: List[Context]):
@@ -457,11 +468,29 @@ class SubscribeChain(ChainBase):
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
"""
获取订阅中涉及的站点清单
:param subscribe: 订阅信息对象
:return: 涉及的站点清单
"""
if subscribe.sites:
return json.loads(subscribe.sites)
# 默认站点
return self.systemconfig.get(SystemConfigKey.RssSites) or []
# 从系统配置获取默认订阅站点
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
# 如果订阅未指定站点信息,直接返回默认站点
if not subscribe.sites:
return default_sites
try:
# 尝试解析订阅中的站点数据
user_sites = json.loads(subscribe.sites)
# 计算 user_sites 和 default_sites 的交集
intersection_sites = [site for site in user_sites if site in default_sites]
# 如果交集与原始订阅不一致,更新数据库
if set(intersection_sites) != set(user_sites):
self.subscribeoper.update(subscribe.id, {
"sites": json.dumps(intersection_sites)
})
# 如果交集为空,返回默认站点
return intersection_sites if intersection_sites else default_sites
except JSONDecodeError:
# 如果 JSON 解析失败,返回默认站点
return default_sites
def get_subscribed_sites(self) -> Optional[List[int]]:
"""
@@ -498,6 +527,7 @@ class SubscribeChain(ChainBase):
"tv_size": default_rule.get("tv_size"),
"movie_size": default_rule.get("movie_size"),
"min_seeders": default_rule.get("min_seeders"),
"min_seeders_time": default_rule.get("min_seeders_time"),
}
def match(self, torrents: Dict[str, List[Context]]):
@@ -507,6 +537,8 @@ class SubscribeChain(ChainBase):
if not torrents:
logger.warn('没有缓存资源,无法匹配订阅')
return
# 记录重新识别过的种子
_recognize_cached = []
# 所有订阅
subscribes = self.subscribeoper.list('R')
# 遍历订阅
@@ -517,7 +549,20 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 订阅的站点域名列表
domains = []
if subscribe.sites:
try:
siteids = json.loads(subscribe.sites)
if siteids:
domains = self.siteoper.get_domains_by_ids(siteids)
except JSONDecodeError:
pass
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -565,20 +610,16 @@ class SubscribeChain(ChainBase):
# 电视剧订阅
if meta.type == MediaType.TV:
# 整合实际缺失集与订阅开始集结束集
# 整合实际缺失集与订阅开始集结束集,同时剔除已下载的集数
no_exists = self.__get_subscribe_no_exits(
subscribe_name=f'{subscribe.name} {meta.season}',
no_exists=no_exists,
mediakey=mediakey,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
downloaded_episodes=self.__get_downloaded_episodes(subscribe)
)
# 打印汇总缺失集信息
if no_exists and no_exists.get(mediakey):
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 过滤规则
filter_rule = self.get_filter_rule(subscribe)
@@ -586,15 +627,41 @@ class SubscribeChain(ChainBase):
# 遍历缓存种子
_match_context = []
for domain, contexts in torrents.items():
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
if domains and domain not in domains:
continue
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
for context in contexts:
# 检查是否匹配
torrent_meta = context.meta_info
torrent_mediainfo = context.media_info
torrent_info = context.torrent_info
# 如果识别了媒体信息则比对TMDBID和类型
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
# 直接比对媒体信息
# 先判断是否有没识别的种子
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
_cache_key = f"{torrent_info.title}_{torrent_info.description}"
if _cache_key not in _recognize_cached:
_recognize_cached.append(_cache_key)
logger.info(
f'{torrent_info.site_name} - {torrent_info.title} 订阅缓存为未识别状态,尝试重新识别...')
# 重新识别(不使用缓存)
torrent_mediainfo = self.recognize_media(meta=torrent_meta, cache=False)
if not torrent_mediainfo:
logger.warn(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
# 更新缓存
torrent_mediainfo = mediainfo
context.media_info = mediainfo
else:
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
@@ -606,22 +673,8 @@ class SubscribeChain(ChainBase):
logger.info(
f'{mediainfo.title_year} 通过媒体信ID匹配到资源{torrent_info.site_name} - {torrent_info.title}')
else:
# 没有torrent_mediainfo媒体信息按标题匹配
manual_match = False
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid != mediainfo.tmdb_id:
continue
if torrent_meta.doubanid and torrent_meta.doubanid != mediainfo.douban_id:
continue
manual_match = True
if not manual_match:
# 没有指定tmdbid按标题匹配
if not self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info,
logerror=False):
continue
continue
# 优先级过滤规则
if subscribe.best_version:
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
@@ -633,28 +686,28 @@ class SubscribeChain(ChainBase):
mediainfo=torrent_mediainfo)
if result is not None and not result:
# 不符合过滤规则
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
logger.debug(f"{torrent_info.title} 不匹配当前过滤规则")
continue
# 不在订阅站点范围的不处理
sub_sites = self.get_sub_sites(subscribe)
if sub_sites and torrent_info.site not in sub_sites:
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
continue
# 如果是电视剧
if torrent_mediainfo.type == MediaType.TV:
# 有多季的不要
if len(torrent_meta.season_list) > 1:
logger.info(f'{torrent_info.title} 有多季,不处理')
logger.debug(f'{torrent_info.title} 有多季,不处理')
continue
# 比对季
if torrent_meta.begin_season:
if meta.begin_season != torrent_meta.begin_season:
logger.info(f'{torrent_info.title} 季不匹配')
logger.debug(f'{torrent_info.title} 季不匹配')
continue
elif meta.begin_season != 1:
logger.info(f'{torrent_info.title} 季不匹配')
logger.debug(f'{torrent_info.title} 季不匹配')
continue
# 非洗版
if not subscribe.best_version:
@@ -669,19 +722,15 @@ class SubscribeChain(ChainBase):
not set(no_exists_info.episodes).intersection(
set(torrent_meta.episode_list)
):
logger.info(
logger.debug(
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
)
continue
# 过滤掉已经下载的集数
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
logger.info(f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 已下载过')
continue
else:
# 洗版时,非整季不要
if meta.type == MediaType.TV:
if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 过滤规则
@@ -693,8 +742,8 @@ class SubscribeChain(ChainBase):
# 洗版时,优先级小于已下载优先级的不要
if subscribe.best_version:
if subscribe.current_priority \
and torrent_info.pri_order < subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
and torrent_info.pri_order <= subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
# 匹配成功
@@ -735,7 +784,11 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -779,7 +832,10 @@ class SubscribeChain(ChainBase):
return
note = []
if subscribe.note:
note = json.loads(subscribe.note)
try:
note = json.loads(subscribe.note)
except JSONDecodeError:
note = []
for context in downloads:
meta = context.meta_info
mediainfo = context.media_info
@@ -802,18 +858,21 @@ class SubscribeChain(ChainBase):
})
@staticmethod
def __check_subscribe_note(subscribe: Subscribe, episodes: List[int]) -> bool:
def __get_downloaded_episodes(subscribe: Subscribe) -> List[int]:
"""
检查当前集是否已下载过
获取已下载过的集数
"""
if not subscribe.note:
return False
if not episodes:
return False
note = json.loads(subscribe.note)
if set(episodes).issubset(set(note)):
return True
return False
return []
if subscribe.type != MediaType.TV.value:
return []
try:
episodes = json.loads(subscribe.note)
logger.info(f'订阅 {subscribe.name}{subscribe.season}季 已下载集数:{episodes}')
return episodes
except JSONDecodeError:
logger.warn(f'订阅 {subscribe.name} note字段解析失败')
return []
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
subscribe: Subscribe,
@@ -862,15 +921,25 @@ class SubscribeChain(ChainBase):
# 删除订阅
self.subscribeoper.delete(subscribe.id)
# 发送通知
if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=link))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"subscribe_info": subscribe.to_dict(),
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
self.subscribehelper.sub_done_async({
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id
})
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
@@ -920,29 +989,40 @@ class SubscribeChain(ChainBase):
return
# 删除订阅
self.subscribeoper.delete(subscribe_id)
# 统计订阅
self.subscribehelper.sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
# 重新发送消息
self.remote_list(channel, userid)
@staticmethod
def __get_subscribe_no_exits(no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
def __get_subscribe_no_exits(subscribe_name: str,
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
mediakey: Union[str, int],
begin_season: int,
total_episode: int,
start_episode: int):
start_episode: int,
downloaded_episodes: List[int] = None):
"""
根据订阅开始集数和总集数结合TMDB信息计算当前订阅的缺失集数
:param subscribe_name: 订阅名称
:param no_exists: 缺失季集列表
:param mediakey: TMDB ID或豆瓣ID
:param begin_season: 开始季
:param total_episode: 订阅设定总集数
:param start_episode: 订阅设定开始集数
:param downloaded_episodes: 已下载集数
"""
# 使用订阅的总集数和开始集数替换no_exists
if no_exists \
and no_exists.get(mediakey) \
and (total_episode or start_episode):
if not no_exists or not no_exists.get(mediakey):
return no_exists
no_exists_item = no_exists.get(mediakey)
if total_episode or start_episode:
logger.info(f'订阅 {subscribe_name} 设定的开始集数:{start_episode}、总集数:{total_episode}')
# 该季原缺失信息
no_exist_season = no_exists.get(mediakey).get(begin_season)
no_exist_season = no_exists_item.get(begin_season)
if no_exist_season:
# 原集列表
episode_list = no_exist_season.episodes
@@ -980,6 +1060,41 @@ class SubscribeChain(ChainBase):
total_episode=total_episode,
start_episode=start_episode
)
# 根据订阅已下载集数更新缺失集数
if downloaded_episodes:
logger.info(f'订阅 {subscribe_name} 已下载集数:{downloaded_episodes}')
# 该季原缺失信息
no_exist_season = no_exists_item.get(begin_season)
if no_exist_season:
# 原集列表
episode_list = no_exist_season.episodes
# 原总集数
total = no_exist_season.total_episode
# 原开始集数
start = no_exist_season.start_episode
# 整季缺失
if not episode_list:
episode_list = list(range(start, total + 1))
# 更新剧集列表
episodes = list(set(episode_list).difference(set(downloaded_episodes)))
# 更新集合
no_exists[mediakey][begin_season] = NotExistMediaInfo(
season=begin_season,
episodes=episodes,
total_episode=total,
start_episode=start
)
else:
# 开始集数
start = start_episode or 1
# 不存在的季
no_exists[mediakey][begin_season] = NotExistMediaInfo(
season=begin_season,
episodes=list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes))),
total_episode=total_episode,
start_episode=start
)
logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为:{no_exists}')
return no_exists
@eventmanager.register(EventType.SiteDeleted)
@@ -1012,7 +1127,10 @@ class SubscribeChain(ChainBase):
for subscribe in self.subscribeoper.list():
if not subscribe.sites:
continue
sites = json.loads(subscribe.sites) or []
try:
sites = json.loads(subscribe.sites)
except JSONDecodeError:
sites = []
if site_id not in sites:
continue
sites.remove(site_id)

View File

@@ -96,14 +96,14 @@ class SystemChain(ChainBase, metaclass=Singleton):
获取后端最新版本
"""
try:
with RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest") as version_res:
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
except Exception as err:
logger.error(f"获取后端最新版本失败:{str(err)}")
return None
@@ -114,14 +114,14 @@ class SystemChain(ChainBase, metaclass=Singleton):
获取前端最新版本
"""
try:
with RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest") as version_res:
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
except Exception as err:
logger.error(f"获取前端最新版本失败:{str(err)}")
return None
@@ -153,7 +153,10 @@ class SystemChain(ChainBase, metaclass=Singleton):
"""
获取前端版本
"""
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if SystemUtils.is_frozen() and SystemUtils.is_windows():
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
else:
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, 'r') as f:

View File

@@ -124,5 +124,15 @@ class TmdbChain(ChainBase, metaclass=Singleton):
while True:
info = random.choice(infos)
if info and info.backdrop_path:
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.backdrop_path}"
return info.backdrop_path
return None
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_trending_wallpapers(self, num: int = 10) -> Optional[List[str]]:
"""
获取所有流行壁纸
"""
infos = self.tmdb_trending()
if infos:
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
return None

View File

@@ -99,7 +99,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址')
return []
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False,
timeout=int(site.get("timeout") or 30))
if rss_items is None:
# rss过期尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site)
@@ -153,12 +154,15 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 所有站点索引
indexers = self.siteshelper.get_indexers()
# 需要刷新的站点domain
domains = []
# 遍历站点缓存资源
for indexer in indexers:
# 未开启的站点不刷新
if sites and indexer.get("id") not in sites:
continue
domain = StringUtils.get_url_domain(indexer.get("domain"))
domains.append(domain)
if stype == "spider":
# 刷新首页种子
torrents: List[TorrentInfo] = self.browse(domain=domain)
@@ -219,7 +223,9 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
self.save_cache(torrents_cache, self._rss_file)
# 返回
# 去除不在站点范围内的缓存种子
if sites and torrents_cache:
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
return torrents_cache
def __renew_rss_url(self, domain: str, site: dict):
@@ -248,10 +254,14 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
# 发送消息
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site'))
)
else:
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site')))
except Exception as e:
logger.error(f"站点 {domain} RSS链接自动获取失败{str(e)} - {traceback.format_exc()}")
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site')))

View File

@@ -4,20 +4,24 @@ import threading
from pathlib import Path
from typing import List, Optional, Tuple, Union, Dict
from app import schemas
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 MetaInfoPath
from app.core.metainfo import MetaInfoPath, MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.db.systemconfig_oper import SystemConfigOper
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.aliyun import AliyunHelper
from app.helper.directory import DirectoryHelper
from app.helper.format import FormatParser
from app.helper.progress import ProgressHelper
from app.helper.u115 import U115Helper
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
@@ -41,6 +45,17 @@ class TransferChain(ChainBase):
self.mediachain = MediaChain()
self.tmdbchain = TmdbChain()
self.systemconfig = SystemConfigOper()
self.directoryhelper = DirectoryHelper()
self.all_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
"""
获取重命名后的名称
:param meta: 元数据
:param mediainfo: 媒体信息
:return: 重命名后的名称(含目录)
"""
return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo)
def process(self) -> bool:
"""
@@ -63,18 +78,24 @@ class TransferChain(ChainBase):
downloadhis: DownloadHistory = self.downloadhis.get_by_hash(torrent.hash)
if downloadhis:
# 类型
mtype = MediaType(downloadhis.type)
try:
mtype = MediaType(downloadhis.type)
except ValueError:
mtype = MediaType.TV
# 按TMDBID识别
mediainfo = self.recognize_media(mtype=mtype,
tmdbid=downloadhis.tmdbid,
doubanid=downloadhis.doubanid)
if mediainfo:
# 补充图片
self.obtain_images(mediainfo)
else:
# 非MoviePilot下载的任务按文件识别
mediainfo = None
# 执行转移
self.do_transfer(path=torrent.path, mediainfo=mediainfo,
download_hash=torrent.hash)
self.__do_transfer(storage="local", path=torrent.path,
mediainfo=mediainfo, download_hash=torrent.hash)
# 设置下载任务状态
self.transfer_completed(hashs=torrent.hash, path=torrent.path)
@@ -82,14 +103,20 @@ class TransferChain(ChainBase):
logger.info("下载器文件转移执行完成")
return True
def do_transfer(self, path: Path, meta: MetaBase = None,
mediainfo: MediaInfo = None, download_hash: str = None,
target: Path = None, transfer_type: str = None,
season: int = None, epformat: EpisodeFormat = None,
min_filesize: int = 0, force: bool = False) -> Tuple[bool, str]:
def __do_transfer(self, storage: str, path: Path, drive_id: str = None, fileid: str = None, filetype: str = None,
meta: MetaBase = None, mediainfo: MediaInfo = None,
download_hash: str = None,
target: Path = None, transfer_type: str = None,
season: int = None, epformat: EpisodeFormat = None,
min_filesize: int = 0, scrape: bool = None,
force: bool = False) -> Tuple[bool, str]:
"""
执行一个复杂目录的转移操作
:param storage: 存储器
:param path: 待转移目录或文件
:param drive_id: 网盘ID
:param fileid: 文件ID
:param filetype: 文件类型
:param meta: 元数据
:param mediainfo: 媒体信息
:param download_hash: 下载记录hash
@@ -98,26 +125,88 @@ class TransferChain(ChainBase):
:param season: 季
:param epformat: 剧集格式
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param force: 是否强制转移
返回:成功标识,错误信息
"""
if not transfer_type:
transfer_type = settings.TRANSFER_TYPE
# 获取待转移路径清单
trans_paths = self.__get_trans_paths(path)
if not trans_paths:
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
return False, f"{path.name} 没有找到可转移的媒体文件"
# 有集自定义格式
# 自定义格式
formaterHandler = FormatParser(eformat=epformat.format,
details=epformat.detail,
part=epformat.part,
offset=epformat.offset) if epformat else None
# 整理屏蔽词
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
# 开始进度
self.progress.start(ProgressKey.FileTransfer)
# 本地存储
if storage == "local":
# 本地整理
result = self.__transfer_local(path=path, meta=meta, mediainfo=mediainfo,
formaterHandler=formaterHandler,
transfer_exclude_words=transfer_exclude_words,
min_filesize=min_filesize, transfer_type=transfer_type,
target=target, season=season, scrape=scrape,
download_hash=download_hash, force=force)
else:
# 网盘整理
result = self.__transfer_online(storage=storage,
fileitem=schemas.FileItem(
path=str(path) + ("/" if filetype == "dir" else ""),
type=filetype,
drive_id=drive_id,
fileid=fileid,
name=path.name
),
meta=meta,
mediainfo=mediainfo)
if result and result[0] and scrape:
# 刮削元数据
self.progress.update(value=0,
text=f"正在刮削 {path} ...",
key=ProgressKey.FileTransfer)
self.mediachain.manual_scrape(storage=storage,
fileitem=schemas.FileItem(
path=str(path) + ("/" if filetype == "dir" else ""),
type=filetype,
drive_id=drive_id,
fileid=fileid,
name=path.name
),
meta=meta,
mediainfo=mediainfo)
# 结速进度
self.progress.end(ProgressKey.FileTransfer)
return result
def __transfer_local(self, path: Path, meta: MetaBase = None, mediainfo: MediaInfo = None,
formaterHandler: FormatParser = None, transfer_exclude_words: List[str] = None,
min_filesize: int = 0, transfer_type: str = None, target: Path = None,
season: int = None, scrape: bool = None, download_hash: str = None,
force: bool = False) -> Tuple[bool, str]:
"""
整理一个本地目录
"""
# 汇总错误信息
err_msgs: List[str] = []
# 已处理数量
processed_num = 0
# 失败数量
fail_num = 0
# 跳过数量
skip_num = 0
# 获取待转移路径清单
trans_paths = self.__get_trans_paths(path)
if not trans_paths:
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
return False, f"{path.name} 没有找到可转移的媒体文件"
# 目录所有文件清单
transfer_files = SystemUtils.list_files(directory=path,
extensions=settings.RMT_MEDIAEXT,
@@ -126,23 +215,12 @@ class TransferChain(ChainBase):
# 有集自定义格式,过滤文件
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)
# 整理屏蔽词
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
for trans_path in trans_paths:
# 汇总季集清单
@@ -260,7 +338,8 @@ class TransferChain(ChainBase):
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。",
link=settings.MP_DOMAIN('#/history')
))
# 计数
processed_num += 1
@@ -300,7 +379,8 @@ class TransferChain(ChainBase):
path=file_path,
transfer_type=transfer_type,
target=target,
episodes_info=episodes_info)
episodes_info=episodes_info,
scrape=scrape)
if not transferinfo:
logger.error("文件转移模块运行失败")
return False, "文件转移模块运行失败"
@@ -322,7 +402,8 @@ class TransferChain(ChainBase):
mtype=NotificationType.Manual,
title=f"{file_mediainfo.title_year} {file_meta.season_episode} 入库失败!",
text=f"原因:{transferinfo.message or '未知'}",
image=file_mediainfo.get_message_image()
image=file_mediainfo.get_message_image(),
link=settings.MP_DOMAIN('#/history')
))
# 计数
processed_num += 1
@@ -357,7 +438,7 @@ class TransferChain(ChainBase):
transferinfo=transferinfo
)
# 刮削单个文件
if settings.SCRAP_METADATA:
if transferinfo.need_scrape:
self.scrape_metadata(path=transferinfo.target_path,
mediainfo=file_mediainfo,
transfer_type=transfer_type,
@@ -393,7 +474,6 @@ class TransferChain(ChainBase):
'mediainfo': media,
'transferinfo': transfer_info
})
# 结束进度
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
f"失败 {fail_num} 个,跳过 {skip_num}")
@@ -402,10 +482,218 @@ class TransferChain(ChainBase):
text=f"{path} 转移完成,共 {total_num} 个文件,"
f"失败 {fail_num} 个,跳过 {skip_num}",
key=ProgressKey.FileTransfer)
self.progress.end(ProgressKey.FileTransfer)
return True, "\n".join(err_msgs)
def __transfer_online(self, storage: str, fileitem: schemas.FileItem,
meta: MetaBase, mediainfo: MediaInfo) -> Tuple[bool, str]:
"""
整理一个远程目录
"""
def __list_files(_storage: str, _fileid: str,
_path: str = None, _drive_id: str = None) -> List[schemas.FileItem]:
"""
列出下级文件
"""
if _storage == "aliyun":
return AliyunHelper().list(drive_id=_drive_id, parent_file_id=_fileid, path=_path)
elif _storage == "u115":
return U115Helper().list(parent_file_id=_fileid, path=_path)
return []
def __rename_file(_storage: str, _deive_id: str, _fileid: str, _name: str) -> bool:
"""
重命名文件
"""
if _storage == "aliyun":
return AliyunHelper().rename(drive_id=_deive_id, file_id=_fileid, name=_name)
elif _storage == "u115":
return U115Helper().rename(file_id=_fileid, name=_name)
return False
def __create_folder(_storage: str, _drive_id: str, _parent_fileid: str,
_name: str, _path: str) -> Optional[schemas.FileItem]:
"""
创建目录
"""
if _storage == "aliyun":
return AliyunHelper().create_folder(drive_id=_drive_id, parent_file_id=_parent_fileid,
name=_name, path=_path)
elif _storage == "u115":
return U115Helper().create_folder(parent_file_id=_parent_fileid, name=_name, path=_path)
return None
def __move_file(_storage: str, _drive_id: str, _fileid: str, _target_fileid: str) -> bool:
"""
移动文件
"""
if _storage == "aliyun":
return AliyunHelper().move(drive_id=_drive_id, file_id=_fileid, target_id=_target_fileid)
elif _storage == "u115":
return U115Helper().move(file_id=_fileid, target_id=_target_fileid)
return False
def __remove_dir(_storage: str, _drive_id: str, _fileid: str) -> bool:
"""
删除目录
"""
if _storage == "aliyun":
return AliyunHelper().delete(drive_id=_drive_id, file_id=_fileid)
elif _storage == "u115":
return U115Helper().delete(file_id=_fileid)
return False
logger.info(f"开始整理 {fileitem.path} ...")
self.progress.update(value=0,
text=f"正在整理 {fileitem.path} ...",
key=ProgressKey.FileTransfer)
# 重新识别
if not meta:
# 文件元数据
meta = MetaInfoPath(Path(fileitem.path))
if not mediainfo:
mediainfo = self.mediachain.recognize_by_meta(meta)
if not mediainfo:
logger.warn(f"{fileitem.name} 未识别到媒体信息")
return False, f"{fileitem.name} 未识别到媒体信息"
# 获取完整的路径命名
full_names = self.recommend_name(meta=meta, mediainfo=mediainfo)
if not full_names:
logger.warn(f"{fileitem.path} 未获取到命名")
return False, f"{fileitem.path} 未获取到命名"
if mediainfo.type == MediaType.TV:
# 电视剧
[folder_name, season_name, file_name] = Path(full_names).parts
else:
# 电影
season_name = None
[folder_name, file_name] = Path(full_names).parts
# 如果是单个文件,则直接重命名
if fileitem.type == "file":
# 重命名文件
logger.info(f"正在整理 {fileitem.name} => {file_name} ...")
if not __rename_file(_storage=storage, _deive_id=fileitem.drive_id, _fileid=fileitem.fileid, _name=file_name):
logger.error(f"{fileitem.name} 重命名失败")
return False, f"{fileitem.name} 重命名失败"
logger.info(f"{fileitem.path} 整理完成")
else:
# 目录处理
if mediainfo.type == MediaType.MOVIE:
# 电影目录
# 重命名当前目录
logger.info(f"正在重命名 {fileitem.path} => {folder_name} ...")
if not __rename_file(_storage=storage, _deive_id=fileitem.drive_id,
_fileid=fileitem.fileid, _name=folder_name):
logger.error(f"{fileitem.path} 重命名失败")
return False, f"{fileitem.path} 重命名失败"
logger.info(f"{fileitem.path} 重命名完成")
# 处理所有子文件或目录
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
_drive_id=fileitem.drive_id, _path=fileitem.path)
if not files:
logger.info(f"{fileitem.path} 未找到文件,删除空目录")
if not __remove_dir(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid):
logger.error(f"{fileitem.path} 删除失败")
return False, f"{fileitem.path} 删除失败"
return True, ""
for file in files:
# 过滤不处理的文件
if file.type == "file" and str(file.extension) in ['nfo', 'jpg', 'png']:
continue
# 重新识别文件或目录
file_meta = MetaInfoPath(Path(file.path))
if not file_meta.name:
# 过滤掉无效文件
continue
file_media = self.mediachain.recognize_by_meta(file_meta)
if not file_media:
logger.warn(f"{file.name} 未识别到媒体信息")
continue
# 整理这个文件或目录
self.__transfer_online(storage=storage, fileitem=file, meta=file_meta, mediainfo=file_media)
else:
# 电视剧目录
# 判断当前目录类型
folder_meta = MetaInfo(fileitem.name)
if folder_meta.begin_season and not folder_meta.name:
# 季目录
logger.info(f"正在重命名 {fileitem.path} => {season_name} ...")
if not __rename_file(_storage=storage, _deive_id=fileitem.drive_id,
_fileid=fileitem.fileid, _name=season_name):
logger.error(f"{fileitem.path} 重命名失败")
return False, f"{fileitem.path} 重命名失败"
logger.info(f"{fileitem.path} 重命名完成")
elif folder_meta.name:
# 根目录,重命名当前目录
logger.info(f"正在重命名 {fileitem.path} => {folder_name} ...")
if not __rename_file(_storage=storage, _deive_id=fileitem.drive_id,
_fileid=fileitem.fileid, _name=folder_name):
logger.error(f"{fileitem.path} 重命名失败")
return False, f"{fileitem.path} 重命名失败"
logger.info(f"{fileitem.path} 重命名完成")
# 是否有季
if folder_meta.begin_season:
# 创建季目录
logger.info(f"正在创建目录 {fileitem.path}{season_name} ...")
season_dir = __create_folder(_storage=storage, _drive_id=fileitem.drive_id,
_parent_fileid=fileitem.fileid, _name=season_name,
_path=fileitem.path)
if not season_dir:
logger.error(f"{fileitem.path}/{season_name} 创建失败")
return False, f"{fileitem.path}/{season_name} 创建失败"
logger.info(f"{fileitem.path}/{season_name} 创建完成")
# 移动当前目录下的所有文件到季目录
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
_drive_id=fileitem.drive_id, _path=fileitem.path)
if not files:
logger.error(f"{fileitem.path} 未找到文件,删除空目录")
if not __remove_dir(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid):
logger.error(f"{fileitem.path} 删除失败")
return False, f"{fileitem.path} 删除失败"
logger.info(f"{fileitem.path} 已删除")
return True, ""
for file in files:
if file.type == "dir":
continue
logger.info(f"正在移动 {file.path} => {season_dir.path}...")
if not __move_file(_storage=storage, _drive_id=fileitem.drive_id,
_fileid=file.fileid, _target_fileid=season_dir.fileid):
logger.error(f"{file.name} 移动失败")
return False, f"{file.name} 移动失败"
logger.info(f"{file.path} 移动完成")
# 修改当前目录为季目录
fileitem = season_dir
# 列出当前目录下所有的文件或目录,并进行重命名整理
files = __list_files(_storage=storage, _fileid=fileitem.fileid,
_drive_id=fileitem.drive_id, _path=fileitem.path)
if not files:
logger.info(f"{fileitem.path} 未找到文件,删除空目录")
if not __remove_dir(_storage=storage, _drive_id=fileitem.drive_id, _fileid=fileitem.fileid):
logger.error(f"{fileitem.path} 删除失败")
return False, f"{fileitem.path} 删除失败"
logger.info(f"{fileitem.path} 已删除")
return True, ""
for file in files:
# 过滤不处理的文件
if file.type == "file" and str(file.extension) in ['nfo', 'jpg', 'png']:
continue
# 重新识别文件或目录
file_meta = MetaInfoPath(Path(file.path))
file_media = self.mediachain.recognize_by_meta(file_meta)
if not file_media:
logger.warn(f"{file.name} 未识别到媒体信息")
continue
# 整理这个文件或目录
self.__transfer_online(storage=storage, fileitem=file, meta=file_meta, mediainfo=file_media)
logger.info(f"{fileitem.path} 整理完成")
self.progress.update(value=0,
text=f"{fileitem.path} 整理完成",
key=ProgressKey.FileTransfer)
return True, ""
@staticmethod
def __get_trans_paths(directory: Path):
"""
@@ -479,34 +767,16 @@ class TransferChain(ChainBase):
if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:
args_error()
return
state, errmsg = self.re_transfer(logid=int(logid),
mtype=MediaType(type_str),
mediaid=media_id)
state, errmsg = self.__re_transfer(logid=int(logid),
mtype=MediaType(type_str),
mediaid=media_id)
if not state:
self.post_message(Notification(channel=channel, title="手动整理失败",
text=errmsg, userid=userid))
text=errmsg, userid=userid, link=settings.MP_DOMAIN('#/history')))
return
@staticmethod
def get_root_path(path: str, type_name: str, category: str) -> Optional[Path]:
"""
计算媒体库目录的根路径
"""
if not path or path == "None":
return None
index = -2
if type_name != '电影':
index = -3
if category:
index -= 1
if '/' in path:
retpath = '/'.join(path.split('/')[:index])
else:
retpath = '\\'.join(path.split('\\')[:index])
return Path(retpath)
def re_transfer(self, logid: int, mtype: MediaType = None,
mediaid: str = None) -> Tuple[bool, str]:
def __re_transfer(self, logid: int, mtype: MediaType = None,
mediaid: str = None) -> Tuple[bool, str]:
"""
根据历史记录,重新识别转移,只支持简单条件
:param logid: 历史记录ID
@@ -522,7 +792,6 @@ class TransferChain(ChainBase):
src_path = Path(history.src)
if not src_path.exists():
return False, f"源目录不存在:{src_path}"
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
# 查询媒体信息
if mtype and mediaid:
mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
@@ -542,17 +811,22 @@ class TransferChain(ChainBase):
self.delete_files(Path(history.dest))
# 强制转移
state, errmsg = self.do_transfer(path=src_path,
mediainfo=mediainfo,
download_hash=history.download_hash,
target=dest_path,
force=True)
state, errmsg = self.__do_transfer(storage="local",
path=src_path,
mediainfo=mediainfo,
download_hash=history.download_hash,
force=True)
if not state:
return False, errmsg
return True, ""
def manual_transfer(self, in_path: Path,
def manual_transfer(self,
storage: str,
in_path: Path,
drive_id: str = None,
fileid: str = None,
filetype: str = None,
target: Path = None,
tmdbid: int = None,
doubanid: str = None,
@@ -561,10 +835,15 @@ class TransferChain(ChainBase):
transfer_type: str = None,
epformat: EpisodeFormat = None,
min_filesize: int = 0,
scrape: bool = None,
force: bool = False) -> Tuple[bool, Union[str, list]]:
"""
手动转移,支持复杂条件,带进度显示
:param storage: 存储器
:param in_path: 源文件路径
:param drive_id: 网盘ID
:param fileid: 文件ID
:param filetype: 文件类型
:param target: 目标路径
:param tmdbid: TMDB ID
:param doubanid: 豆瓣ID
@@ -573,6 +852,7 @@ class TransferChain(ChainBase):
:param transfer_type: 转移类型
:param epformat: 剧集格式
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param force: 是否强制转移
"""
logger.info(f"手动转移:{in_path} ...")
@@ -583,20 +863,28 @@ class TransferChain(ChainBase):
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo:
return False, f"媒体信息识别失败tmdbid{tmdbid}doubanid{doubanid}type: {mtype.value}"
else:
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 开始进度
self.progress.start(ProgressKey.FileTransfer)
self.progress.update(value=0,
text=f"开始转移 {in_path} ...",
key=ProgressKey.FileTransfer)
# 开始转移
state, errmsg = self.do_transfer(
state, errmsg = self.__do_transfer(
storage=storage,
path=in_path,
drive_id=drive_id,
fileid=fileid,
filetype=filetype,
mediainfo=mediainfo,
target=target,
transfer_type=transfer_type,
season=season,
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
force=force
)
if not state:
@@ -607,13 +895,18 @@ class TransferChain(ChainBase):
return True, ""
else:
# 没有输入TMDBID时按文件识别
state, errmsg = self.do_transfer(path=in_path,
target=target,
transfer_type=transfer_type,
season=season,
epformat=epformat,
min_filesize=min_filesize,
force=force)
state, errmsg = self.__do_transfer(storage=storage,
path=in_path,
drive_id=drive_id,
fileid=fileid,
filetype=filetype,
target=target,
transfer_type=transfer_type,
season=season,
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
force=force)
return state, errmsg
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
@@ -637,10 +930,10 @@ class TransferChain(ChainBase):
# 发送
self.post_message(Notification(
mtype=NotificationType.Organize,
title=msg_title, text=msg_str, image=mediainfo.get_message_image()))
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
link=settings.MP_DOMAIN('#/history')))
@staticmethod
def delete_files(path: Path) -> Tuple[bool, str]:
def delete_files(self, path: Path) -> Tuple[bool, str]:
"""
删除转移后的文件以及空目录
:param path: 文件路径
@@ -656,6 +949,11 @@ class TransferChain(ChainBase):
for file in files:
Path(file).unlink()
logger.warn(f"文件 {path} 已删除")
# 删除thumb图片
thumb_file = path.parent / (path.stem + "-thumb.jpg")
if thumb_file.exists():
thumb_file.unlink()
logger.info(f"文件 {thumb_file} 已删除")
# 需要删除父目录
elif str(path.parent) == str(path.root):
# 根目录,不删除
@@ -670,26 +968,31 @@ class TransferChain(ChainBase):
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
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 '动漫',
]
# 所有媒体库根目录的名称
library_roots = self.directoryhelper.get_library_dirs()
library_root_names = [Path(library_root.path).name for library_root in library_roots if library_root.path]
# 所有二级分类的名称
category_names = []
category_conf = self.media_category()
if category_conf:
category_names += list(category_conf.keys())
for cats in category_conf.values():
category_names += cats
# 判断父目录是否为空, 为空则删除
for parent_path in path.parents:
# 遍历父目录到媒体库二级分类根路径
if str(parent_path.name) in library_root_names:
if parent_path.name in library_root_names:
break
if parent_path.name in category_names:
continue
if str(parent_path.parent) != str(path.root):
# 父目录非根目录,才删除父目录
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
# 当前路径下没有媒体文件则删除
try:
shutil.rmtree(parent_path)
logger.warn(f"目录 {parent_path} 已删除")
except Exception as e:
logger.error(f"删除目录 {parent_path} 失败:{str(e)}")
return False, f"删除目录 {parent_path} 失败:{str(e)}"
logger.warn(f"目录 {parent_path} 已删除")
return True, ""

View File

@@ -1,3 +1,4 @@
import copy
import importlib
import threading
import traceback
@@ -10,9 +11,10 @@ from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer import TransferChain
from app.core.event import Event as ManagerEvent
from app.core.event import eventmanager, EventManager
from app.core.config import settings
from app.core.event import Event as ManagerEvent, eventmanager, EventManager
from app.core.plugin import PluginManager
from app.helper.message import MessageHelper
from app.helper.thread import ThreadHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -50,6 +52,8 @@ class Command(metaclass=Singleton):
self.chain = CommandChian()
# 定时服务管理
self.scheduler = Scheduler()
# 消息管理器
self.messagehelper = MessageHelper()
# 线程管理器
self.threader = ThreadHelper()
# 内置命令
@@ -165,7 +169,8 @@ class Command(metaclass=Singleton):
}
)
# 广播注册命令菜单
self.chain.register_commands(commands=self.get_commands())
if not settings.DEV:
self.chain.register_commands(commands=self.get_commands())
# 消息处理线程
self._thread = Thread(target=self.__run)
# 启动事件处理线程
@@ -182,24 +187,29 @@ class Command(metaclass=Singleton):
if event:
logger.info(f"处理事件:{event.event_type} - {handlers}")
for handler in handlers:
names = handler.__qualname__.split(".")
[class_name, method_name] = names
try:
names = handler.__qualname__.split(".")
[class_name, method_name] = names
if class_name in self.pluginmanager.get_plugin_ids():
# 插件事件
self.threader.submit(
self.pluginmanager.run_plugin_method,
class_name, method_name, event
class_name, method_name, copy.deepcopy(event)
)
else:
# 检查全局变量中是否存在
if class_name not in globals():
# 导入模块除了插件和Command本身只有chain能响应事件
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
try:
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
continue
else:
# 通过类名创建类实例
class_obj = globals()[class_name]()
@@ -207,10 +217,23 @@ class Command(metaclass=Singleton):
if hasattr(class_obj, method_name):
self.threader.submit(
getattr(class_obj, method_name),
event
copy.deepcopy(event)
)
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
message=f"{class_name}.{method_name}{str(e)}",
role="system")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "event",
"event_type": event.event_type,
"event_handle": f"{class_name}.{method_name}",
"error": str(e),
"traceback": traceback.format_exc()
}
)
def __run_command(self, command: Dict[str, any],
data_str: str = "",
@@ -250,6 +273,8 @@ class Command(metaclass=Singleton):
data = cmd_data.get("data") or {}
data['channel'] = channel
data['user'] = userid
if data_str:
data['args'] = data_str
cmd_data['data'] = data
command['func'](**cmd_data)
elif args_num == 2:
@@ -266,9 +291,11 @@ class Command(metaclass=Singleton):
"""
停止事件处理线程
"""
logger.info("正在停止事件处理...")
self._event.set()
try:
self._thread.join()
logger.info("事件处理停止完成")
except Exception as e:
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
@@ -319,6 +346,9 @@ class Command(metaclass=Singleton):
logger.info(f"{command.get('description')} 执行完成")
except Exception as err:
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
self.messagehelper.put(title=f"执行命令 {cmd} 出错",
message=str(err),
role="system")
@staticmethod
def send_plugin_event(etype: EventType, data: dict) -> None:

View File

@@ -1,7 +1,8 @@
import secrets
import sys
import threading
from pathlib import Path
from typing import List, Optional
from typing import Optional, List
from pydantic import BaseSettings, validator
@@ -9,8 +10,13 @@ from app.utils.system import SystemUtils
class Settings(BaseSettings):
"""
系统配置类
"""
# 项目名称
PROJECT_NAME = "MoviePilot"
# 域名 格式https://movie-pilot.org
APP_DOMAIN: str = ""
# API路径
API_V1_STR: str = "/api/v1"
# 前端资源路径
@@ -33,6 +39,8 @@ class Settings(BaseSettings):
DEBUG: bool = False
# 是否开发模式
DEV: bool = False
# 是否开启插件热加载
PLUGIN_AUTO_RELOAD: bool = False
# 配置文件目录
CONFIG_DIR: Optional[str] = None
# 超级管理员
@@ -49,8 +57,6 @@ class Settings(BaseSettings):
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
SCRAP_SOURCE: str = "themoviedb"
# 刮削入库的媒体文件
SCRAP_METADATA: bool = True
# 新增已入库媒体是否跟随TMDB信息变化
SCRAP_FOLLOW_TMDB: bool = True
# TMDB图片地址
@@ -89,8 +95,8 @@ class Settings(BaseSettings):
AUTH_SITE: str = ""
# 交互搜索自动下载用户ID使用,分割
AUTO_DOWNLOAD_USER: Optional[str] = None
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat多个通知渠道用,分隔
MESSAGER: str = "telegram"
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat/webpush,多个通知渠道用,分隔
MESSAGER: str = "webpush"
# WeChat企业ID
WECHAT_CORPID: Optional[str] = None
# WeChat应用Secret
@@ -153,16 +159,6 @@ class Settings(BaseSettings):
TR_PASSWORD: Optional[str] = None
# 种子标签
TORRENT_TAG: str = "MOVIEPILOT"
# 下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: Optional[str] = None
# 电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: Optional[str] = None
# 动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: Optional[str] = None
# 下载目录二级分类
DOWNLOAD_CATEGORY: bool = False
# 下载站点字幕
DOWNLOAD_SUBTITLE: bool = True
# 媒体服务器 emby/jellyfin/plex多个媒体服务器,分割
@@ -191,6 +187,8 @@ class Settings(BaseSettings):
PLEX_TOKEN: Optional[str] = None
# 转移方式 link/copy/move/softlink
TRANSFER_TYPE: str = "copy"
# 是否同盘优先
TRANSFER_SAME_DISK: bool = True
# CookieCloud是否启动本地服务
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
# CookieCloud服务器地址
@@ -201,20 +199,12 @@ class Settings(BaseSettings):
COOKIECLOUD_PASSWORD: Optional[str] = None
# CookieCloud同步间隔分钟
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# CookieCloud同步黑名单多个域名,分割
COOKIECLOUD_BLACKLIST: Optional[str] = None
# OCR服务器地址
OCR_HOST: str = "https://movie-pilot.org"
# CookieCloud对应的浏览器UA
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
# 媒体库目录,多个目录使用,分隔
LIBRARY_PATH: Optional[str] = None
# 电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: Optional[str] = None
# 二级分类
LIBRARY_CATEGORY: bool = True
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS = [16]
# 电影重命名格式
@@ -231,21 +221,54 @@ class Settings(BaseSettings):
# 大内存模式
BIG_MEMORY_MODE: bool = False
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
# Github token提高请求api限流阈值 ghp_****
GITHUB_TOKEN: Optional[str] = None
# 指定的仓库Github token多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
REPO_GITHUB_TOKEN: Optional[str] = None
# Github代理服务器格式https://mirror.ghproxy.com/
GITHUB_PROXY: Optional[str] = ''
# 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True
AUTO_UPDATE_RESOURCE: bool = False
# 元数据识别缓存过期时间(小时)
META_CACHE_EXPIRE: int = 0
# 是否启用DOH解析域名
DOH_ENABLE: bool = True
# 使用 DOH 解析的域名列表
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
# DOH 解析服务器列表
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# 搜索多个名称
SEARCH_MULTIPLE_NAME: bool = False
# 订阅数据共享
SUBSCRIBE_STATISTIC_SHARE: bool = True
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
MP_SERVER_HOST: str = "https://movie-pilot.org"
# 【已弃用】刮削入库的媒体文件
SCRAP_METADATA: bool = True
# 【已弃用】下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: Optional[str] = None
# 【已弃用】电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 【已弃用】电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: Optional[str] = None
# 【已弃用】动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: Optional[str] = None
# 【已弃用】下载目录二级分类
DOWNLOAD_CATEGORY: bool = False
# 【已弃用】媒体库目录,多个目录使用,分隔
LIBRARY_PATH: Optional[str] = None
# 【已弃用】电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 【已弃用】电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 【已弃用】动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: Optional[str] = None
# 【已弃用】二级分类
LIBRARY_CATEGORY: bool = True
@validator("SUBSCRIBE_RSS_INTERVAL",
"COOKIECLOUD_INTERVAL",
@@ -289,7 +312,7 @@ class Settings(BaseSettings):
@property
def LOG_PATH(self):
return self.CONFIG_PATH / "logs"
@property
def COOKIE_PATH(self):
return self.CONFIG_PATH / "cookies"
@@ -330,48 +353,6 @@ class Settings(BaseSettings):
"server": self.PROXY_HOST
}
@property
def LIBRARY_PATHS(self) -> List[Path]:
if self.LIBRARY_PATH:
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
return [self.CONFIG_PATH / "library"]
@property
def SAVE_PATH(self) -> Path:
"""
获取下载保存目录
"""
if self.DOWNLOAD_PATH:
return Path(self.DOWNLOAD_PATH)
return self.CONFIG_PATH / "downloads"
@property
def SAVE_MOVIE_PATH(self) -> Path:
"""
获取电影下载保存目录
"""
if self.DOWNLOAD_MOVIE_PATH:
return Path(self.DOWNLOAD_MOVIE_PATH)
return self.SAVE_PATH
@property
def SAVE_TV_PATH(self) -> Path:
"""
获取电视剧下载保存目录
"""
if self.DOWNLOAD_TV_PATH:
return Path(self.DOWNLOAD_TV_PATH)
return self.SAVE_PATH
@property
def SAVE_ANIME_PATH(self) -> Path:
"""
获取动漫下载保存目录
"""
if self.DOWNLOAD_ANIME_PATH:
return Path(self.DOWNLOAD_ANIME_PATH)
return self.SAVE_TV_PATH
@property
def GITHUB_HEADERS(self):
"""
@@ -383,6 +364,37 @@ class Settings(BaseSettings):
}
return {}
def REPO_GITHUB_HEADERS(self, repo: str = None):
"""
Github指定的仓库请求头
:param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息
:return: Github请求头
"""
# 如果没有传入指定的仓库名称或没有配置指定的仓库Token则返回默认的请求头信息
if not repo or not self.REPO_GITHUB_TOKEN:
return self.GITHUB_HEADERS
headers = {}
# 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
token_pairs = self.REPO_GITHUB_TOKEN.split(",")
for token_pair in token_pairs:
try:
parts = token_pair.split(":")
if len(parts) != 2:
print(f"无效的令牌格式: {token_pair}")
continue
repo_info = parts[0].strip()
token = parts[1].strip()
if not repo_info or not token:
print(f"无效的令牌或仓库信息: {token_pair}")
continue
headers[repo_info] = {
"Authorization": f"Bearer {token}"
}
except Exception as e:
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
# 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头
return headers.get(repo, self.GITHUB_HEADERS)
@property
def DEFAULT_DOWNLOADER(self):
"""
@@ -390,7 +402,7 @@ class Settings(BaseSettings):
"""
if not self.DOWNLOADER:
return None
return self.DOWNLOADER.split(",")[0]
return next((d for d in settings.DOWNLOADER.split(",") if d), None)
@property
def DOWNLOADERS(self):
@@ -399,7 +411,25 @@ class Settings(BaseSettings):
"""
if not self.DOWNLOADER:
return []
return self.DOWNLOADER.split(",")
return [d for d in settings.DOWNLOADER.split(",") if d]
@property
def VAPID(self):
return {
"subject": f"mailto:{self.SUPERUSER}@movie-pilot.org",
"publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM",
"privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894"
}
def MP_DOMAIN(self, url: str = None):
if not self.APP_DOMAIN:
return None
domain = self.APP_DOMAIN.rstrip("/")
if not domain.startswith("http"):
domain = "http://" + domain
if not url:
return domain
return domain + "/" + url.lstrip("/")
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -423,7 +453,45 @@ class Settings(BaseSettings):
case_sensitive = True
class GlobalVar(object):
"""
全局标识
"""
# 系统停止事件
STOP_EVENT: threading.Event = threading.Event()
# webpush订阅
SUBSCRIPTIONS: List[dict] = []
def stop_system(self):
"""
停止系统
"""
self.STOP_EVENT.set()
def is_system_stopped(self):
"""
是否停止
"""
return self.STOP_EVENT.is_set()
def get_subscriptions(self):
"""
获取webpush订阅
"""
return self.SUBSCRIPTIONS
def push_subscription(self, subscription: dict):
"""
添加webpush订阅
"""
self.SUBSCRIPTIONS.append(subscription)
# 实例化配置
settings = Settings(
_env_file=Settings().CONFIG_PATH / "app.env",
_env_file_encoding="utf-8"
)
# 全局标识
global_vars = GlobalVar()

View File

@@ -347,10 +347,10 @@ class MediaInfo:
return [], []
directors = []
actors = []
for cast in _credits.get("cast"):
for cast in _credits.get("cast") or []:
if cast.get("known_for_department") == "Acting":
actors.append(cast)
for crew in _credits.get("crew"):
for crew in _credits.get("crew") or []:
if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]:
directors.append(crew)
return directors, actors

View File

@@ -1,5 +1,4 @@
import re
from pathlib import Path
from typing import Optional
from Pinyin2Hanzi import is_pinyin
@@ -27,7 +26,7 @@ class MetaVideo(MetaBase):
_source = ""
_effect = []
# 正则式区
_season_re = r"S(\d{2})|^S(\d{1,2})$|S(\d{1,2})E"
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
@@ -74,6 +73,15 @@ class MetaVideo(MetaBase):
self.begin_episode = int(title)
self.type = MediaType.TV
return
# 全名为Season xx 及 Sxx 直接返回
season_full_res = re.search(r"^Season\s+(\d{1,3})$|^S(\d{1,3})$", title)
if season_full_res:
self.type = MediaType.TV
season = season_full_res.group(1)
if season:
self.begin_season = int(season)
self.total_season = 1
return
# 去掉名称中第1个[]的内容
title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1)
# 把xxxx-xxxx年份换成前一个年份常出现在季集上
@@ -138,7 +146,7 @@ class MetaVideo(MetaBase):
# 处理part
if self.part and self.part.upper() == "PART":
self.part = None
# 没有中文标题时,试中描述中获取中文名
# 没有中文标题时,试中描述中获取中文名
if not self.cn_name and self.en_name and self.subtitle:
if self.__is_pinyin(self.en_name):
# 英文名是拼音

View File

@@ -71,7 +71,10 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
"ultrahd": [],
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)',
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', '(?:Lilith|NC)-Raws', '织梦字幕组']
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组','极影字幕社','悠哈璃羽字幕社',
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组',]
}
def __init__(self):

View File

@@ -1,4 +1,3 @@
import logging
from pathlib import Path
from typing import Tuple

View File

@@ -1,4 +1,5 @@
from typing import Generator, Optional, Tuple
import traceback
from typing import Generator, Optional, Tuple, Any
from app.core.config import settings
from app.helper.module import ModuleHelper
@@ -34,22 +35,31 @@ class ModuleManager(metaclass=Singleton):
for module in modules:
module_id = module.__name__
self._modules[module_id] = module
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.info(f"Moudle Loaded{module_id}")
try:
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.info(f"Moudle Loaded{module_id}")
except Exception as err:
logger.error(f"Load Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
def stop(self):
"""
停止所有模块
"""
for _, module in self._running_modules.items():
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
if hasattr(module, "stop"):
module.stop()
try:
module.stop()
logger.info(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("模块停止完成")
def reload(self):
"""
@@ -87,7 +97,17 @@ class ModuleManager(metaclass=Singleton):
return True
return False
def get_modules(self, method: str) -> Generator:
def get_running_module(self, module_id: str) -> Any:
"""
根据模块id获取模块运行实例
"""
if not module_id:
return None
if not self._running_modules:
return None
return self._running_modules.get(module_id)
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
"""
@@ -97,3 +117,19 @@ class ModuleManager(metaclass=Singleton):
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
"""
if not module_id:
return None
if not self._modules:
return None
return self._modules.get(module_id)
def get_modules(self) -> dict:
"""
获取模块列表
"""
return self._modules

View File

@@ -1,23 +1,100 @@
import concurrent
import concurrent.futures
import importlib.util
import inspect
import os
import threading
import time
import traceback
from typing import List, Any, Dict, Tuple, Optional
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from app import schemas
from app.core.config import settings
from app.core.event import eventmanager
from app.db.plugindata_oper import PluginDataOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.module import ModuleHelper
from app.helper.plugin import PluginHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.crypto import RSAUtils
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
class PluginMonitorHandler(FileSystemEventHandler):
# 计时器
__reload_timer = None
# 防抖时间间隔
__debounce_interval = 0.5
# 最近一次修改时间
__last_modified = 0
# 修改间隔
__timeout = 2
def on_modified(self, event):
"""
插件文件修改后重载
"""
if event.is_directory:
return
# 使用 pathlib 处理文件路径,跳过非 .py 文件以及 pycache 目录中的文件
event_path = Path(event.src_path)
if not event_path.name.endswith(".py") or "pycache" in event_path.parts:
return
current_time = time.time()
if current_time - self.__last_modified < self.__timeout:
return
self.__last_modified = current_time
# 读取插件根目录下的__init__.py文件读取class XXXX(_PluginBase)的类名
try:
plugins_root = settings.ROOT_PATH / "app" / "plugins"
# 确保修改的文件在 plugins 目录下
if plugins_root not in event_path.parents:
return
# 获取插件目录路径没有找到__init__.py时说明不是有效包跳过插件重载
# 插件重载目前没有支持app/plugins/plugin/package/__init__.py的场景这里也不做支持
plugin_dir = event_path.parent
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
logger.debug(f"{plugin_dir} 下没有找到 __init__.py跳过插件重载")
return
with open(init_file, "r", encoding="utf-8") as f:
lines = f.readlines()
pid = None
for line in lines:
if line.startswith("class") and "(_PluginBase)" in line:
pid = line.split("class ")[1].split("(_PluginBase)")[0].strip()
if pid:
# 防抖处理,通过计时器延迟加载
if self.__reload_timer:
self.__reload_timer.cancel()
self.__reload_timer = threading.Timer(self.__debounce_interval, self.__reload_plugin, [pid])
self.__reload_timer.start()
except Exception as e:
logger.error(f"插件文件修改后重载出错:{str(e)}")
@staticmethod
def __reload_plugin(pid):
"""
重新加载插件
"""
try:
logger.info(f"插件 {pid} 文件修改,重新加载...")
PluginManager().reload_plugin(pid)
except Exception as e:
logger.error(f"插件文件修改后重载出错:{str(e)}")
class PluginManager(metaclass=Singleton):
"""
插件管理器
@@ -30,11 +107,17 @@ class PluginManager(metaclass=Singleton):
_running_plugins: dict = {}
# 配置Key
_config_key: str = "plugin.%s"
# 监听器
_observer: Observer = None
def __init__(self):
self.siteshelper = SitesHelper()
self.pluginhelper = PluginHelper()
self.systemconfig = SystemConfigOper()
self.plugindata = PluginDataOper()
# 开发者模式监测插件修改
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
self.__start_monitor()
def init_config(self):
# 停止已有插件
@@ -47,11 +130,28 @@ class PluginManager(metaclass=Singleton):
启动加载插件
:param pid: 插件ID为空加载所有插件
"""
def check_module(module: Any):
"""
检查模块
"""
if not hasattr(module, 'init_plugin') or not hasattr(module, "plugin_name"):
return False
return True
# 扫描插件目录
plugins = ModuleHelper.load(
"app.plugins",
filter_func=lambda _, obj: hasattr(obj, 'init_plugin') and hasattr(obj, "plugin_name")
)
if pid:
# 加载指定插件
plugins = ModuleHelper.load_with_pre_filter(
"app.plugins",
filter_func=lambda name, obj: check_module(obj) and name == pid
)
else:
# 加载所有插件
plugins = ModuleHelper.load(
"app.plugins",
filter_func=lambda _, obj: check_module(obj)
)
# 已安装插件
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 排序
@@ -61,6 +161,12 @@ class PluginManager(metaclass=Singleton):
if pid and plugin_id != pid:
continue
try:
# 判断插件是否满足认证要求,如不满足则不进行实例化
if not self.__set_and_check_auth_level(plugin=plugin):
# 如果是插件热更新实例,这里则进行替换
if plugin_id in self._plugins:
self._plugins[plugin_id] = plugin
continue
# 存储Class
self._plugins[plugin_id] = plugin
# 未安装的不加载
@@ -105,6 +211,10 @@ class PluginManager(metaclass=Singleton):
:param pid: 插件ID为空停止所有插件
"""
# 停止插件
if pid:
logger.info(f"正在停止插件 {pid}...")
else:
logger.info("正在停止所有插件...")
for plugin_id, plugin in self._running_plugins.items():
if pid and plugin_id != pid:
continue
@@ -114,12 +224,32 @@ class PluginManager(metaclass=Singleton):
# 清空指定插件
if pid in self._running_plugins:
self._running_plugins.pop(pid)
if pid in self._plugins:
self._plugins.pop(pid)
else:
# 清空
self._plugins = {}
self._running_plugins = {}
logger.info("插件停止完成")
def __start_monitor(self):
"""
开发者模式下监测插件文件修改
"""
logger.info("开发者模式下开始监测插件文件修改...")
monitor_handler = PluginMonitorHandler()
self._observer = Observer()
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
self._observer.start()
def stop_monitor(self):
"""
停止监测插件修改
"""
# 停止监测
if self._observer:
logger.info("正在停止插件文件修改监测...")
self._observer.stop()
self._observer.join()
logger.info("插件文件修改监测停止完成")
@staticmethod
def __stop_plugin(plugin: Any):
@@ -212,15 +342,26 @@ class PluginManager(metaclass=Singleton):
return False
return self.systemconfig.delete(self._config_key % pid)
def delete_plugin_data(self, pid: str) -> bool:
"""
删除插件数据
:param pid: 插件ID
"""
if not self._plugins.get(pid):
return False
self.plugindata.del_data(pid)
return True
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
"""
获取插件表单
:param pid: 插件ID
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return [], {}
if hasattr(self._running_plugins[pid], "get_form"):
return self._running_plugins[pid].get_form() or ([], {})
if hasattr(plugin, "get_form"):
return plugin.get_form() or ([], {})
return [], {}
def get_plugin_page(self, pid: str) -> List[dict]:
@@ -228,12 +369,51 @@ class PluginManager(metaclass=Singleton):
获取插件页面
:param pid: 插件ID
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return []
if hasattr(self._running_plugins[pid], "get_page"):
return self._running_plugins[pid].get_page() or []
if hasattr(plugin, "get_page"):
return plugin.get_page() or []
return []
def get_plugin_dashboard(self, pid: str, key: str, **kwargs) -> Optional[schemas.PluginDashboard]:
"""
获取插件仪表盘
:param pid: 插件ID
:param key: 仪表盘key
"""
def __get_params_count(func: Callable):
"""
获取函数的参数信息
"""
signature = inspect.signature(func)
return len(signature.parameters)
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if hasattr(plugin, "get_dashboard"):
# 检查方法的参数个数
params_count = __get_params_count(plugin.get_dashboard)
if params_count > 1:
dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs)
elif params_count > 0:
dashboard: Tuple = plugin.get_dashboard(**kwargs)
else:
dashboard: Tuple = plugin.get_dashboard()
if dashboard:
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,
name=plugin.plugin_name,
key=key or "",
cols=cols or {},
elements=elements,
attrs=attrs or {}
)
return None
def get_plugin_commands(self) -> List[Dict[str, Any]]:
"""
获取插件命令
@@ -254,7 +434,7 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件命令出错:{str(e)}")
return ret_commands
def get_plugin_apis(self) -> List[Dict[str, Any]]:
def get_plugin_apis(self, plugin_id: str = None) -> List[Dict[str, Any]]:
"""
获取插件API
[{
@@ -267,6 +447,8 @@ class PluginManager(metaclass=Singleton):
"""
ret_apis = []
for pid, plugin in self._running_plugins.items():
if plugin_id and pid != plugin_id:
continue
if hasattr(plugin, "get_api") \
and ObjectUtils.check_method(plugin.get_api):
try:
@@ -301,17 +483,48 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
return ret_services
def get_plugin_dashboard_meta(self):
"""
获取所有插件仪表盘元信息
"""
dashboard_meta = []
for plugin_id, plugin in self._running_plugins.items():
if not hasattr(plugin, "get_dashboard") or not ObjectUtils.check_method(plugin.get_dashboard):
continue
try:
if not plugin.get_state():
continue
# 如果是多仪表盘实现
if hasattr(plugin, "get_dashboard_meta") and ObjectUtils.check_method(plugin.get_dashboard_meta):
meta = plugin.get_dashboard_meta()
if meta:
dashboard_meta.extend([{
"id": plugin_id,
"name": m.get("name"),
"key": m.get("key"),
} for m in meta if m])
else:
dashboard_meta.append({
"id": plugin_id,
"name": plugin.plugin_name,
"key": "",
})
except Exception as e:
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
return dashboard_meta
def get_plugin_attr(self, pid: str, attr: str) -> Any:
"""
获取插件属性
:param pid: 插件ID
:param attr: 属性名
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if not hasattr(self._running_plugins[pid], attr):
if not hasattr(plugin, attr):
return None
return getattr(self._running_plugins[pid], attr)
return getattr(plugin, attr)
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
@@ -321,11 +534,12 @@ class PluginManager(metaclass=Singleton):
:param args: 参数
:param kwargs: 关键字参数
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if not hasattr(self._running_plugins[pid], method):
if not hasattr(plugin, method):
return None
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
return getattr(plugin, method)(*args, **kwargs)
def get_plugin_ids(self) -> List[str]:
"""
@@ -390,11 +604,12 @@ class PluginManager(metaclass=Singleton):
if plugin_obj and hasattr(plugin_obj, "get_page"):
if ObjectUtils.check_method(plugin_obj.get_page):
plugin.has_page = True
# 公钥
if plugin_info.get("key"):
plugin.plugin_public_key = plugin_info.get("key")
# 权限
if plugin_info.get("level"):
plugin.auth_level = plugin_info.get("level")
if self.siteshelper.auth_level < plugin.auth_level:
continue
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):
continue
# 名称
if plugin_info.get("name"):
plugin.plugin_name = plugin_info.get("name")
@@ -438,24 +653,27 @@ class PluginManager(metaclass=Singleton):
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for m in settings.PLUGIN_MARKET.split(","):
if not m:
continue
futures.append(executor.submit(__get_plugin_info, m))
for future in concurrent.futures.as_completed(futures):
plugins = future.result()
if plugins:
all_plugins.extend(plugins)
# 去重
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
# 所有插件按repo在设置中的顺序排序
all_plugins.sort(
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
)
# 按插件ID和版本号去重相同插件以前面的为准
result = []
_dup = []
# 相同ID的插件保留版本号最大版本
max_versions = {}
for p in all_plugins:
key = f"{p.id}v{p.plugin_version}"
if key not in _dup:
_dup.append(key)
result.append(p)
logger.info(f"共获取到 {len(result)}第三方插件")
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
max_versions[p.id] = p.plugin_version
result = [p for p in all_plugins if
p.plugin_version == max_versions[p.id]]
logger.info(f"共获取到 {len(result)}线上插件")
return result
def get_local_plugins(self) -> List[schemas.Plugin]:
@@ -494,11 +712,12 @@ class PluginManager(metaclass=Singleton):
plugin.has_page = True
else:
plugin.has_page = False
# 公钥
if hasattr(plugin_class, "plugin_public_key"):
plugin.plugin_public_key = plugin_class.plugin_public_key
# 权限
if hasattr(plugin_class, "auth_level"):
plugin.auth_level = plugin_class.auth_level
if self.siteshelper.auth_level < plugin.auth_level:
continue
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class):
continue
# 名称
if hasattr(plugin_class, "plugin_name"):
plugin.plugin_name = plugin_class.plugin_name
@@ -533,10 +752,70 @@ class PluginManager(metaclass=Singleton):
@staticmethod
def is_plugin_exists(pid: str) -> bool:
"""
判断插件是否在本地文件系统存在
判断插件是否在本地包中存在
:param pid: 插件ID
"""
if not pid:
return False
plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
return plugin_dir.exists()
try:
# 构建包名
package_name = f"app.plugins.{pid.lower()}"
# 检查包是否存在
package_exists = importlib.util.find_spec(package_name) is not None
logger.debug(f"{pid} exists: {package_exists}")
return package_exists
except Exception as e:
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
return False
def __set_and_check_auth_level(self, plugin: Union[schemas.Plugin, Type[Any]],
source: Optional[Union[dict, Type[Any]]] = None) -> bool:
"""
设置并检查插件的认证级别
:param plugin: 插件对象或包含 auth_level 属性的对象
:param source: 可选的字典对象或类对象,可能包含 "level""auth_level"
:return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True否则返回 False
"""
# 检查并赋值 source 中的 level 或 auth_level
if source:
if isinstance(source, dict) and "level" in source:
plugin.auth_level = source.get("level")
elif hasattr(source, "auth_level"):
plugin.auth_level = source.auth_level
# 如果 source 为空且 plugin 本身没有 auth_level直接返回 True
elif not hasattr(plugin, "auth_level"):
return True
# auth_level 级别说明
# 1 - 所有用户可见
# 2 - 站点认证用户可见
# 3 - 站点&密钥认证可见
# 99 - 站点&特殊密钥认证可见
# 如果当前站点认证级别大于 1 且插件级别为 99并存在插件公钥说明为特殊密钥认证通过密钥匹配进行认证
if self.siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"):
plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__
public_key = plugin.plugin_public_key
if public_key:
private_key = PluginManager.__get_plugin_private_key(plugin_id)
verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key)
return verify
# 如果当前站点认证级别小于插件级别,则返回 False
if self.siteshelper.auth_level < plugin.auth_level:
return False
return True
@staticmethod
def __get_plugin_private_key(plugin_id: str) -> Optional[str]:
"""
根据插件标识获取对应的私钥
:param plugin_id: 插件标识
:return: 对应的插件私钥,如果未找到则返回 None
"""
try:
# 将插件标识转换为大写并构建环境变量名称
env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY"
private_key = os.environ.get(env_var_name)
return private_key
except Exception as e:
logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}")
return None

View File

@@ -30,7 +30,7 @@ reusable_oauth2 = OAuth2PasswordBearer(
def create_access_token(
userid: Union[str, Any], username: str, super_user: bool = False,
expires_delta: timedelta = None
expires_delta: timedelta = None, level: int = 1
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
@@ -42,7 +42,8 @@ def create_access_token(
"exp": expire,
"sub": str(userid),
"username": username,
"super_user": super_user
"super_user": super_user,
"level": level
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@@ -61,21 +62,21 @@ def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
)
def get_token(token: str = None) -> str:
def __get_token(token: str = None) -> str:
"""
从请求URL中获取token
"""
return token
def get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
def __get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
"""
从请求URL中获取apikey
"""
return apikey or x_api_key
def verify_uri_token(token: str = Depends(get_token)) -> str:
def verify_apitoken(token: str = Depends(__get_token)) -> str:
"""
通过依赖项使用token进行身份认证
"""
@@ -87,7 +88,7 @@ def verify_uri_token(token: str = Depends(get_token)) -> str:
return token
def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str:
def verify_apikey(apikey: str = Depends(__get_apikey)) -> str:
"""
通过依赖项使用apikey进行身份认证
"""
@@ -99,6 +100,18 @@ def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str:
return apikey
def verify_uri_token(token: str = Depends(__get_token)) -> str:
"""
通过依赖项使用token进行身份认证
"""
if not verify_token(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token校验不通过"
)
return token
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)

View File

@@ -139,3 +139,15 @@ class DownloadHistoryOper(DbOper):
return DownloadHistory.list_by_type(db=self._db,
mtype=mtype,
days=days)
def delete_history(self, historyid):
"""
删除下载记录
"""
DownloadHistory.delete(self._db, historyid)
def delete_downloadfile(self, downloadfileid):
"""
删除下载文件记录
"""
DownloadFiles.delete(self._db, downloadfileid)

View File

@@ -7,4 +7,4 @@ from .subscribe import Subscribe
from .systemconfig import SystemConfig
from .transferhistory import TransferHistory
from .user import User
from .userconfig import UserConfig
from .userconfig import UserConfig

View File

@@ -29,6 +29,11 @@ class PluginData(Base):
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
@staticmethod
@db_update
def del_plugin_data(db: Session, plugin_id: str):
db.query(PluginData).filter(PluginData.plugin_id == plugin_id).delete()
@staticmethod
@db_query
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):

View File

@@ -45,6 +45,8 @@ class Site(Base):
limit_count = Column(Integer, default=0)
# 流控间隔
limit_seconds = Column(Integer, default=0)
# 超时时间
timeout = Column(Integer, default=0)
# 是否启用
is_active = Column(Boolean(), default=True)
# 创建时间
@@ -67,6 +69,12 @@ class Site(Base):
result = db.query(Site).order_by(Site.pri).all()
return list(result)
@staticmethod
@db_query
def get_domains_by_ids(db: Session, ids: list):
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
return [r[0] for r in result]
@staticmethod
@db_update
def reset(db: Session):

View File

@@ -57,6 +57,7 @@ class TransferHistory(Base):
).offset((page - 1) * count).limit(count).all()
else:
result = db.query(TransferHistory).filter(or_(
TransferHistory.title.like(f'%{title}%'),
TransferHistory.src.like(f'%{title}%'),
TransferHistory.dest.like(f'%{title}%'),
)).order_by(
@@ -89,6 +90,11 @@ class TransferHistory(Base):
def get_by_src(db: Session, src: str):
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
@staticmethod
@db_query
def get_by_dest(db: Session, dest: str):
return db.query(TransferHistory).filter(TransferHistory.dest == dest).first()
@staticmethod
@db_query
def list_by_hash(db: Session, download_hash: str):
@@ -123,6 +129,7 @@ class TransferHistory(Base):
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
else:
return db.query(func.count(TransferHistory.id)).filter(or_(
TransferHistory.title.like(f'%{title}%'),
TransferHistory.src.like(f'%{title}%'),
TransferHistory.dest.like(f'%{title}%')
)).first()[0]

View File

@@ -44,13 +44,16 @@ class PluginDataOper(DbOper):
else:
return PluginData.get_plugin_data(self._db, plugin_id)
def del_data(self, plugin_id: str, key: str) -> Any:
def del_data(self, plugin_id: str, key: str = None) -> Any:
"""
删除插件数据
:param plugin_id: 插件id
:param key: 数据key
"""
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
if key:
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
else:
PluginData.del_plugin_data(self._db, plugin_id)
def truncate(self):
"""

View File

@@ -63,6 +63,12 @@ class SiteOper(DbOper):
"""
return Site.get_by_domain(self._db, domain)
def get_domains_by_ids(self, ids: List[int]) -> List[str]:
"""
按ID获取站点域名
"""
return Site.get_domains_by_ids(self._db, ids)
def exists(self, domain: str) -> bool:
"""
判断站点是否存在

View File

@@ -36,6 +36,13 @@ class TransferHistoryOper(DbOper):
"""
return TransferHistory.get_by_src(self._db, src)
def get_by_dest(self, dest: str) -> TransferHistory:
"""
按转移路径查询转移记录
:param dest: 数据key
"""
return TransferHistory.get_by_dest(self._db, dest)
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
"""
按种子hash查询转移记录

620
app/helper/aliyun.py Normal file
View File

@@ -0,0 +1,620 @@
import base64
import json
import time
import uuid
from pathlib import Path
from typing import Optional, Tuple, List
from requests import Response
from app import schemas
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
class AliyunHelper:
"""
阿里云相关操作
"""
_X_SIGNATURE = ('f4b7bed5d8524a04051bd2da876dd79afe922b8205226d65855d02b267422adb1'
'e0d8a816b021eaf5c36d101892180f79df655c5712b348c2a540ca136e6b22001')
_X_PUBLIC_KEY = ('04d9d2319e0480c840efeeb75751b86d0db0c5b9e72c6260a1d846958adceaf9d'
'ee789cab7472741d23aafc1a9c591f72e7ee77578656e6c8588098dea1488ac2a')
# 生成二维码
qrcode_url = ("https://passport.aliyundrive.com/newlogin/qrcode/generate.do?"
"appName=aliyun_drive&fromSite=52&appEntrance=web&isMobile=false"
"&lang=zh_CN&returnUrl=&bizParams=&_bx-v=2.0.31")
# 二维码登录确认
check_url = "https://passport.aliyundrive.com/newlogin/qrcode/query.do?appName=aliyun_drive&fromSite=52&_bx-v=2.0.31"
# 更新访问令牌
update_accessstoken_url = "https://auth.aliyundrive.com/v2/account/token"
# 创建会话
create_session_url = "https://api.aliyundrive.com/users/v1/users/device/create_session"
# 用户信息
user_info_url = "https://user.aliyundrive.com/v2/user/get"
# 浏览文件
list_file_url = "https://api.aliyundrive.com/adrive/v3/file/list"
# 创建目录或文件
create_folder_file_url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
# 文件详情
file_detail_url = "https://api.aliyundrive.com/v2/file/get"
# 删除文件
delete_file_url = " https://api.aliyundrive.com/v2/recyclebin/trash"
# 文件重命名
rename_file_url = "https://api.aliyundrive.com/v3/file/update"
# 获取下载链接
download_url = "https://api.aliyundrive.com/v2/file/get_download_url"
# 移动文件
move_file_url = "https://api.aliyundrive.com/v2/file/move"
# 上传文件完成
upload_file_complete_url = "https://api.aliyundrive.com/v2/file/complete"
def __init__(self):
self.systemconfig = SystemConfigOper()
def __handle_error(self, res: Response, apiname: str, action: bool = True):
"""
统一处理和打印错误信息
"""
if res is None:
logger.warn("无法连接到阿里云盘!")
return
try:
result = res.json()
except Exception as err:
logger.error(f"解析阿里云盘返回数据失败:{str(err)}")
return
code = result.get("code")
message = result.get("message")
display_message = result.get("display_message")
if code or message:
logger.warn(f"Aliyun {apiname}失败:{code} - {display_message or message}")
if action:
if code == "DeviceSessionSignatureInvalid":
logger.warn("设备已失效,正在重新建立会话...")
self.__create_session(self.__get_headers(self.__auth_params))
if code == "UserDeviceOffline":
logger.warn("设备已离线,尝试重新登录,如仍报错请检查阿里云盘绑定设备数量是否超限!")
self.__create_session(self.__get_headers(self.__auth_params))
if code == "AccessTokenInvalid":
logger.warn("访问令牌已失效,正在刷新令牌...")
self.__update_accesstoken(self.__auth_params, self.__auth_params.get("refreshToken"))
else:
logger.info(f"Aliyun {apiname}成功")
@property
def __auth_params(self):
"""
获取阿里云盘认证参数并初始化参数格式
"""
return self.systemconfig.get(SystemConfigKey.UserAliyunParams) or {}
def __update_params(self, params: dict):
"""
设置阿里云盘认证参数
"""
current_params = self.__auth_params
current_params.update(params)
self.systemconfig.set(SystemConfigKey.UserAliyunParams, current_params)
def __clear_params(self):
"""
清除阿里云盘认证参数
"""
self.systemconfig.delete(SystemConfigKey.UserAliyunParams)
def generate_qrcode(self) -> Optional[Tuple[dict, str]]:
"""
生成二维码
"""
res = RequestUtils(timeout=10).get_res(self.qrcode_url)
if res:
data = res.json().get("content", {}).get("data")
return {
"codeContent": data.get("codeContent"),
"ck": data.get("ck"),
"t": data.get("t")
}, ""
elif res is not None:
self.__handle_error(res, "生成二维码")
return {}, f"请求阿里云盘二维码失败:{res.status_code} - {res.reason}"
return {}, f"请求阿里云盘二维码失败:无法连接!"
def check_login(self, ck: str, t: str) -> Optional[Tuple[dict, str]]:
"""
二维码登录确认
"""
params = {
"t": t,
"ck": ck,
"appName": "aliyun_drive",
"appEntrance": "web",
"isMobile": "false",
"lang": "zh_CN",
"returnUrl": "",
"fromSite": "52",
"bizParams": "",
"navlanguage": "zh-CN",
"navPlatform": "MacIntel",
}
body = "&".join([f"{key}={value}" for key, value in params.items()])
status = {
"NEW": "请用阿里云盘 App 扫码",
"SCANED": "请在手机上确认",
"EXPIRED": "二维码已过期",
"CANCELED": "已取消",
"CONFIRMED": "已确认",
}
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
res = RequestUtils(headers=headers, timeout=5).post_res(self.check_url, data=body)
if res:
data = res.json().get("content", {}).get("data") or {}
qrCodeStatus = data.get("qrCodeStatus")
data["tip"] = status.get(qrCodeStatus) or "未知"
if data.get("bizExt"):
try:
bizExt = json.loads(base64.b64decode(data["bizExt"]).decode('GBK'))
pds_login_result = bizExt.get("pds_login_result")
if pds_login_result:
data.pop('bizExt')
data.update({
'userId': pds_login_result.get('userId'),
'expiresIn': pds_login_result.get('expiresIn'),
'nickName': pds_login_result.get('nickName'),
'avatar': pds_login_result.get('avatar'),
'tokenType': pds_login_result.get('tokenType'),
"refreshToken": pds_login_result.get('refreshToken'),
"accessToken": pds_login_result.get('accessToken'),
"defaultDriveId": pds_login_result.get('defaultDriveId'),
"updateTime": time.time(),
})
self.__update_params(data)
self.user_info()
except Exception as e:
return {}, f"bizExt 解码失败:{str(e)}"
return data, ""
elif res is not None:
self.__handle_error(res, "登录确认")
return {}, f"阿里云盘登录确认失败:{res.status_code} - {res.reason}"
return {}, "阿里云盘登录确认失败:无法连接!"
def __update_accesstoken(self, params: dict, refresh_token: str) -> bool:
"""
更新阿里云盘访问令牌
"""
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(
self.update_accessstoken_url, json={
"refresh_token": refresh_token,
"grant_type": "refresh_token"
})
if res:
data = res.json()
code = data.get("code")
if code in ["RefreshTokenExpired", "InvalidParameter.RefreshToken"]:
logger.warn("刷新令牌已过期,请重新登录!")
self.__clear_params()
return False
self.__update_params({
"accessToken": data.get('access_token'),
"expiresIn": data.get('expires_in'),
"updateTime": time.time()
})
logger.info(f"阿里云盘访问令牌已更新accessToken={data.get('access_token')}")
return True
else:
self.__handle_error(res, "更新令牌", action=False)
return False
def __create_session(self, headers: dict):
"""
创建会话
"""
def __os_name():
"""
获取操作系统名称
"""
if SystemUtils.is_windows():
return 'Windows 操作系统'
elif SystemUtils.is_macos():
return 'MacOS 操作系统'
else:
return '类 Unix 操作系统'
res = RequestUtils(headers=headers, timeout=5).post_res(self.create_session_url, json={
'deviceName': f'MoviePilot {SystemUtils.platform}',
'modelName': __os_name(),
'pubKey': self._X_PUBLIC_KEY,
})
self.__handle_error(res, "创建会话", action=False)
@property
def __access_params(self) -> Optional[dict]:
"""
获取阿里云盘访问参数,如果超时则更新后返回
"""
params = self.__auth_params
if not params:
logger.warn("阿里云盘访问令牌不存在,请先扫码登录!")
return None
expires_in = params.get("expiresIn")
update_time = params.get("updateTime")
refresh_token = params.get("refreshToken")
if not expires_in or not update_time or not refresh_token:
logger.warn("阿里云盘访问令牌参数错误,请重新扫码登录!")
self.__clear_params()
return None
# 是否需要更新设备信息
update_device = False
# 判断访问令牌是否过期
if (time.time() - update_time) >= expires_in:
logger.info("阿里云盘访问令牌已过期,正在更新...")
if not self.__update_accesstoken(params, refresh_token):
# 更新失败
return None
update_device = True
# 生成设备ID
x_device_id = params.get("x_device_id")
if not x_device_id:
x_device_id = uuid.uuid4().hex
params['x_device_id'] = x_device_id
self.__update_params({"x_device_id": x_device_id})
update_device = True
# 更新设备信息重新创建会话
if update_device:
self.__create_session(self.__get_headers(params))
return params
def __get_headers(self, params: dict):
"""
获取请求头
"""
if not params:
return {}
return {
"Authorization": f"Bearer {params.get('accessToken')}",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.alipan.com/",
"User-Agent": settings.USER_AGENT,
"X-Canary": "client=web,app=adrive,version=v4.9.0",
"x-device-id": params.get('x_device_id'),
"x-signature": self._X_SIGNATURE
}
def user_info(self) -> dict:
"""
获取用户信息drive_id等
"""
params = self.__access_params
if not params:
return {}
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.user_info_url)
if res:
result = res.json()
self.__update_params({
"resourceDriveId": result.get("resource_drive_id"),
"backDriveId": result.get("backup_drive_id")
})
return result
else:
self.__handle_error(res, "获取用户信息")
return {}
def list(self, drive_id: str = None, parent_file_id: str = 'root', list_type: str = None,
limit: int = 100, order_by: str = 'updated_at', path: str = "/") -> List[schemas.FileItem]:
"""
浏览文件
limit 返回文件数量,默认 50最大 100
order_by created_at/updated_at/name/size
parent_file_id 根目录为root
type all | file | folder
"""
params = self.__access_params
if not params:
return []
# 请求头
headers = self.__get_headers(params)
# 根目录处理
if not drive_id:
return [
schemas.FileItem(
fileid=parent_file_id,
drive_id=params.get("resourceDriveId"),
parent_fileid="root",
type="dir",
path="/资源库/",
name="资源库"
),
schemas.FileItem(
fileid=parent_file_id,
drive_id=params.get("backDriveId"),
parent_fileid="root",
type="dir",
path="/备份盘/",
name="备份盘"
)
]
# 返回数据
ret_items = []
# 分页获取
next_marker = None
while True:
if not parent_file_id or parent_file_id == "/":
parent_file_id = "root"
res = RequestUtils(headers=headers, timeout=10).post_res(self.list_file_url, json={
"drive_id": drive_id,
"type": list_type,
"limit": limit,
"order_by": order_by,
"parent_file_id": parent_file_id,
"marker": next_marker
}, params={
'jsonmask': ('next_marker,items(name,file_id,drive_id,type,size,created_at,updated_at,'
'category,file_extension,parent_file_id,mime_type,starred,thumbnail,url,'
'streams_info,content_hash,user_tags,user_meta,trashed,video_media_metadata,'
'video_preview_metadata,sync_meta,sync_device_flag,sync_flag,punish_flag')
})
if res:
result = res.json()
items = result.get("items")
if not items:
break
# 合并数据
ret_items.extend(items)
next_marker = result.get("next_marker")
if not next_marker:
# 没有下一页
break
else:
self.__handle_error(res, "浏览文件")
break
return [schemas.FileItem(
fileid=fileinfo.get("file_id"),
parent_fileid=fileinfo.get("parent_file_id"),
type="dir" if fileinfo.get("type") == "folder" else "file",
path=f"{path}{fileinfo.get('name')}" + ("/" if fileinfo.get("type") == "folder" else ""),
name=fileinfo.get("name"),
size=fileinfo.get("size"),
extension=fileinfo.get("file_extension"),
modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")),
thumbnail=fileinfo.get("thumbnail"),
drive_id=fileinfo.get("drive_id"),
) for fileinfo in ret_items]
def create_folder(self, drive_id: str, parent_file_id: str, name: str, path: str = "/") -> Optional[schemas.FileItem]:
"""
创建目录
"""
params = self.__access_params
if not params:
return None
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={
"drive_id": drive_id,
"parent_file_id": parent_file_id,
"name": name,
"check_name_mode": "refuse",
"type": "folder"
})
if res:
"""
{
"parent_file_id": "root",
"type": "folder",
"file_id": "6673f2c8a88344741bd64ad192d7512b92087719",
"domain_id": "bj29",
"drive_id": "39146740",
"file_name": "test",
"encrypt_mode": "none"
}
"""
result = res.json()
return schemas.FileItem(
fileid=result.get("file_id"),
drive_id=result.get("drive_id"),
parent_fileid=result.get("parent_file_id"),
type=result.get("type"),
name=result.get("file_name"),
path=f"{path}{result.get('file_name')}",
)
else:
self.__handle_error(res, "创建目录")
return None
def delete(self, drive_id: str, file_id: str) -> bool:
"""
删除文件
"""
params = self.__access_params
if not params:
return False
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.delete_file_url, json={
"drive_id": drive_id,
"file_id": file_id
})
if res:
return True
else:
self.__handle_error(res, "删除文件")
return False
def detail(self, drive_id: str, file_id: str, path: str = "/") -> Optional[schemas.FileItem]:
"""
获取文件详情
"""
params = self.__access_params
if not params:
return None
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.file_detail_url, json={
"drive_id": drive_id,
"file_id": file_id
})
if res:
result = res.json()
return schemas.FileItem(
fileid=result.get("file_id"),
drive_id=result.get("drive_id"),
parent_fileid=result.get("parent_file_id"),
type="file",
name=result.get("name"),
size=result.get("size"),
extension=result.get("file_extension"),
modify_time=StringUtils.str_to_timestamp(result.get("updated_at")),
thumbnail=result.get("thumbnail"),
path=f"{path}{result.get('name')}"
)
else:
self.__handle_error(res, "获取文件详情")
return None
def rename(self, drive_id: str, file_id: str, name: str) -> bool:
"""
重命名文件
"""
params = self.__access_params
if not params:
return False
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.rename_file_url, json={
"drive_id": drive_id,
"file_id": file_id,
"name": name,
"check_name_mode": "refuse"
})
if res:
return True
else:
self.__handle_error(res, "重命名文件")
return False
def download(self, drive_id: str, file_id: str) -> Optional[str]:
"""
获取下载链接
"""
params = self.__access_params
if not params:
return None
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.download_url, json={
"drive_id": drive_id,
"file_id": file_id
})
if res:
return res.json().get("url")
else:
self.__handle_error(res, "获取下载链接")
return None
def move(self, drive_id: str, file_id: str, target_id: str) -> bool:
"""
移动文件
"""
params = self.__access_params
if not params:
return False
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.move_file_url, json={
"drive_id": drive_id,
"file_id": file_id,
"to_parent_file_id": target_id,
"check_name_mode": "refuse"
})
if res:
return True
else:
self.__handle_error(res, "移动文件")
return False
def upload(self, drive_id: str, parent_file_id: str, file_path: Path) -> Optional[schemas.FileItem]:
"""
上传文件,并标记完成
"""
params = self.__access_params
if not params:
return None
headers = self.__get_headers(params)
res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={
"drive_id": drive_id,
"parent_file_id": parent_file_id,
"name": file_path.name,
"check_name_mode": "refuse",
"create_scene": "file_upload",
"type": "file",
"part_info_list": [
{
"part_number": 1
}
],
"size": file_path.stat().st_size
})
if not res:
self.__handle_error(res, "创建文件")
return None
# 获取上传参数
result = res.json()
if result.get("exist"):
logger.info(f"文件{result.get('file_name')}已存在,无需上传")
return schemas.FileItem(
drive_id=result.get("drive_id"),
fileid=result.get("file_id"),
parent_fileid=result.get("parent_file_id"),
type="file",
name=result.get("file_name"),
path=f"{file_path.parent}/{result.get('file_name')}"
)
file_id = result.get("file_id")
upload_id = result.get("upload_id")
part_info_list = result.get("part_info_list")
if part_info_list:
# 上传地址
upload_url = part_info_list[0].get("upload_url")
# 上传文件
res = RequestUtils(headers={
"Content-Type": "",
"User-Agent": settings.USER_AGENT,
"Referer": "https://www.alipan.com/",
"Accept": "*/*",
}).put_res(upload_url, data=file_path.read_bytes())
if not res:
self.__handle_error(res, "上传文件")
return None
# 标记文件上传完毕
res = RequestUtils(headers=headers, timeout=10).post_res(self.upload_file_complete_url, json={
"drive_id": drive_id,
"file_id": file_id,
"upload_id": upload_id
})
if not res:
self.__handle_error(res, "标记上传状态")
return None
result = res.json()
return schemas.FileItem(
fileid=result.get("file_id"),
drive_id=result.get("drive_id"),
parent_fileid=result.get("parent_file_id"),
type="file",
name=result.get("name"),
path=f"{file_path.parent}/{result.get('name')}",
)
else:
logger.warn("上传文件失败:无法获取上传地址!")
return None

164
app/helper/directory.py Normal file
View File

@@ -0,0 +1,164 @@
from pathlib import Path
from typing import List, Optional
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey, MediaType
from app.utils.system import SystemUtils
class DirectoryHelper:
"""
下载目录/媒体库目录帮助类
"""
def __init__(self):
self.systemconfig = SystemConfigOper()
def get_download_dirs(self) -> List[schemas.MediaDirectory]:
"""
获取下载目录
"""
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.DownloadDirectories)
if not dir_conf:
return []
return [schemas.MediaDirectory(**d) for d in dir_conf]
def get_library_dirs(self) -> List[schemas.MediaDirectory]:
"""
获取媒体库目录
"""
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.LibraryDirectories)
if not dir_conf:
return []
return [schemas.MediaDirectory(**d) for d in dir_conf]
def get_download_dir(self, media: MediaInfo = None, to_path: Path = None) -> Optional[schemas.MediaDirectory]:
"""
根据媒体信息获取下载目录
:param media: 媒体信息
:param to_path: 目标目录
"""
# 处理类型
if media:
media_type = media.type.value
else:
media_type = MediaType.UNKNOWN.value
download_dirs = self.get_download_dirs()
# 按照配置顺序查找(保存后的数据已经排序)
for download_dir in download_dirs:
if not download_dir.path:
continue
download_path = Path(download_dir.path)
# 有目标目录,但目标目录与当前目录不相等时不要
if to_path and download_path != to_path:
continue
# 目录类型为全部的,符合条件
if not download_dir.media_type:
return download_dir
# 目录类型相等,目录类别为全部,符合条件
if download_dir.media_type == media_type and not download_dir.category:
return download_dir
# 目录类型相等,目录类别相等,符合条件
if download_dir.media_type == media_type and download_dir.category == media.category:
return download_dir
return None
def get_library_dir(self, media: MediaInfo = None, in_path: Path = None,
to_path: Path = None) -> Optional[schemas.MediaDirectory]:
"""
根据媒体信息获取媒体库目录,需判断是否同盘优先
:param media: 媒体信息
:param in_path: 源目录
:param to_path: 目标目录
"""
def __comman_parts(path1: Path, path2: Path) -> int:
"""
计算两个路径的公共路径长度
"""
parts1 = path1.parts
parts2 = path2.parts
root_flag = parts1[0] == '/' and parts2[0] == '/'
length = min(len(parts1), len(parts2))
for i in range(length):
if parts1[i] == '/' and parts2[i] == '/':
continue
if parts1[i] != parts2[i]:
return i - 1 if root_flag else i
return length - 1 if root_flag else length
# 处理类型
if media:
media_type = media.type.value
else:
media_type = MediaType.UNKNOWN.value
# 匹配的目录
matched_dirs = []
library_dirs = self.get_library_dirs()
# 按照配置顺序查找(保存后的数据已经排序)
for library_dir in library_dirs:
if not library_dir.path:
continue
# 有目标目录,但目标目录与当前目录不相等时不要
if to_path and Path(library_dir.path) != to_path:
continue
# 目录类型为全部的,符合条件
if not library_dir.media_type:
matched_dirs.append(library_dir)
# 目录类型相等,目录类别为全部,符合条件
if library_dir.media_type == media_type and not library_dir.category:
matched_dirs.append(library_dir)
# 目录类型相等,目录类别相等,符合条件
if library_dir.media_type == media_type and library_dir.category == media.category:
matched_dirs.append(library_dir)
# 未匹配到
if not matched_dirs:
return None
# 没有目录则创建
for matched_dir in matched_dirs:
matched_path = Path(matched_dir.path)
if not matched_path.exists():
matched_path.mkdir(parents=True, exist_ok=True)
# 只匹配到一项
if len(matched_dirs) == 1:
return matched_dirs[0]
# 有源路径,且开启同盘/同目录优先时
if in_path and settings.TRANSFER_SAME_DISK:
# 优先同根路径
max_length = 0
target_dirs = []
for matched_dir in matched_dirs:
try:
# 计算in_path和path的公共路径长度
relative_len = __comman_parts(in_path, Path(matched_dir.path))
if relative_len and relative_len >= max_length:
max_length = relative_len
target_dirs.append({
'path': matched_dir,
'relative_len': relative_len
})
except Exception as e:
logger.debug(f"计算目标路径时出错:{str(e)}")
continue
if target_dirs:
target_dirs.sort(key=lambda x: x['relative_len'], reverse=True)
matched_dirs = [x['path'] for x in target_dirs]
# 优先同盘
for matched_dir in matched_dirs:
matched_path = Path(matched_dir.path)
if SystemUtils.is_same_disk(matched_path, in_path):
return matched_dir
# 返回最优先的匹配
return matched_dirs[0]

View File

@@ -15,16 +15,6 @@ from typing import Dict, Optional
from app.core.config import settings
from app.log import logger
# 定义一个全局集合来存储注册的主机
_registered_hosts = {
'api.themoviedb.org',
'api.tmdb.org',
'webservice.fanart.tv',
'api.github.com',
'github.com',
'raw.githubusercontent.com',
'api.telegram.org'
}
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
@@ -32,21 +22,13 @@ _executor = concurrent.futures.ThreadPoolExecutor()
# 定义默认的DoH配置
_doh_timeout = 5
_doh_cache: Dict[str, str] = {}
_doh_resolvers = [
# https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https
"1.0.0.1",
"1.1.1.1",
# https://support.quad9.net/hc/en-us
"9.9.9.9",
"149.112.112.112"
]
def _patched_getaddrinfo(host, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本。
"""
if host not in _registered_hosts:
if host not in settings.DOH_DOMAINS.split(","):
return _orig_getaddrinfo(host, *args, **kwargs)
# 检查主机是否已解析
@@ -57,7 +39,7 @@ def _patched_getaddrinfo(host, *args, **kwargs):
# 使用DoH解析主机
futures = []
for resolver in _doh_resolvers:
for resolver in settings.DOH_RESOLVERS.split(","):
futures.append(_executor.submit(_doh_query, resolver, host))
for future in concurrent.futures.as_completed(futures):

View File

@@ -82,7 +82,7 @@ class FormatParser(object):
return int(s) + self.__offset, int(e) + self.__offset, self.part
return self._start_ep + self.__offset, None, self.part
if not self._format:
return None, None, None
return self._start_ep, self._end_ep, self.part
s, e = self.__handle_single(file_name)
return s + self.__offset if s is not None else None, \
e + self.__offset if e is not None else None, self.part

View File

@@ -10,34 +10,54 @@ class MessageHelper(metaclass=Singleton):
"""
消息队列管理器,包括系统消息和用户消息
"""
def __init__(self):
self.sys_queue = queue.Queue()
self.user_queue = queue.Queue()
def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
def put(self, message: Any, role: str = "plugin", title: str = None, note: Union[list, dict] = None):
"""
存消息
:param message: 消息
:param role: 消息通道 sys/user
:param role: 消息通道 systm系统消息plugin插件消息user用户消息
:param title: 标题
:param note: 附件json
"""
if role == "sys":
self.sys_queue.put(message)
if role in ["system", "plugin"]:
# 没有标题时获取插件名称
if role == "plugin" and not title:
title = "插件通知"
# 系统通知,默认
self.sys_queue.put(json.dumps({
"type": role,
"title": title,
"text": message,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note
}))
else:
if isinstance(message, str):
self.user_queue.put(message)
# 非系统的文本通知
self.user_queue.put(json.dumps({
"title": title,
"text": message,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note
}))
elif hasattr(message, "to_dict"):
# 非系统的复杂结构通知,如媒体信息/种子列表等。
content = message.to_dict()
content['title'] = title
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
content['note'] = json.dumps(note) if note else None
content['note'] = note
self.user_queue.put(json.dumps(content))
def get(self, role: str = "sys") -> Optional[str]:
def get(self, role: str = "system") -> Optional[str]:
"""
取消息
:param role: 消息通道 sys/user
:param role: 消息通道 systm系统消息plugin插件消息user用户消息
"""
if role == "sys":
if role == "system":
if not self.sys_queue.empty():
return self.sys_queue.get(block=False)
else:

View File

@@ -13,12 +13,12 @@ class ModuleHelper:
"""
@classmethod
def load(cls, package_path, filter_func=lambda name, obj: True):
def load(cls, package_path: str, filter_func=lambda name, obj: True):
"""
导入模块
导入模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return:
:return: 导入的模块对象列表
"""
submodules: list = []
@@ -40,6 +40,58 @@ class ModuleHelper:
return submodules
@classmethod
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True):
"""
导入子模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return: 导入的模块对象列表
"""
submodules: list = []
packages = importlib.import_module(package_path)
def reload_module_objects(target_module):
"""加载模块并返回对象"""
importlib.reload(target_module)
# reload后重新过滤已经重新加载后的模块中的对象
return [
obj for name, obj in target_module.__dict__.items()
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)
]
def reload_sub_modules(parent_module, parent_module_name):
"""重新加载一级子模块"""
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__):
full_sub_module_name = f'{parent_module_name}.{sub_module_name}'
try:
full_sub_module = importlib.import_module(full_sub_module_name)
importlib.reload(full_sub_module)
except Exception as sub_err:
logger.debug(f'加载子模块 {full_sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
# 遍历包中的所有子模块
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
try:
module = importlib.import_module(full_package_name)
# 预检查模块中的对象
candidates = [(name, obj) for name, obj in module.__dict__.items() if
not name.startswith('_') and isinstance(obj, type)]
# 确定是否需要重新加载
if any(filter_func(name, obj) for name, obj in candidates):
# 如果子模块是包,重新加载其子模块
if is_pkg:
reload_sub_modules(module, full_package_name)
submodules.extend(reload_module_objects(module))
except Exception as err:
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
return submodules
@staticmethod
def dynamic_import_all_modules(base_path: Path, package_name: str):
"""

View File

@@ -20,13 +20,13 @@ class PluginHelper(metaclass=Singleton):
插件市场管理,下载安装插件到本地
"""
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
_base_url = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/%s/%s/main/"
_install_reg = "https://movie-pilot.org/plugin/install/%s"
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
_install_report = "https://movie-pilot.org/plugin/install"
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
_install_statistic = "https://movie-pilot.org/plugin/statistic"
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
def __init__(self):
self.systemconfig = SystemConfigOper()
@@ -35,6 +35,10 @@ class PluginHelper(metaclass=Singleton):
if self.install_report():
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
@property
def proxies(self):
return None if settings.GITHUB_PROXY else settings.PROXY
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
"""
@@ -47,7 +51,8 @@ class PluginHelper(metaclass=Singleton):
if not user or not repo:
return {}
raw_url = self._base_url % (user, repo)
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
res = RequestUtils(proxies=self.proxies,
headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"),
timeout=10).get_res(f"{raw_url}package.json")
if res:
try:
@@ -133,12 +138,16 @@ class PluginHelper(metaclass=Singleton):
if not user or not repo:
return False, "不支持的插件仓库地址格式"
user_repo = f"{user}/{repo}"
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
"""
获取插件的文件列表
"""
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p}"
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{_p}"
r = RequestUtils(proxies=settings.PROXY,
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
timeout=30).get_res(file_api)
if r is None:
return None, "连接仓库失败"
elif r.status_code != 200:
@@ -157,9 +166,11 @@ class PluginHelper(metaclass=Singleton):
return False, "文件列表为空"
for item in _l:
if item.get("download_url"):
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载插件文件
res = RequestUtils(proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
res = RequestUtils(proxies=self.proxies,
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
timeout=60).get_res(download_url)
if not res:
return False, f"文件 {item.get('name')} 下载失败!"
elif res.status_code != 200:

View File

@@ -34,7 +34,11 @@ class ProgressHelper(metaclass=Singleton):
key = key.value
if not self._process_detail.get(key):
return
self._process_detail[key]['enable'] = False
self._process_detail[key] = {
"enable": False,
"value": 100,
"text": "正在处理..."
}
def update(self, key: Union[ProgressKey, str], value: float = None, text: str = None):
if isinstance(key, Enum):

View File

@@ -15,7 +15,7 @@ class ResourceHelper(metaclass=Singleton):
检测和更新资源包
"""
# 资源包的git仓库地址
_repo = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
_repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
_base_dir: Path = settings.ROOT_PATH
@@ -23,6 +23,10 @@ class ResourceHelper(metaclass=Singleton):
self.siteshelper = SitesHelper()
self.check()
@property
def proxies(self):
return None if settings.GITHUB_PROXY else settings.PROXY
def check(self):
"""
检测是否有更新,如有则下载安装
@@ -32,7 +36,7 @@ class ResourceHelper(metaclass=Singleton):
if SystemUtils.is_frozen():
return
logger.info("开始检测资源包版本...")
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
if res:
try:
resource_info = json.loads(res.text)
@@ -86,9 +90,11 @@ class ResourceHelper(metaclass=Singleton):
if not save_path:
continue
if item.get("download_url"):
logger.info(f"开始更新资源文件:{item.get('name')} ...")
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载资源文件
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(item["download_url"])
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(download_url)
if not res:
logger.error(f"文件 {item.get('name')} 下载失败!")
elif res.status_code != 200:

View File

@@ -16,11 +16,13 @@ class SubscribeHelper(metaclass=Singleton):
订阅数据统计
"""
_sub_reg = "https://movie-pilot.org/subscribe/add"
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
_sub_report = "https://movie-pilot.org/subscribe/report"
_sub_done = f"{settings.MP_SERVER_HOST}/subscribe/done"
_sub_statistic = "https://movie-pilot.org/subscribe/statistic"
_sub_report = f"{settings.MP_SERVER_HOST}/subscribe/report"
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
def __init__(self):
self.systemconfig = SystemConfigOper()
@@ -58,6 +60,19 @@ class SubscribeHelper(metaclass=Singleton):
return True
return False
def sub_done(self, sub: dict) -> bool:
"""
完成订阅统计
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = RequestUtils(timeout=5, headers={
"Content-Type": "application/json"
}).post_res(self._sub_done, json=sub)
if res and res.status_code == 200:
return True
return False
def sub_reg_async(self, sub: dict) -> bool:
"""
异步新增订阅统计
@@ -66,6 +81,14 @@ class SubscribeHelper(metaclass=Singleton):
Thread(target=self.sub_reg, args=(sub,)).start()
return True
def sub_done_async(self, sub: dict) -> bool:
"""
异步完成订阅统计
"""
# 开新线程处理
Thread(target=self.sub_done, args=(sub,)).start()
return True
def sub_report(self) -> bool:
"""
上报存量订阅统计

View File

@@ -428,15 +428,23 @@ class TorrentHelper(metaclass=Singleton):
return True
@staticmethod
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo,
torrent: TorrentInfo, logerror: bool = True) -> bool:
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
"""
检查种子是否匹配媒体信息
:param mediainfo: 需要匹配的媒体信息
:param torrent_meta: 种子识别信息
:param torrent: 种子信息
:param logerror: 是否记录错误日志
"""
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(
f'{mediainfo.title} 通过词表指定TMDBID匹配到资源{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
logger.info(
f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源{torrent.site_name} - {torrent.title}')
return True
# 要匹配的媒体标题、原标题
media_titles = {
StringUtils.clear_upper(mediainfo.title),
@@ -451,32 +459,28 @@ class TorrentHelper(metaclass=Singleton):
} - {""}
# 比对种子识别类型
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value}'
f'不匹配 {mediainfo.type.value}')
logger.debug(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value}'
f'不匹配 {mediainfo.type.value}')
return False
# 比对种子在站点中的类型
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category}'
f'不匹配 {mediainfo.type.value}')
logger.debug(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category}'
f'不匹配 {mediainfo.type.value}')
return False
# 比对年份
if mediainfo.year:
if mediainfo.type == MediaType.TV:
# 剧集年份,每季的年份可能不同
# 剧集年份,每季的年份可能不同,没年份时不比较年份(很多剧集种子不带年份)
if torrent_meta.year and torrent_meta.year not in [year for year in
mediainfo.season_years.values()]:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
return False
else:
# 电影年份上下浮动1年
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
# 电影年份上下浮动1年,没年份时不通过
if not torrent_meta.year or torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
return False
# 比对标题和原语种标题
if meta_names.intersection(media_titles):
@@ -489,21 +493,24 @@ class TorrentHelper(metaclass=Singleton):
return True
# 标题拆分
if torrent_meta.org_string:
titles = [StringUtils.clear_upper(t) for t in re.split(r'[\s/【】.\[\]\-]+',
torrent_meta.org_string) if t]
# 只拆分出标题中的非英文单词进行匹配,英文单词容易误匹配(带空格的多个单词组合除外)
titles = [StringUtils.clear_upper(t) for t in re.split(
r'[\s/【】.\[\]\-]+',
torrent_meta.org_string
) if not StringUtils.is_english_word(t)]
# 在标题中判断是否存在标题、原语种标题
if media_titles.intersection(titles):
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 在副标题中判断是否存在标题、原语种标题、别名、译名
# 在副标题中(非英文单词)判断是否存在标题、原语种标题、别名、译名
if torrent.description:
subtitles = {StringUtils.clear_upper(t) for t in re.split(r'[\s/|]+',
torrent.description) if t}
subtitles = {StringUtils.clear_upper(t) for t in re.split(
r'[\s/【】|]+',
torrent.description) if not StringUtils.is_english_word(t)}
if media_titles.intersection(subtitles) or media_names.intersection(subtitles):
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title}'
f'副标题:{torrent.description}')
return True
# 未匹配
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
return False

281
app/helper/u115.py Normal file
View File

@@ -0,0 +1,281 @@
import base64
from pathlib import Path
from typing import Optional, Tuple, List
import oss2
import py115
from py115 import Cloud
from py115.types import LoginTarget, QrcodeSession, QrcodeStatus, Credential, DownloadTicket
from app import schemas
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton
class U115Helper(metaclass=Singleton):
"""
115相关操作
"""
cloud: Optional[Cloud] = None
_session: QrcodeSession = None
def __init__(self):
self.systemconfig = SystemConfigOper()
def __init_cloud(self) -> bool:
"""
初始化Cloud
"""
credential = self.__credential
if not credential:
logger.warn("115未登录请先登录")
return False
try:
if not self.cloud:
self.cloud = py115.connect(credential)
except Exception as err:
logger.error(f"115连接失败请重新扫码登录{str(err)}")
self.__clear_credential()
return False
return True
@property
def __credential(self) -> Optional[Credential]:
"""
获取已保存的115认证参数
"""
cookie_dict = self.systemconfig.get(SystemConfigKey.User115Params)
if not cookie_dict:
return None
return Credential.from_dict(cookie_dict)
def __save_credentail(self, credential: Credential):
"""
设置115认证参数
"""
self.systemconfig.set(SystemConfigKey.User115Params, credential.to_dict())
def __clear_credential(self):
"""
清除115认证参数
"""
self.systemconfig.delete(SystemConfigKey.User115Params)
def generate_qrcode(self) -> Optional[str]:
"""
生成二维码
"""
try:
self.cloud = py115.connect()
self._session = self.cloud.qrcode_login(LoginTarget.Web)
image_bin = self._session.image_data
if not image_bin:
logger.warn("115生成二维码失败未获取到二维码数据")
return None
# 转换为base64图片格式
image_base64 = base64.b64encode(image_bin).decode()
return f"data:image/png;base64,{image_base64}"
except Exception as e:
logger.warn(f"115生成二维码失败{str(e)}")
return None
def check_login(self) -> Optional[Tuple[dict, str]]:
"""
二维码登录确认
"""
if not self._session:
return {}, "请先生成二维码!"
try:
if not self.cloud:
return {}, "请先生成二维码!"
status = self.cloud.qrcode_poll(self._session)
if status == QrcodeStatus.Done:
# 确认完成,保存认证信息
self.__save_credentail(self.cloud.export_credentail())
result = {
"status": 1,
"tip": "登录成功!"
}
elif status == QrcodeStatus.Waiting:
result = {
"status": 0,
"tip": "请使用微信或115客户端扫码"
}
elif status == QrcodeStatus.Expired:
result = {
"status": -1,
"tip": "二维码已过期,请重新刷新!"
}
self.cloud = None
elif status == QrcodeStatus.Failed:
result = {
"status": -2,
"tip": "登录失败,请重试!"
}
self.cloud = None
else:
result = {
"status": -3,
"tip": "未知错误,请重试!"
}
self.cloud = None
return result, ""
except Exception as e:
return {}, f"115登录确认失败{str(e)}"
def storage(self) -> Optional[Tuple[int, int]]:
"""
获取存储空间
"""
if not self.__init_cloud():
return None
try:
return self.cloud.storage().space()
except Exception as e:
logger.error(f"获取115存储空间失败{str(e)}")
return None
def list(self, parent_file_id: str = '0', path: str = "/") -> Optional[List[schemas.FileItem]]:
"""
浏览文件
"""
if not self.__init_cloud():
return None
try:
items = self.cloud.storage().list(dir_id=parent_file_id)
return [schemas.FileItem(
fileid=item.file_id,
parent_fileid=item.parent_id,
type="dir" if item.is_dir else "file",
path=f"{path}{item.name}" + ("/" if item.is_dir else ""),
name=item.name,
size=item.size,
extension=Path(item.name).suffix[1:],
modify_time=item.modified_time.timestamp() if item.modified_time else 0,
pickcode=item.pickcode
) for item in items]
except Exception as e:
logger.error(f"浏览115文件失败{str(e)}")
return None
def create_folder(self, parent_file_id: str, name: str, path: str = "/") -> Optional[schemas.FileItem]:
"""
创建目录
"""
if not self.__init_cloud():
return None
try:
result = self.cloud.storage().make_dir(parent_file_id, name)
return schemas.FileItem(
fileid=result.file_id,
parent_fileid=result.parent_id,
type="dir",
path=f"{path}{name}/",
name=name,
modify_time=result.modified_time.timestamp() if result.modified_time else 0,
pickcode=result.pickcode
)
except Exception as e:
logger.error(f"创建115目录失败{str(e)}")
return None
def delete(self, file_id: str) -> bool:
"""
删除文件
"""
if not self.__init_cloud():
return False
try:
self.cloud.storage().delete(file_id)
return True
except Exception as e:
logger.error(f"删除115文件失败{str(e)}")
return False
def rename(self, file_id: str, name: str) -> bool:
"""
重命名文件
"""
if not self.__init_cloud():
return False
try:
self.cloud.storage().rename(file_id, name)
return True
except Exception as e:
logger.error(f"重命名115文件失败{str(e)}")
return False
def download(self, pickcode: str) -> Optional[DownloadTicket]:
"""
获取下载链接
"""
if not self.__init_cloud():
return None
try:
return self.cloud.storage().request_download(pickcode)
except Exception as e:
logger.error(f"115下载失败{str(e)}")
return None
def move(self, file_id: str, target_id: str) -> bool:
"""
移动文件
"""
if not self.__init_cloud():
return False
try:
self.cloud.storage().move(file_id, target_id)
return True
except Exception as e:
logger.error(f"移动115文件失败{str(e)}")
return False
def upload(self, parent_file_id: str, file_path: Path) -> Optional[schemas.FileItem]:
"""
上传文件
"""
if not self.__init_cloud():
return None
try:
ticket = self.cloud.storage().request_upload(dir_id=parent_file_id, file_path=str(file_path))
if ticket is None:
logger.warn(f"115请求上传出错")
return None
elif ticket.is_done:
logger.warn(f"115请求上传失败文件已存在")
return {}
else:
auth = oss2.StsAuth(**ticket.oss_token)
bucket = oss2.Bucket(
auth=auth,
endpoint=ticket.oss_endpoint,
bucket_name=ticket.bucket_name,
)
por = bucket.put_object_from_file(
key=ticket.object_key,
filename=str(file_path),
headers=ticket.headers,
)
result = por.resp.response.json()
if result:
fileitem = result.get('data')
logger.info(f"115上传文件成功{fileitem}")
return schemas.FileItem(
fileid=fileitem.get('file_id'),
parent_fileid=parent_file_id,
type="file",
name=fileitem.get('file_name'),
path=f"{file_path / fileitem.get('file_name')}",
size=fileitem.get('file_size'),
extension=Path(fileitem.get('file_name')).suffix[1:],
pickcode=fileitem.get('pickcode')
)
else:
logger.warn(f"115上传文件失败{por.resp.response.text}")
return None
except Exception as e:
logger.error(f"上传115文件失败{str(e)}")
return None

View File

@@ -98,7 +98,6 @@ class LoggerManager:
# 终端日志
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = CustomFormatter(f"%(leveltext)s%(message)s")
console_handler.setFormatter(console_formatter)
_logger.addHandler(console_handler)
@@ -109,7 +108,6 @@ class LoggerManager:
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_formater = CustomFormatter(f"【%(levelname)s】%(asctime)s - %(message)s")
file_handler.setFormatter(file_formater)
_logger.addHandler(file_handler)
@@ -138,6 +136,7 @@ class LoggerManager:
if not _logger:
_logger = self.__setup_logger(logfile)
self._loggers[logfile] = _logger
# 调用logger的方法打印日志
if hasattr(_logger, method):
method = getattr(_logger, method)
method(f"{caller_name} - {msg}", *args, **kwargs)

View File

@@ -1,7 +1,9 @@
import multiprocessing
import os
import signal
import sys
import threading
from types import FrameType
import uvicorn as uvicorn
from PIL import Image
@@ -16,14 +18,22 @@ if SystemUtils.is_frozen():
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.module import ModuleManager
# SitesHelper涉及资源包拉取提前引入并容错提示
try:
from app.helper.sites import SitesHelper
except ImportError as e:
error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源"
print(error_message, file=sys.stderr)
sys.exit(1)
from app.core.plugin import PluginManager
from app.db.init import init_db, update_db, init_super_user
from app.helper.thread import ThreadHelper
from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper
from app.helper.sites import SitesHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.command import Command, CommandChian
@@ -149,16 +159,34 @@ def check_auth():
"""
if SitesHelper().auth_level < 2:
err_msg = "用户认证失败,站点相关功能将无法使用!"
MessageHelper().put(f"注意:{err_msg}")
MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system")
CommandChian().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证",
text=err_msg
text=err_msg,
link=settings.MP_DOMAIN('#/site')
)
)
def singal_handle():
"""
监听停止信号
"""
def stop_event(signum: int, _: FrameType):
"""
SIGTERM信号处理
"""
print(f"接收到停止信号:{signum},正在停止系统...")
global_vars.stop_system()
# 设置信号处理程序
signal.signal(signal.SIGTERM, stop_event)
signal.signal(signal.SIGINT, stop_event)
@App.on_event("shutdown")
def shutdown_server():
"""
@@ -168,6 +196,7 @@ def shutdown_server():
ModuleManager().stop()
# 停止插件
PluginManager().stop()
PluginManager().stop_monitor()
# 停止事件消费
Command().stop()
# 停止虚拟显示
@@ -209,6 +238,8 @@ def start_module():
start_frontend()
# 检查认证状态
check_auth()
# 监听停止信号
singal_handle()
if __name__ == '__main__':

View File

@@ -27,6 +27,14 @@ class _ModuleBase(metaclass=ABCMeta):
"""
pass
@staticmethod
@abstractmethod
def get_name() -> str:
"""
获取模块名称
"""
pass
@abstractmethod
def stop(self) -> None:
"""
@@ -69,6 +77,8 @@ def checkMessage(channel_type: MessageChannel):
return None
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
return None
if channel_type == MessageChannel.WebPush and not switch.get("webpush"):
return None
return func(self, message, *args, **kwargs)
return wrapper

View File

@@ -23,16 +23,20 @@ class BangumiModule(_ModuleBase):
"""
测试模块连接性
"""
with RequestUtils().get_res("https://api.bgm.tv/") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接Bangumi错误码{ret.status_code}"
ret = RequestUtils().get_res("https://api.bgm.tv/")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接Bangumi错误码{ret.status_code}"
return False, "Bangumi网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def get_name() -> str:
return "Bangumi"
def recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
"""

View File

@@ -15,6 +15,7 @@ from app.modules.douban.apiv2 import DoubanApi
from app.modules.douban.douban_cache import DoubanCache
from app.modules.douban.scraper import DoubanScraper
from app.schemas import MediaPerson
from app.schemas.exception import APIRateLimitException
from app.schemas.types import MediaType
from app.utils.common import retry
from app.utils.http import RequestUtils
@@ -38,16 +39,20 @@ class DoubanModule(_ModuleBase):
"""
测试模块连接性
"""
with RequestUtils().get_res("https://movie.douban.com/") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
ret = RequestUtils().get_res("https://movie.douban.com/")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
return False, "豆瓣网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def get_name() -> str:
return "豆瓣"
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: str = None,
@@ -143,11 +148,12 @@ class DoubanModule(_ModuleBase):
return None
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:param mtype: 媒体类型
:param raise_exception: 触发速率限制时是否抛出异常
:return: 豆瓣信息
"""
"""
@@ -422,6 +428,12 @@ class DoubanModule(_ModuleBase):
"""
info = self.doubanapi.tv_detail(doubanid)
if info:
if "subject_ip_rate_limit" in info.get("msg", ""):
msg = f"触发豆瓣IP速率限制错误信息{info} ..."
logger.warn(msg)
if raise_exception:
raise APIRateLimitException(msg)
return None
celebrities = self.doubanapi.tv_celebrities(doubanid)
if celebrities:
info["directors"] = celebrities.get("directors")
@@ -434,6 +446,12 @@ class DoubanModule(_ModuleBase):
"""
info = self.doubanapi.movie_detail(doubanid)
if info:
if "subject_ip_rate_limit" in info.get("msg", ""):
msg = f"触发豆瓣IP速率限制错误信息{info} ..."
logger.warn(msg)
if raise_exception:
raise APIRateLimitException(msg)
return None
celebrities = self.doubanapi.movie_celebrities(doubanid)
if celebrities:
info["directors"] = celebrities.get("directors")
@@ -468,7 +486,7 @@ class DoubanModule(_ModuleBase):
else:
infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count,
sort=sort, tags=tags)
if infos:
if infos and infos.get("items"):
medias = [MediaInfo(douban_info=info) for info in infos.get("items")]
return [media for media in medias if media.poster_path
and "movie_large.jpg" not in media.poster_path
@@ -484,7 +502,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.movie_showing(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -504,7 +522,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -514,7 +532,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -524,7 +542,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -534,7 +552,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.tv_hot(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -549,7 +567,7 @@ class DoubanModule(_ModuleBase):
if not meta.name:
return []
result = self.doubanapi.search(meta.name)
if not result:
if not result or not result.get("items"):
return []
# 返回数据
ret_medias = []
@@ -591,7 +609,8 @@ class DoubanModule(_ModuleBase):
@retry(Exception, 5, 3, 3, logger=logger)
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
mtype: MediaType = None, year: str = None, season: int = None,
raise_exception: bool = False) -> dict:
"""
搜索和匹配豆瓣信息
:param name: 名称
@@ -599,6 +618,7 @@ class DoubanModule(_ModuleBase):
:param mtype: 类型
:param year: 年份
:param season: 季号
:param raise_exception: 触发速率限制时是否抛出异常
"""
if imdbid:
# 优先使用IMDBID查询
@@ -619,8 +639,14 @@ class DoubanModule(_ModuleBase):
return {}
# 触发rate limit
if "search_access_rate_limit" in result.values():
logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...")
raise Exception("触发豆瓣API速率限制")
msg = f"触发豆瓣API速率限制错误信息{result} ..."
logger.warn(msg)
if raise_exception:
raise APIRateLimitException(msg)
return {}
if not result.get("items"):
logger.warn(f"未找到 {name} 的豆瓣信息")
return {}
for item_obj in result.get("items"):
type_name = item_obj.get("type_name")
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
@@ -650,7 +676,7 @@ class DoubanModule(_ModuleBase):
"""
infos = self.doubanapi.movie_top250(start=(page - 1) * count,
count=count)
if infos:
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
@@ -755,6 +781,26 @@ class DoubanModule(_ModuleBase):
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
logger.info(f"{path} 刮削完成")
def metadata_nfo(self, mediainfo: MediaInfo, season: int = None, **kwargs) -> Optional[str]:
"""
获取NFO文件内容文本
:param mediainfo: 媒体信息
:param season: 季号
"""
if settings.SCRAP_SOURCE != "douban":
return None
return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season)
def metadata_img(self, mediainfo: MediaInfo, season: int = None) -> Optional[dict]:
"""
获取图片名称和url
:param mediainfo: 媒体信息
:param season: 季号
"""
if settings.SCRAP_SOURCE != "douban":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season)
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
补充抓取媒体信息图片

View File

@@ -195,13 +195,13 @@ class DoubanApi(metaclass=Singleton):
'_ts': ts,
'_sig': self.__sign(url=req_url, ts=ts)
})
with RequestUtils(
ua=choice(self._user_agents),
session=self._session
).get_res(url=req_url, params=params) as resp:
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}
resp = RequestUtils(
ua=choice(self._user_agents),
session=self._session
).get_res(url=req_url, params=params)
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
def __post(self, url: str, **kwargs) -> dict:
@@ -228,7 +228,7 @@ class DoubanApi(metaclass=Singleton):
ua=settings.USER_AGENT,
session=self._session,
).post_res(url=req_url, data=params)
if resp.status_code == 400 and "rate_limit" in resp.text:
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Union
from typing import Union, Optional
from xml.dom import minidom
from app.core.config import settings
@@ -17,6 +17,44 @@ class DoubanScraper:
_force_nfo = False
_force_img = False
def get_metadata_nfo(self, mediainfo: MediaInfo, season: int = None) -> Optional[str]:
"""
获取NFO文件内容文本
:param mediainfo: 媒体信息
:param season: 季号
"""
if mediainfo.type == MediaType.MOVIE:
# 电影元数据文件
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
else:
if season:
# 季元数据文件
doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season)
else:
# 电视剧元数据文件
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
if doc:
return doc.toprettyxml(indent=" ", encoding="utf-8")
return None
@staticmethod
def get_metadata_img(mediainfo: MediaInfo, season: int = None) -> Optional[dict]:
"""
获取图片内容
:param mediainfo: 媒体信息
:param season: 季号
"""
ret_dict = {}
if season:
# 豆瓣无季图片
return {}
if mediainfo.poster_path:
ret_dict[f"poster{Path(mediainfo.poster_path).suffix}"] = mediainfo.poster_path
if mediainfo.backdrop_path:
ret_dict[f"backdrop{Path(mediainfo.backdrop_path).suffix}"] = mediainfo.backdrop_path
return ret_dict
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
file_path: Path, transfer_type: str,
force_nfo: bool = False, force_img: bool = False):
@@ -47,15 +85,11 @@ class DoubanScraper:
self.__gen_movie_nfo_file(mediainfo=mediainfo,
file_path=file_path)
# 生成电影图片
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.poster_path,
file_path=image_path)
# 背景图
if mediainfo.backdrop_path:
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
image_dict = self.get_metadata_img(mediainfo)
for img_name, img_url in image_dict.items():
image_path = file_path.with_name(img_name)
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.backdrop_path,
self.__save_image(url=img_url,
file_path=image_path)
# 电视剧
else:
@@ -65,15 +99,11 @@ class DoubanScraper:
self.__gen_tv_nfo_file(mediainfo=mediainfo,
dir_path=file_path.parents[1])
# 生成根目录图片
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.poster_path,
file_path=image_path)
# 背景图
if mediainfo.backdrop_path:
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
image_dict = self.get_metadata_img(mediainfo)
for img_name, img_url in image_dict.items():
image_path = file_path.with_name(img_name)
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.backdrop_path,
self.__save_image(url=img_url,
file_path=image_path)
# 季目录NFO
if self._force_nfo or not file_path.with_name("season.nfo").exists():
@@ -84,7 +114,7 @@ class DoubanScraper:
logger.error(f"{file_path} 刮削失败:{str(e)}")
@staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Node):
# 简介
xplot = DomUtils.add_node(doc, root, "plot")
xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
@@ -108,14 +138,15 @@ class DoubanScraper:
def __gen_movie_nfo_file(self,
mediainfo: MediaInfo,
file_path: Path):
file_path: Path = None) -> minidom.Document:
"""
生成电影的NFO描述文件
:param mediainfo: 豆瓣信息
:param file_path: 电影文件路径
"""
# 开始生成XML
logger.info(f"正在生成电影NFO文件{file_path.name}")
if file_path:
logger.info(f"正在生成电影NFO文件{file_path.name}")
doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "movie")
# 公共部分
@@ -127,11 +158,14 @@ class DoubanScraper:
# 年份
DomUtils.add_node(doc, root, "year", mediainfo.year or "")
# 保存
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
if file_path:
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
return doc
def __gen_tv_nfo_file(self,
mediainfo: MediaInfo,
dir_path: Path):
dir_path: Path = None) -> minidom.Document:
"""
生成电视剧的NFO描述文件
:param mediainfo: 媒体信息
@@ -152,9 +186,13 @@ class DoubanScraper:
DomUtils.add_node(doc, root, "season", "-1")
DomUtils.add_node(doc, root, "episode", "-1")
# 保存
self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo"))
if dir_path:
self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo"))
def __gen_tv_season_nfo_file(self, mediainfo: MediaInfo, season: int, season_path: Path):
return doc
def __gen_tv_season_nfo_file(self, mediainfo: MediaInfo,
season: int, season_path: Path = None) -> minidom.Document:
"""
生成电视剧季的NFO描述文件
:param mediainfo: 媒体信息
@@ -179,7 +217,9 @@ class DoubanScraper:
# seasonnumber
DomUtils.add_node(doc, root, "seasonnumber", str(season))
# 保存
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
if season_path:
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
return doc
def __save_image(self, url: str, file_path: Path):
"""
@@ -193,15 +233,15 @@ class DoubanScraper:
url = url.replace("/format/webp", "/format/jpg")
file_path.with_suffix(".jpg")
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
with RequestUtils().get_res(url=url) as r:
if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)
else:
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
r = RequestUtils().get_res(url=url)
if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")

View File

@@ -14,6 +14,10 @@ class EmbyModule(_ModuleBase):
def init_module(self) -> None:
self.emby = Emby()
@staticmethod
def get_name() -> str:
return "Emby"
def stop(self):
pass

View File

@@ -18,16 +18,10 @@ class Emby:
def __init__(self):
self._host = settings.EMBY_HOST
if self._host:
if not self._host.endswith("/"):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._host = RequestUtils.standardize_base_url(self._host)
self._playhost = settings.EMBY_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._playhost = RequestUtils.standardize_base_url(self._playhost)
self._apikey = settings.EMBY_API_KEY
self.user = self.get_user(settings.SUPERUSER)
self.folders = self.get_emby_folders()
@@ -56,12 +50,12 @@ class Emby:
return []
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
@@ -74,29 +68,29 @@ class Emby:
return []
req_url = "%semby/Library/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
res = RequestUtils().get_res(req_url)
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
return []
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
return []
@@ -113,12 +107,12 @@ class Emby:
user = self.user
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Items")
else:
logger.error(f"User/Views 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Items")
else:
logger.error(f"User/Views 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接User/Views 出错:" + str(e))
return []
@@ -164,20 +158,20 @@ class Emby:
return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
res = RequestUtils().get_res(req_url)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
if user.get("Name") == user_name:
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
# 查询管理员
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return None
@@ -227,11 +221,11 @@ class Emby:
return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
except Exception as e:
logger.error(f"连接System/Info出错" + str(e))
@@ -245,12 +239,12 @@ class Emby:
return 0
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("TotalRecordCount")
else:
logger.error(f"Users/Query 未获取到返回数据")
return 0
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("TotalRecordCount")
else:
logger.error(f"Users/Query 未获取到返回数据")
return 0
except Exception as e:
logger.error(f"连接Users/Query出错" + str(e))
return 0
@@ -264,17 +258,17 @@ class Emby:
return schemas.Statistic()
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
res = RequestUtils().get_res(req_url)
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic()
@@ -299,14 +293,14 @@ class Emby:
"&api_key=%s") % (
self._host, name, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -329,36 +323,36 @@ class Emby:
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
self._host, title, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for res_item in res_items:
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="emby",
library=res_item.get("ParentId"),
item_id=res_item.get("Id"),
item_type=res_item.get("Type"),
title=res_item.get("Name"),
original_title=res_item.get("OriginalTitle"),
year=res_item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
path=res_item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
continue
if (mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))):
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for res_item in res_items:
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="emby",
library=res_item.get("ParentId"),
item_id=res_item.get("Id"),
item_type=res_item.get("Type"),
title=res_item.get("Name"),
original_title=res_item.get("OriginalTitle"),
year=res_item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
path=res_item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
return ret_movies
continue
if (mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))):
ret_movies.append(mediaserver_item)
return ret_movies
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -401,25 +395,25 @@ class Emby:
try:
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
self._host, item_id, season, self._apikey)
with RequestUtils().get_res(req_url) as res_json:
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in season_episodes:
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
# 返回
return item_id, season_episodes
res_json = RequestUtils().get_res(req_url)
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in season_episodes:
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
# 返回
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -463,13 +457,13 @@ class Emby:
req_url = "%sItems/%s/Images/%s" % (self._playhost, item_id, image_type)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
res = RequestUtils().get_res(req_url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
except Exception as e:
logger.error(f"连接Items/Id/Images出错" + str(e))
return None
@@ -482,11 +476,11 @@ class Emby:
return False
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby")
except Exception as e:
logger.error(f"连接Items/Id/Refresh出错" + str(e))
return False
@@ -500,11 +494,11 @@ class Emby:
return False
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Emby")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Emby")
except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e))
return False
@@ -579,23 +573,23 @@ class Emby:
return None
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="emby",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="emby",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
except Exception as e:
logger.error(f"连接Items/Id出错" + str(e))
return None
@@ -610,17 +604,17 @@ class Emby:
yield None
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')):
yield item
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield None
@@ -1032,52 +1026,52 @@ class Emby:
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
else:
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
image_tag=item.get("SeriesPrimaryImageTag"))
if not image:
image = self.__get_local_image_by_id(item.get("SeriesId"))
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
image = self.__get_local_image_by_id(item.get("Id"))
else:
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
image_tag=item.get("SeriesPrimaryImageTag"))
if not image:
image = self.__get_local_image_by_id(item.get("SeriesId"))
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
@@ -1095,35 +1089,35 @@ class Emby:
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []

View File

@@ -321,16 +321,20 @@ class FanartModule(_ModuleBase):
"""
测试模块连接性
"""
with RequestUtils().get_res("https://webservice.fanart.tv") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接fanart错误码{ret.status_code}"
ret = RequestUtils().get_res("https://webservice.fanart.tv")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接fanart错误码{ret.status_code}"
return False, "fanart网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True
@staticmethod
def get_name() -> str:
return "Fanart"
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
获取图片

View File

@@ -9,21 +9,34 @@ from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
from app.log import logger
from app.modules import _ModuleBase
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, MediaDirectory
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
lock = Lock()
class FileTransferModule(_ModuleBase):
"""
文件整理模块
"""
def __init__(self):
super().__init__()
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "文件整理"
def stop(self):
pass
@@ -31,42 +44,68 @@ class FileTransferModule(_ModuleBase):
"""
测试模块连接性
"""
if not settings.DOWNLOAD_PATH:
return False, "下载目录未设置"
directoryhelper = DirectoryHelper()
# 检查下载目录
download_paths: List[str] = []
for path in [settings.DOWNLOAD_PATH,
settings.DOWNLOAD_MOVIE_PATH,
settings.DOWNLOAD_TV_PATH,
settings.DOWNLOAD_ANIME_PATH]:
download_paths = directoryhelper.get_download_dirs()
if not download_paths:
return False, "下载目录未设置"
for d_path in download_paths:
path = d_path.path
if not path:
continue
return False, f"下载目录 {d_path.name} 对应路径未设置"
download_path = Path(path)
if not download_path.exists():
return False, f"下载目录 {download_path} 不存在"
download_paths.append(path)
# 下载目录的设备ID
download_devids = [Path(path).stat().st_dev for path in download_paths]
return False, f"下载目录 {d_path.name} 对应路径 {path} 不存在"
# 检查媒体库目录
if not settings.LIBRARY_PATH:
libaray_paths = directoryhelper.get_library_dirs()
if not libaray_paths:
return False, "媒体库目录未设置"
# 比较媒体库目录的设备ID
for path in settings.LIBRARY_PATHS:
for l_path in libaray_paths:
path = l_path.path
if not path:
return False, f"媒体库目录 {l_path.name} 对应路径未设置"
library_path = Path(path)
if not library_path.exists():
return False, f"媒体库目录不存在:{library_path}"
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
if library_path.stat().st_dev not in download_devids:
return False, f"媒体库目录 {library_path} " \
f"与下载目录 {','.join(download_paths)} 不在同一设备,将无法硬链接"
return False, f"媒体库目录{l_path.name} 对应的路径 {path} 不存在"
# 检查硬链接条件
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
for d_path in download_paths:
link_ok = False
for l_path in libaray_paths:
if SystemUtils.is_same_disk(Path(d_path.path), Path(l_path.path)):
link_ok = True
break
if not link_ok:
return False, f"媒体库目录中未找到" \
f"与下载目录 {d_path.path} 在同一磁盘/存储空间/映射路径的目录,将无法硬链接"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
"""
获取重命名后的名称
:param meta: 元数据
:param mediainfo: 媒体信息
:return: 重命名后的名称(含目录)
"""
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 获取重命名后的名称
path = self.get_rename_path(
template_string=rename_format,
rename_dict=self.__get_naming_dict(meta=meta,
mediainfo=mediainfo,
file_ext=Path(meta.title).suffix)
)
return str(path)
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
episodes_info: List[TmdbEpisode] = None,
scrape: bool = None) -> TransferInfo:
"""
文件转移
:param path: 文件路径
@@ -75,39 +114,49 @@ class FileTransferModule(_ModuleBase):
:param transfer_type: 转移方式
:param target: 目标路径
:param episodes_info: 当前季的全部集信息
:param scrape: 是否刮削元数据
:return: {path, target_path, message}
"""
# 获取目标路径
if not target:
# 未指定目的目录,根据源目录选择一个媒体库
target = self.get_target_path(in_path=path)
# 拼装媒体库一、二级子目录
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target)
else:
# 指定了目的目录
if target.is_file():
logger.error(f"转移目标路径是一个文件 {target} 是一个文件")
return TransferInfo(success=False,
path=path,
message=f"{target} 不是有效目录")
# 只拼装二级子目录(不要一级目录)
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target, typename_dir=False)
if not target:
logger.error("未找到媒体库目录,无法转移文件")
# 目标路径不能是文件
if target and target.is_file():
logger.error(f"转移目标路径是一个文件 {target} 是一个文件")
return TransferInfo(success=False,
path=path,
message="未找到媒体库目录")
message=f"{target} 不是有效目录")
# 获取目标路径
directoryhelper = DirectoryHelper()
if target:
dir_info = directoryhelper.get_library_dir(mediainfo, in_path=path, to_path=target)
else:
logger.info(f"获取转移目标路径:{target}")
dir_info = directoryhelper.get_library_dir(mediainfo, in_path=path)
if dir_info:
# 是否需要刮削
if scrape is None:
need_scrape = dir_info.scrape
else:
need_scrape = scrape
# 拼装媒体库一、二级子目录
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dir_info)
elif target:
# 自定义目标路径
need_scrape = scrape or False
else:
# 未找到有效的媒体库目录
logger.error(
f"{mediainfo.type.value} {mediainfo.title_year} 未找到有效的媒体库目录,无法转移文件,源路径:{path}")
return TransferInfo(success=False,
path=path,
message="未找到有效的媒体库目录")
logger.info(f"获取转移目标路径:{target}")
# 转移
return self.transfer_media(in_path=path,
in_meta=meta,
mediainfo=mediainfo,
transfer_type=transfer_type,
target_dir=target,
episodes_info=episodes_info)
episodes_info=episodes_info,
need_scrape=need_scrape)
@staticmethod
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
@@ -170,12 +219,13 @@ class FileTransferModule(_ModuleBase):
"""
# 字幕正则式
_zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&](cn|ch[si]|sg|zho?|eng)" \
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&]?(cn|ch[si]|sg|zho?|eng)" \
r"|简[体中]?)[.\])])" \
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
r"|简体|简中|JPSC" \
r"|(?<![a-z0-9])gb(?![a-z0-9])"
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
r"|(cht|eng)[-_&]?(cht|eng)" \
r"|繁[体中]?)[.\])])" \
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC" \
r"|(?<![a-z0-9])big5(?![a-z0-9])"
@@ -380,43 +430,24 @@ class FileTransferModule(_ModuleBase):
over_flag=over_flag)
@staticmethod
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path, typename_dir: bool = True) -> Path:
def __get_dest_dir(mediainfo: MediaInfo, target_dir: MediaDirectory) -> Path:
"""
根据设置并装媒体库目录
:param mediainfo: 媒体信息
:target_dir: 媒体库根目录
:typename_dir: 是否加上类型目录
"""
if not target_dir:
return target_dir
if not target_dir.media_type and target_dir.auto_category:
# 一级自动分类
download_dir = Path(target_dir.path) / mediainfo.type.value
else:
download_dir = Path(target_dir.path)
if mediainfo.type == MediaType.MOVIE:
# 电影
if typename_dir:
# 目的目录加上类型和二级分类
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
else:
# 目的目录加上二级分类
target_dir = target_dir / mediainfo.category
if not target_dir.category and target_dir.auto_category and mediainfo.category:
# 二级自动分类
download_dir = download_dir / mediainfo.category
if mediainfo.type == MediaType.TV:
# 电视剧
if mediainfo.genre_ids \
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
if typename_dir:
target_dir = target_dir / (settings.LIBRARY_ANIME_NAME
or settings.LIBRARY_TV_NAME) / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
else:
# 电视剧
if typename_dir:
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
return target_dir
return download_dir
def transfer_media(self,
in_path: Path,
@@ -424,7 +455,8 @@ class FileTransferModule(_ModuleBase):
mediainfo: MediaInfo,
transfer_type: str,
target_dir: Path,
episodes_info: List[TmdbEpisode] = None
episodes_info: List[TmdbEpisode] = None,
need_scrape: bool = False
) -> TransferInfo:
"""
识别并转移一个文件或者一个目录下的所有文件
@@ -434,6 +466,7 @@ class FileTransferModule(_ModuleBase):
:param target_dir: 媒体库根目录
:param transfer_type: 文件转移方式
:param episodes_info: 当前季的全部集信息
:param need_scrape: 是否需要刮削
:return: TransferInfo、错误信息
"""
# 检查目录路径
@@ -486,7 +519,8 @@ class FileTransferModule(_ModuleBase):
path=in_path,
target_path=new_path,
total_size=file_size,
is_bluray=bluray_flag)
is_bluray=bluray_flag,
need_scrape=need_scrape)
else:
# 转移单个文件
if mediainfo.type == MediaType.TV:
@@ -585,7 +619,8 @@ class FileTransferModule(_ModuleBase):
total_size=file_size,
is_bluray=False,
file_list=[str(in_path)],
file_list_new=[str(new_file)])
file_list_new=[str(new_file)],
need_scrape=need_scrape)
@staticmethod
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
@@ -656,6 +691,10 @@ class FileTransferModule(_ModuleBase):
"doubanid": mediainfo.douban_id,
# 季号
"season": meta.season_seq,
# 季年份根据season值获取
"season_year": mediainfo.season_years.get(
int(meta.season_seq),
None) if (mediainfo.season_years and meta.season_seq) else None,
# 集号
"episode": meta.episode_seqs,
# 季集 SxxExx
@@ -685,96 +724,32 @@ 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"计算媒体库路径时出错:{str(e)}")
continue
return path
@staticmethod
def get_target_path(in_path: Path = None) -> Optional[Path]:
"""
计算一个最好的目的目录有in_path时找与in_path同路径的没有in_path时顺序查找1个符合大小要求的没有in_path和size时返回第1个
:param in_path: 源目录
"""
if not settings.LIBRARY_PATHS:
return None
# 目的路径,多路径以,分隔
dest_paths = settings.LIBRARY_PATHS
# 只有一个路径,直接返回
if len(dest_paths) == 1:
return dest_paths[0]
# 匹配有最长共同上级路径的目录
max_length = 0
target_path = None
if in_path:
for path in dest_paths:
try:
# 计算in_path和path的公共字符串长度
relative = StringUtils.find_common_prefix(str(in_path), str(path))
if len(str(path)) == len(relative):
# 目录完整匹配的,直接返回
return path
if len(relative) > max_length:
# 更新最大长度
max_length = len(relative)
target_path = path
except Exception as e:
logger.debug(f"计算目标路径时出错:{str(e)}")
continue
if target_path:
return target_path
# 顺序匹配第1个满足空间存储要求的目录
if in_path.exists():
file_size = in_path.stat().st_size
for path in dest_paths:
if SystemUtils.free_space(path) > file_size:
return path
# 默认返回第1个
return dest_paths[0]
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
"""
判断媒体文件是否存在于本地文件系统,只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if not settings.LIBRARY_PATHS:
return None
# 目的路径
dest_paths = settings.LIBRARY_PATHS
dest_paths = DirectoryHelper().get_library_dirs()
# 检查每一个媒体库目录
for dest_path in dest_paths:
# 媒体库路径
target_dir = self.get_target_path(dest_path)
if not target_dir:
continue
# 媒体分类路径
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dest_path)
if not target_dir.exists():
continue
# 重命名格式
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=meta,
mediainfo=mediainfo)
)
# 取相对路径的第1层目录
if rel_path.parts:
media_path = target_dir / rel_path.parts[0]
@@ -818,8 +793,6 @@ class FileTransferModule(_ModuleBase):
删除目录下的所有版本文件
:param path: 目录路径
"""
if not path.exists():
return False
# 识别文件中的季集信息
meta = MetaInfoPath(path)
season = meta.season
@@ -832,7 +805,7 @@ class FileTransferModule(_ModuleBase):
return False
# 删除文件
for media_file in media_files:
if media_file == path:
if str(media_file) == str(path):
continue
# 识别文件中的季集信息
filemeta = MetaInfoPath(media_file)

View File

@@ -1,30 +1,42 @@
import threading
from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults
class RuleParser:
_lock = threading.Lock()
_thread_local = threading.local()
def __init__(self):
"""
定义语法规则
"""
# 表达式
expr: Forward = Forward()
# 原子
atom: Combine = Combine(Word(alphas, alphanums) | Word(nums) + Word(alphas, alphanums))
# 逻辑非操作符
operator_not: Literal = Literal('!').setParseAction(lambda: 'not')
# 逻辑操作符
operator_or: Literal = Literal('|').setParseAction(lambda: 'or')
# 逻辑操作符
operator_and: Literal = Literal('&').setParseAction(lambda: 'and')
# 定义表达式的语法规则
expr <<= operator_not + expr | operator_or | operator_and | atom | ('(' + expr + ')')
with self._lock:
if not hasattr(self._thread_local, 'initialized'):
# 表达式
expr: Forward = Forward()
# 原子
atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums)))
# 逻辑操作符
operator_not: Literal = Literal('!').setParseAction(lambda t: 'not')
# 逻辑操作符
operator_or: Literal = Literal('|').setParseAction(lambda t: 'or')
# 逻辑与操作符
operator_and: Literal = Literal('&').setParseAction(lambda t: 'and')
# 定义表达式的语法规则
expr <<= (operator_not + expr) | atom | ('(' + expr + ')')
# 运算符优先级
self.expr = infixNotation(expr,
[(operator_not, 1, opAssoc.RIGHT),
(operator_and, 2, opAssoc.LEFT),
(operator_or, 2, opAssoc.LEFT)])
# 运算符优先级
self.expr = infixNotation(expr,
[(operator_not, 1, opAssoc.RIGHT),
(operator_and, 2, opAssoc.LEFT),
(operator_or, 2, opAssoc.LEFT)])
self._thread_local.expr = self.expr
self._thread_local.initialized = True
else:
self.expr = self._thread_local.expr
def parse(self, expression: str) -> ParseResults:
"""
@@ -41,7 +53,9 @@ class RuleParser:
if __name__ == '__main__':
# 测试代码
expression_str = "!BLU & 4K & CN > !BLU & 1080P & CN > !BLU & 4K > !BLU & 1080P"
expression_str = """
SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D
"""
for exp in expression_str.split('>'):
parsed_expr = RuleParser().parse(exp)
print(parsed_expr.as_list())
parsed_expr = RuleParser().parse(exp.strip())
print(parsed_expr.asList())

View File

@@ -136,6 +136,10 @@ class FilterModule(_ModuleBase):
def init_module(self) -> None:
self.parser = RuleParser()
@staticmethod
def get_name() -> str:
return "过滤器"
def stop(self):
pass
@@ -169,6 +173,7 @@ class FilterModule(_ModuleBase):
continue
# 能命中优先级的才返回
if not self.__get_order(torrent, rule_string):
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} 不匹配优先级规则")
continue
ret_torrents.append(torrent)
@@ -191,7 +196,7 @@ class FilterModule(_ModuleBase):
torrent_episodes = meta.episode_list
if not set(torrent_seasons).issubset(set(seasons)):
# 种子季不在过滤季中
logger.info(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {seasons}")
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}")
return False
if not torrent_episodes:
# 整季按匹配处理
@@ -201,7 +206,7 @@ class FilterModule(_ModuleBase):
if need_episodes \
and not set(torrent_episodes).intersection(set(need_episodes)):
# 单季集没有交集的不要
logger.info(f"种子 {torrent.site_name} - {torrent.title} "
logger.debug(f"种子 {torrent.site_name} - {torrent.title} "
f"{torrent_episodes} 没有需要的集:{need_episodes}")
return False
return True
@@ -223,7 +228,7 @@ class FilterModule(_ModuleBase):
if self.__match_group(torrent, parsed_group.as_list()[0]):
# 出现匹配时中断
matched = True
logger.info(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
torrent.pri_order = res_order
break
# 优先级降低,继续匹配
@@ -333,7 +338,7 @@ class FilterModule(_ModuleBase):
info_values = [str(info_value).upper()]
# 过滤值转化为数组
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
values = [str(val).upper() for val in value.split(",") if val]
else:
values = [str(value).upper()]
# 没有交集为不匹配

View File

@@ -9,10 +9,12 @@ from app.db.sitestatistic_oper import SiteStatisticOper
from app.helper.sites import SitesHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.haidan import HaiDanSpider
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
from app.modules.indexer.yema import YemaSpider
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@@ -25,6 +27,10 @@ class IndexerModule(_ModuleBase):
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "站点索引"
def stop(self):
pass
@@ -107,6 +113,17 @@ class IndexerModule(_ModuleBase):
mtype=mtype,
page=page
)
elif site.get('parser') == "Yema":
error_flag, result = YemaSpider(site).search(
keyword=search_word,
mtype=mtype,
page=page
)
elif site.get('parser') == "Haidan":
error_flag, result = HaiDanSpider(site).search(
keyword=search_word,
mtype=mtype
)
else:
error_flag, result = self.__spider_search(
search_word=search_word,

View File

@@ -0,0 +1,167 @@
import urllib.parse
from typing import Tuple, List
from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class HaiDanSpider:
"""
haidan.video API
"""
_indexerid = None
_domain = None
_url = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 100
_searchurl = "%storrents.php"
_detailurl = "%sdetails.php?group_id=%s&torrent_id=%s"
_timeout = 15
# 电影分类
_movie_category = ['401', '404', '405']
_tv_category = ['402', '403', '404', '405']
# 足销状态 1-普通2-免费3-2X4-2X免费5-50%6-2X50%7-30%
_dl_state = {
"1": 1,
"2": 0,
"3": 1,
"4": 0,
"5": 0.5,
"6": 0.5,
"7": 0.3
}
_up_state = {
"1": 1,
"2": 1,
"3": 2,
"4": 2,
"5": 1,
"6": 2,
"7": 1
}
def __init__(self, indexer: CommentedMap):
self.systemconfig = SystemConfigOper()
if indexer:
self._indexerid = indexer.get('id')
self._url = indexer.get('domain')
self._domain = StringUtils.get_url_domain(self._url)
self._searchurl = self._searchurl % self._url
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]:
"""
搜索
"""
def __dict_to_query(_params: dict):
"""
将数组转换为逗号分隔的字符串
"""
for key, value in _params.items():
if isinstance(value, list):
_params[key] = ','.join(map(str, value))
return urllib.parse.urlencode(params)
# 检查cookie
if not self._cookie:
return True, []
if not mtype:
categories = []
elif mtype == MediaType.TV:
categories = self._tv_category
else:
categories = self._movie_category
# 搜索类型
if keyword.startswith('tt'):
search_area = '4'
else:
search_area = '0'
params = {
"isapi": "1",
"search_area": search_area, # 0-标题 1-简介较慢3-发种用户名 4-IMDb
"search": keyword,
"search_mode": "0", # 0-与 1-或 2-精准
"cat": categories
}
res = RequestUtils(
cookies=self._cookie,
ua=self._ua,
proxies=self._proxy,
timeout=self._timeout
).get_res(url=f"{self._searchurl}?{__dict_to_query(params)}")
torrents = []
if res and res.status_code == 200:
result = res.json()
code = result.get('code')
if code != 0:
logger.warn(f"{self._name} 搜索失败:{result.get('msg')}")
return True, []
data = result.get('data') or {}
for tid, item in data.items():
category_value = result.get('category')
if category_value in self._tv_category \
and category_value not in self._movie_category:
category = MediaType.TV.value
elif category_value in self._movie_category:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
torrent = {
'title': item.get('name'),
'description': item.get('small_descr'),
'enclosure': item.get('url'),
'pubdate': StringUtils.format_timestamp(item.get('added')),
'size': int(item.get('size') or '0'),
'seeders': int(item.get('seeders') or '0'),
'peers': int(item.get("leechers") or '0'),
'grabs': int(item.get("times_completed") or '0'),
'downloadvolumefactor': self.__get_downloadvolumefactor(item.get('sp_state')),
'uploadvolumefactor': self.__get_uploadvolumefactor(item.get('sp_state')),
'page_url': self._detailurl % (self._url, item.get('group_id'), tid),
'labels': [],
'category': category
}
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
def __get_downloadvolumefactor(self, discount: str) -> float:
"""
获取下载系数
"""
if discount:
return self._dl_state.get(discount, 1)
return 1
def __get_uploadvolumefactor(self, discount: str) -> float:
"""
获取上传系数
"""
if discount:
return self._up_state.get(discount, 1)
return 1

View File

@@ -15,18 +15,20 @@ from app.utils.string import StringUtils
class MTorrentSpider:
"""
mTorrent API需要缓存ApiKey
mTorrent API
"""
_indexerid = None
_domain = None
_url = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 100
_searchurl = "%sapi/torrent/search"
_downloadurl = "%sapi/torrent/genDlToken"
_searchurl = "https://api.%s/api/torrent/search"
_downloadurl = "https://api.%s/api/torrent/genDlToken"
_pageurl = "%sdetail/%s"
_timeout = 15
# 电影分类
_movie_category = ['401', '419', '420', '421', '439', '405', '404']
@@ -53,7 +55,8 @@ class MTorrentSpider:
self.systemconfig = SystemConfigOper()
if indexer:
self._indexerid = indexer.get('id')
self._domain = indexer.get('domain')
self._url = indexer.get('domain')
self._domain = StringUtils.get_url_domain(self._url)
self._searchurl = self._searchurl % self._domain
self._name = indexer.get('name')
if indexer.get('proxy'):
@@ -62,6 +65,7 @@ class MTorrentSpider:
self._ua = indexer.get('ua')
self._apikey = indexer.get('apikey')
self._token = indexer.get('token')
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
"""
@@ -92,7 +96,7 @@ class MTorrentSpider:
},
proxies=self._proxy,
referer=f"{self._domain}browse",
timeout=15
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:
@@ -122,7 +126,7 @@ class MTorrentSpider:
'grabs': int(result.get('status', {}).get("timesCompleted") or '0'),
'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')),
'page_url': self._pageurl % (self._url, result.get('id')),
'imdbid': self.__find_imdbid(result.get('imdb')),
'labels': labels,
'category': category
@@ -189,7 +193,6 @@ class MTorrentSpider:
'id': torrent_id
},
'header': {
'Content-Type': 'application/json',
'User-Agent': f'{self._ua}',
'Accept': 'application/json, text/plain, */*',
'x-api-key': self._apikey

View File

@@ -63,8 +63,8 @@ class TorrentSpider:
torrents_info: dict = {}
# 种子列表
torrents_info_array: list = []
# 搜索超时, 默认: 30
_timeout = 30
# 搜索超时, 默认: 15
_timeout = 15
def __init__(self,
indexer: CommentedMap,
@@ -491,8 +491,10 @@ class TorrentSpider:
pubdate = torrent(selector.get('selector', '')).clone()
self.__remove(pubdate, selector)
items = self.__attribute_or_text(pubdate, selector)
self.torrents_info['pubdate'] = self.__index(items, selector)
self.torrents_info['pubdate'] = self.__filter_text(self.torrents_info.get('pubdate'),
pubdate_str = self.__index(items, selector)
if pubdate_str:
pubdate_str = pubdate_str.replace('\n', ' ').strip()
self.torrents_info['pubdate'] = self.__filter_text(pubdate_str,
selector.get('filters'))
def __get_date_elapsed(self, torrent):
@@ -674,12 +676,15 @@ class TorrentSpider:
try:
args = filter_item.get("args")
if method_name == "re_search" and isinstance(args, list):
text = re.search(r"%s" % args[0], text).group(args[-1])
rematch = re.search(r"%s" % args[0], text)
if rematch:
text = rematch.group(args[-1])
elif method_name == "split" and isinstance(args, list):
text = text.split(r"%s" % args[0])[args[-1]]
elif method_name == "replace" and isinstance(args, list):
text = text.replace(r"%s" % args[0], r"%s" % args[-1])
elif method_name == "dateparse" and isinstance(args, str):
text = text.replace("\n", " ").strip()
text = datetime.datetime.strptime(text, r"%s" % args)
elif method_name == "strip":
text = text.strip()

View File

@@ -18,6 +18,7 @@ class TNodeSpider:
_ua = None
_token = None
_size = 100
_timeout = 15
_searchurl = "%sapi/torrent/advancedSearch"
_downloadurl = "%sapi/torrent/download/%s"
_pageurl = "%storrent/info/%s"
@@ -32,6 +33,7 @@ class TNodeSpider:
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._timeout = indexer.get('timeout') or 15
self.init_config()
def init_config(self):
@@ -43,7 +45,7 @@ class TNodeSpider:
res = RequestUtils(ua=self._ua,
cookies=self._cookie,
proxies=self._proxy,
timeout=15).get_res(url=self._domain)
timeout=self._timeout).get_res(url=self._domain)
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
if csrf_token:
@@ -77,7 +79,7 @@ class TNodeSpider:
},
cookies=self._cookie,
proxies=self._proxy,
timeout=15
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:

View File

@@ -17,11 +17,13 @@ class TorrentLeech:
_browseurl = "%storrents/browse/list/page/2%s"
_downloadurl = "%sdownload/%s/%s"
_pageurl = "%storrent/%s"
_timeout = 15
def __init__(self, indexer: CommentedMap):
self._indexer = indexer
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
@@ -40,7 +42,7 @@ class TorrentLeech:
},
cookies=self._indexer.get('cookie'),
proxies=self._proxy,
timeout=15
timeout=self._timeout
).get_res(url)
torrents = []
if res and res.status_code == 200:

148
app/modules/indexer/yema.py Normal file
View File

@@ -0,0 +1,148 @@
from typing import Tuple, List
from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class YemaSpider:
"""
YemaPT API
"""
_indexerid = None
_domain = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 40
_searchurl = "%sapi/torrent/fetchCategoryOpenTorrentList"
_downloadurl = "%sapi/torrent/download?id=%s"
_pageurl = "%s#/torrent/detail/%s/"
_timeout = 15
# 分类
_movie_category = 4
_tv_category = 5
def __init__(self, indexer: CommentedMap):
self.systemconfig = SystemConfigOper()
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')
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
"""
搜索
"""
if not mtype:
categoryId = self._movie_category
elif mtype == MediaType.TV:
categoryId = self._tv_category
else:
categoryId = self._movie_category
params = {
"categoryId": categoryId,
"pageParam": {
"current": page + 1,
"pageSize": self._size,
"total": self._size
},
"sorter": {}
}
if keyword:
params.update({
"keyword": keyword,
})
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"Accept": "application/json, text/plain, */*"
},
cookies=self._cookie,
proxies=self._proxy,
referer=f"{self._domain}",
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:
results = res.json().get('data', []) or []
for result in results:
category_value = result.get('categoryId')
if category_value == self._tv_category:
category = MediaType.TV.value
elif category_value == self._movie_category:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
torrent = {
'title': result.get('showName'),
'description': result.get('shortDesc'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.unify_datetime_str(result.get('gmtCreate')),
'size': result.get('fileSize'),
'seeders': result.get('seedNum'),
'peers': result.get('leechNum'),
'grabs': result.get('completedNum'),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
'page_url': self._pageurl % (self._domain, result.get('id')),
'labels': [],
'category': category
}
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 __get_downloadvolumefactor(discount: str) -> float:
"""
获取下载系数
"""
discount_dict = {
"free": 0,
"half": 0.5,
"none": 1
}
if discount:
return discount_dict.get(discount, 1)
return 1
@staticmethod
def __get_uploadvolumefactor(discount: str) -> float:
"""
获取上传系数
"""
discount_dict = {
"none": 1,
"one_half": 1.5,
"double_upload": 2
}
if discount:
return discount_dict.get(discount, 1)
return 1
def __get_download_url(self, torrent_id: str) -> str:
"""
获取下载链接
"""
return self._downloadurl % (self._domain, torrent_id)

View File

@@ -14,6 +14,10 @@ class JellyfinModule(_ModuleBase):
def init_module(self) -> None:
self.jellyfin = Jellyfin()
@staticmethod
def get_name() -> str:
return "Jellyfin"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "jellyfin"

View File

@@ -15,16 +15,10 @@ class Jellyfin:
def __init__(self):
self._host = settings.JELLYFIN_HOST
if self._host:
if not self._host.endswith("/"):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._host = RequestUtils.standardize_base_url(self._host)
self._playhost = settings.JELLYFIN_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._playhost = RequestUtils.standardize_base_url(self._playhost)
self._apikey = settings.JELLYFIN_API_KEY
self.user = self.get_user(settings.SUPERUSER)
self.serverid = self.get_server_id()
@@ -52,12 +46,12 @@ class Jellyfin:
return []
req_url = "%sLibrary/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
@@ -70,29 +64,29 @@ class Jellyfin:
return []
req_url = "%sLibrary/VirtualFolders?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
library_items = res.json()
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
res = RequestUtils().get_res(req_url)
if res:
library_items = res.json()
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders 未获取到返回数据")
return []
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/VirtualFolders 出错:" + str(e))
return []
@@ -109,12 +103,12 @@ class Jellyfin:
user = self.user
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Items")
else:
logger.error(f"Users/Views 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Items")
else:
logger.error(f"Users/Views 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Users/Views 出错:" + str(e))
return []
@@ -163,12 +157,12 @@ class Jellyfin:
return 0
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return len(res.json())
else:
logger.error(f"Users 未获取到返回数据")
return 0
res = RequestUtils().get_res(req_url)
if res:
return len(res.json())
else:
logger.error(f"Users 未获取到返回数据")
return 0
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return 0
@@ -181,20 +175,20 @@ class Jellyfin:
return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
res = RequestUtils().get_res(req_url)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
if user.get("Name") == user_name:
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
# 查询管理员
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return None
@@ -244,11 +238,11 @@ class Jellyfin:
return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
except Exception as e:
logger.error(f"连接System/Info出错" + str(e))
return None
@@ -262,17 +256,17 @@ class Jellyfin:
return schemas.Statistic()
req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
res = RequestUtils().get_res(req_url)
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic()
@@ -287,14 +281,14 @@ class Jellyfin:
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, name)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -317,36 +311,36 @@ class Jellyfin:
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, title)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for item in res_items:
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
continue
if mediaserver_item.title == title and (
not year or str(mediaserver_item.year) == str(year)):
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for item in res_items:
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
return ret_movies
continue
if mediaserver_item.title == title and (
not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item)
return ret_movies
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -387,25 +381,25 @@ class Jellyfin:
try:
req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % (
self._host, item_id, season, self.user, self._apikey)
with RequestUtils().get_res(req_url) as res_json:
if res_json:
tv_info = res_json.json()
res_items = tv_info.get("Items")
# 返回的季集信息
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if not season_episodes.get(season_index):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
return item_id, season_episodes
res_json = RequestUtils().get_res(req_url)
if res_json:
tv_info = res_json.json()
res_items = tv_info.get("Items")
# 返回的季集信息
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if not season_episodes.get(season_index):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -458,13 +452,13 @@ class Jellyfin:
_host = self._playhost
req_url = "%sItems/%s/Images/%s" % (_host, item_id, image_type)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
res = RequestUtils().get_res(req_url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
except Exception as e:
logger.error(f"连接Items/Id/Images出错" + str(e))
return None
@@ -479,12 +473,12 @@ class Jellyfin:
"""
req_url = "%sItems/%s/Ancestors?api_key=%s" % (self._host, item_id, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json()[index].get(key)
else:
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
return None
res = RequestUtils().get_res(req_url)
if res:
return res.json()[index].get(key)
else:
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
return None
except Exception as e:
logger.error(f"连接Items/Id/Ancestors出错" + str(e))
return None
@@ -497,11 +491,11 @@ class Jellyfin:
return False
req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Jellyfin")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Jellyfin")
except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e))
return False
@@ -589,6 +583,8 @@ class Jellyfin:
eventItem.item_id = message.get('ItemId')
eventItem.tmdb_id = message.get('Provider_tmdb')
eventItem.overview = message.get('Overview')
eventItem.item_favorite = message.get('Favorite')
eventItem.save_reason = message.get('SaveReason')
eventItem.device_name = message.get('DeviceName')
eventItem.user_name = message.get('NotificationUsername')
eventItem.client = message.get('ClientName')
@@ -611,6 +607,11 @@ class Jellyfin:
eventItem.item_name = "%s %s" % (
message.get('Name'), "(" + str(message.get('Year')) + ")")
playback_position_ticks = message.get('PlaybackPositionTicks')
runtime_ticks = message.get('RunTimeTicks')
if playback_position_ticks is not None and runtime_ticks is not None:
eventItem.percentage = playback_position_ticks / runtime_ticks * 100
# 获取消息图片
if eventItem.item_id:
# 根据返回的item_id去调用媒体服务器获取
@@ -632,23 +633,23 @@ class Jellyfin:
req_url = "%sUsers/%s/Items/%s?api_key=%s" % (
self._host, self.user, itemid, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
return None
@@ -663,17 +664,17 @@ class Jellyfin:
yield None
req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")):
yield item
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield None
@@ -761,50 +762,50 @@ class Jellyfin:
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
with RequestUtils().get_res(image) as image_res:
if not image_res or image_res.status_code == 404:
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
image_res = RequestUtils().get_res(image)
if not image_res or image_res.status_code == 404:
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
@@ -822,35 +823,35 @@ class Jellyfin:
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []

View File

@@ -14,6 +14,10 @@ class PlexModule(_ModuleBase):
def init_module(self) -> None:
self.plex = Plex()
@staticmethod
def get_name() -> str:
return "Plex"
def stop(self):
pass

View File

@@ -1,35 +1,31 @@
import json
from functools import lru_cache
from pathlib import Path
from typing import List, Optional, Dict, Tuple, Generator, Any
from urllib.parse import quote_plus
from cachetools import TTLCache, cached
from plexapi import media
from plexapi.server import PlexServer
from requests import Response, Session
from app import schemas
from app.core.config import settings
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
class Plex:
_plex = None
_session = None
def __init__(self):
self._host = settings.PLEX_HOST
if self._host:
if not self._host.endswith("/"):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._host = RequestUtils.standardize_base_url(self._host)
self._playhost = settings.PLEX_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._playhost = RequestUtils.standardize_base_url(self._playhost)
self._token = settings.PLEX_TOKEN
if self._host and self._token:
try:
@@ -38,6 +34,7 @@ class Plex:
except Exception as e:
self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}")
self._session = self.__adapt_plex_session()
def is_inactive(self) -> bool:
"""
@@ -58,7 +55,7 @@ class Plex:
self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}")
@lru_cache(maxsize=10)
@cached(cache=TTLCache(maxsize=100, ttl=86400))
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
"""
获取媒体服务器最近添加的媒体的图片列表
@@ -76,10 +73,11 @@ class Plex:
# 如果总数不足,接续获取下一页
while len(poster_urls) < total_size:
items = self._plex.fetchItems(f"/hubs/home/recentlyAdded?type={mtype}&sectionID={library_key}",
container_size=total_size,
container_start=container_start)
container_start=container_start,
container_size=8,
maxresults=8)
for item in items:
if item.type == 'episode':
if item.type == "episode":
# 如果是剧集的单集,则去找上级的图片
if item.parentThumb is not None:
poster_urls[item.parentThumb] = None
@@ -236,16 +234,19 @@ class Plex:
if item_id:
videos = self._plex.fetchItem(item_id)
else:
# 兼容年份为空的场景
kwargs = {"year": year} if year else {}
# 根据标题和年份模糊搜索,该结果不够准确
videos = self._plex.library.search(title=title,
year=year,
libtype="show")
libtype="show",
**kwargs)
if (not videos
and original_title
and str(original_title) != str(title)):
videos = self._plex.library.search(title=original_title,
year=year,
libtype="show")
libtype="show",
**kwargs)
if not videos:
return None, {}
if isinstance(videos, list):
@@ -264,25 +265,59 @@ class Plex:
season_episodes[episode.seasonNumber].append(episode.index)
return videos.key, season_episodes
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
def get_remote_image_by_id(self, item_id: str, image_type: str, depth: int = 0) -> Optional[str]:
"""
根据ItemId从Plex查询图片地址
:param item_id: 在Emby中的ID
:param item_id: 在Plex中的ID
:param image_type: 图片的类型Poster或者Backdrop等
:param depth: 当前递归深度默认为0
:return: 图片对应在TMDB中的URL
"""
if not self._plex:
if not self._plex or depth > 2 or not item_id:
return None
try:
if image_type == "Poster":
images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id,
cls=media.Poster)
image_url = None
ekey = f"/library/metadata/{item_id}"
item = self._plex.fetchItem(ekey=ekey)
if not item:
return None
# 如果配置了外网播放地址以及Token则默认从Plex媒体服务器获取图片否则返回有外网地址的图片资源
if settings.PLEX_PLAY_HOST and settings.PLEX_TOKEN:
query = {"X-Plex-Token": settings.PLEX_TOKEN}
if image_type == "Poster":
if item.thumb:
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query)
else:
# 默认使用art也就是Backdrop进行处理
if item.art:
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.art, query=query)
# 这里对episode进行特殊处理实际上episode的Backdrop是Poster
# 也有个别情况比如机智的凡人小子episode就是Poster因此这里把episode的优先级降低默认还是取art
if not image_url and item.TYPE == "episode" and item.thumb:
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query)
else:
images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id,
cls=media.Art)
for image in images:
if hasattr(image, 'key') and image.key.startswith('http'):
return image.key
if image_type == "Poster":
images = self._plex.fetchItems(ekey=f"{ekey}/posters",
cls=media.Poster)
else:
# 默认使用art也就是Backdrop进行处理
images = self._plex.fetchItems(ekey=f"{ekey}/arts",
cls=media.Art)
# 这里对episode进行特殊处理实际上episode的Backdrop是Poster
# 也有个别情况比如机智的凡人小子episode就是Poster因此这里把episode的优先级降低默认还是取art
if not images and item.TYPE == "episode":
images = self._plex.fetchItems(ekey=f"{ekey}/posters",
cls=media.Poster)
for image in images:
if hasattr(image, "key") and image.key.startswith("http"):
image_url = image.key
break
# 如果最后还是找不到,则递归父级进行查找
if not image_url and hasattr(item, "parentRatingKey"):
return self.get_remote_image_by_id(item_id=item.parentRatingKey,
image_type=image_type,
depth=depth + 1)
return image_url
except Exception as e:
logger.error(f"获取封面出错:" + str(e))
return None
@@ -314,7 +349,7 @@ class Plex:
# 否则一个一个刷新
for path, lib_key in result_dict.items():
logger.info(f"刷新媒体库:{lib_key} - {path}")
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(path)}')
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(str(Path(path).parent))}')
@staticmethod
def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, str]:
@@ -375,26 +410,34 @@ class Plex:
@staticmethod
def __get_ids(guids: List[Any]) -> dict:
def parse_tmdb_id(value: str) -> (bool, int):
"""尝试将TMDB ID字符串转换为整数。如果成功返回(True, int),失败则返回(False, None)。"""
try:
int_value = int(value)
return True, int_value
except ValueError:
return False, None
guid_mapping = {
"imdb://": "imdb_id",
"tmdb://": "tmdb_id",
"tvdb://": "tvdb_id"
}
ids = {}
for prefix, varname in guid_mapping.items():
ids[varname] = None
ids = {varname: None for varname in guid_mapping.values()}
for guid in guids:
guid_id = guid['id'] if isinstance(guid, dict) else guid.id
for prefix, varname in guid_mapping.items():
if isinstance(guid, dict):
if guid['id'].startswith(prefix):
# 找到匹配的ID
ids[varname] = guid['id'][len(prefix):]
break
else:
if guid.id.startswith(prefix):
# 找到匹配的ID
ids[varname] = guid.id[len(prefix):]
break
if guid_id.startswith(prefix):
clean_id = guid_id[len(prefix):]
if varname == "tmdb_id":
# tmdb_id为intPlex可能存在脏数据特别处理tmdb_id
success, parsed_id = parse_tmdb_id(clean_id)
if success:
ids[varname] = parsed_id
else:
ids[varname] = clean_id
break
return ids
def get_items(self, parent: str) -> Generator:
@@ -409,25 +452,29 @@ class Plex:
section = self._plex.library.sectionByID(int(parent))
if section:
for item in section.all():
if not item:
try:
if not item:
continue
ids = self.__get_ids(item.guids)
path = None
if item.locations:
path = item.locations[0]
yield schemas.MediaServerItem(
server="plex",
library=item.librarySectionID,
item_id=item.key,
item_type=item.type,
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids['tmdb_id'],
imdbid=ids['imdb_id'],
tvdbid=ids['tvdb_id'],
path=path,
)
except Exception as e:
logger.error(f"处理媒体项目时出错:{str(e)}, 跳过此项目。")
continue
ids = self.__get_ids(item.guids)
path = None
if item.locations:
path = item.locations[0]
yield schemas.MediaServerItem(
server="plex",
library=item.librarySectionID,
item_id=item.key,
item_type=item.type,
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids['tmdb_id'],
imdbid=ids['imdb_id'],
tvdbid=ids['tvdb_id'],
path=path,
)
except Exception as err:
logger.error(f"获取媒体库列表出错:{str(err)}")
yield None
@@ -616,8 +663,12 @@ class Plex:
return []
# 媒体库白名单
allow_library = ",".join([lib.id for lib in self.get_librarys()])
params = {'contentDirectoryID': allow_library}
items = self._plex.fetchItems("/hubs/continueWatching/items", container_start=0, container_size=num, params=params)
params = {"contentDirectoryID": allow_library}
items = self._plex.fetchItems("/hubs/continueWatching/items",
container_start=0,
container_size=num,
maxresults=num,
params=params)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
@@ -691,7 +742,12 @@ class Plex:
elif item.TYPE == "episode":
item_type = MediaType.TV.value
title = "%s%s季 第%s" % (item.grandparentTitle, item.parentIndex, item.index)
image = self._host + item.parentThumb.lstrip('/') + f"?X-Plex-Token={self._token}"
thumb = (item.parentThumb or item.grandparentThumb or '').lstrip('/')
image = (self._host + thumb + f"?X-Plex-Token={self._token}")
elif item.TYPE == "show":
item_type = MediaType.TV.value
title = "%s%s" % (item.title, item.seasonCount)
image = item.posterUrl
link = self.get_play_url(item.key)
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
@@ -701,7 +757,73 @@ class Plex:
image=image,
link=link
))
offset += num
return ret_resume[:num]
def get_data(self, endpoint: str, **kwargs) -> Optional[Response]:
"""
自定义从媒体服务器获取数据
:param endpoint: 端点
:param kwargs: 其他请求参数如headers, cookies, proxies等
"""
return self.__request(method="get", endpoint=endpoint, **kwargs)
def post_data(self, endpoint: str, **kwargs) -> Optional[Response]:
"""
自定义从媒体服务器获取数据
:param endpoint: 端点
:param kwargs: 其他请求参数如headers, cookies, proxies等
"""
return self.__request(method="post", endpoint=endpoint, **kwargs)
def put_data(self, endpoint: str, **kwargs) -> Optional[Response]:
"""
自定义从媒体服务器获取数据
:param endpoint: 端点
:param kwargs: 其他请求参数如headers, cookies, proxies等
"""
return self.__request(method="put", endpoint=endpoint, **kwargs)
def __request(self, method: str, endpoint: str, **kwargs) -> Optional[Response]:
"""
自定义从媒体服务器获取数据
:param method: HTTP方法如 get, post, put 等
:param endpoint: 端点
:param kwargs: 其他请求参数如headers, cookies, proxies等
"""
if not self._session:
return
try:
url = RequestUtils.adapt_request_url(host=self._host, endpoint=endpoint)
kwargs.setdefault("headers", self.__get_request_headers())
kwargs.setdefault("raise_exception", True)
request_method = getattr(RequestUtils(session=self._session), f"{method}_res", None)
if request_method:
return request_method(url=url, **kwargs)
else:
logger.error(f"方法 {method} 不存在")
return None
except Exception as e:
logger.error(f"连接Plex出错" + str(e))
return None
@staticmethod
def __get_request_headers() -> dict:
"""获取请求头"""
return {
"X-Plex-Token": settings.PLEX_TOKEN,
"Accept": "application/json",
"Content-Type": "application/json"
}
@staticmethod
def __adapt_plex_session() -> Session:
"""
创建并配置一个针对Plex服务的requests.Session实例
这个会话包括特定的头部信息用于处理所有的Plex请求
"""
# 设置请求头部,通常包括验证令牌和接受/内容类型头部
headers = Plex.__get_request_headers()
session = Session()
session.headers = headers
return session

View File

@@ -23,6 +23,10 @@ class QbittorrentModule(_ModuleBase):
def init_module(self) -> None:
self.qbittorrent = Qbittorrent()
@staticmethod
def get_name() -> str:
return "Qbittorrent"
def stop(self):
pass
@@ -188,11 +192,12 @@ class QbittorrentModule(_ModuleBase):
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = settings.SAVE_PATH / torrent.get('name')
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
title=torrent.get('name'),
path=torrent_path,
hash=torrent.get('hash'),
size=torrent.get('total_size'),
tags=torrent.get('tags')
))
elif status == TorrentStatus.TRANSFER:
@@ -207,7 +212,7 @@ class QbittorrentModule(_ModuleBase):
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = settings.SAVE_PATH / torrent.get('name')
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
title=torrent.get('name'),
path=torrent_path,
@@ -238,7 +243,7 @@ class QbittorrentModule(_ModuleBase):
return None
return ret_torrents
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
def transfer_completed(self, hashs: str, path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
"""
转移完成后的处理
@@ -250,7 +255,7 @@ class QbittorrentModule(_ModuleBase):
return
self.qbittorrent.set_torrents_tag(ids=hashs, tags=['已整理'])
# 移动模式删除种子
if settings.TRANSFER_TYPE == "move":
if settings.TRANSFER_TYPE in ["move", "rclone_move"]:
if self.remove_torrents(hashs):
logger.info(f"移动模式删除种子成功:{hashs} ")
# 删除残留文件

View File

@@ -16,6 +16,10 @@ class SlackModule(_ModuleBase):
def init_module(self) -> None:
self.slack = Slack()
@staticmethod
def get_name() -> str:
return "Slack"
def stop(self):
self.slack.stop()
@@ -198,7 +202,7 @@ class SlackModule(_ModuleBase):
:return: 成功或失败
"""
self.slack.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Slack)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:

View File

@@ -93,13 +93,13 @@ class Slack:
"""
return True if self._client else False
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
def send_msg(self, title: str, text: str = "", image: str = "", link: str = "", userid: str = ""):
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param url: 点击消息转转的URL
:param link: 点击消息转转的URL
:param userid: 用户ID如有则只发消息给该用户
:user_id: 发送消息的目标用户ID为空则发给管理员
"""
@@ -132,7 +132,7 @@ class Slack:
"alt_text": f"{title}"
}})
# 链接
if url:
if link:
blocks.append({
"type": "actions",
"elements": [
@@ -144,7 +144,7 @@ class Slack:
"emoji": True
},
"value": "click_me_url",
"url": f"{url}",
"url": f"{link}",
"action_id": "actionId-url"
}
]

View File

@@ -28,6 +28,10 @@ class SubtitleModule(_ModuleBase):
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "站点字幕"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass

View File

@@ -13,6 +13,10 @@ class SynologyChatModule(_ModuleBase):
def init_module(self) -> None:
self.synologychat = SynologyChat()
@staticmethod
def get_name() -> str:
return "Synology Chat"
def stop(self):
pass
@@ -70,7 +74,7 @@ class SynologyChatModule(_ModuleBase):
:return: 成功或失败
"""
self.synologychat.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.SynologyChat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -91,4 +95,5 @@ class SynologyChatModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)

View File

@@ -36,7 +36,8 @@ class SynologyChat:
return True
return False
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
def send_msg(self, title: str, text: str = "", image: str = "",
userid: str = "", link: str = None) -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
@@ -44,6 +45,7 @@ class SynologyChat:
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:user_id: 发送消息的目标用户ID为空则发给管理员
:param link: 链接地址
"""
if not title and not text:
logger.error("标题和内容不能同时为空")
@@ -64,6 +66,10 @@ class SynologyChat:
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
else:
caption = title
if link:
caption = f"{caption}\n[查看详情]({link})"
payload_data = {'text': quote(caption)}
if image:
payload_data['file_url'] = quote(image)
@@ -127,7 +133,7 @@ class SynologyChat:
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -157,6 +163,9 @@ class SynologyChat:
f"_{description}_"
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
userids = [int(userid)]
else:

View File

@@ -15,6 +15,10 @@ class TelegramModule(_ModuleBase):
def init_module(self) -> None:
self.telegram = Telegram()
@staticmethod
def get_name() -> str:
return "Telegram"
def stop(self):
self.telegram.stop()
@@ -106,7 +110,7 @@ class TelegramModule(_ModuleBase):
:return: 成功或失败
"""
self.telegram.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Telegram)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -117,7 +121,7 @@ class TelegramModule(_ModuleBase):
:return: 成功或失败
"""
return self.telegram.send_meidas_msg(title=message.title, medias=medias,
userid=message.userid)
userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Telegram)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
@@ -127,7 +131,8 @@ class TelegramModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.telegram.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.telegram.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)
def register_commands(self, commands: Dict[str, dict]):
"""

View File

@@ -1,5 +1,6 @@
import re
import threading
import uuid
from pathlib import Path
from threading import Event
from typing import Optional, List, Dict
@@ -67,13 +68,15 @@ class Telegram:
"""
return self._bot is not None
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
def send_msg(self, title: str, text: str = "", image: str = "",
userid: str = "", link: str = None) -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:param link: 跳转链接
:userid: 发送消息的目标用户ID为空则发给管理员
"""
if not self._telegram_token or not self._telegram_chat_id:
@@ -85,10 +88,15 @@ class Telegram:
try:
if text:
# 对text进行Markdown特殊字符转义
text = re.sub(r"([_`])", r"\\\1", text)
caption = f"*{title}*\n{text}"
else:
caption = f"*{title}*"
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -100,7 +108,8 @@ class Telegram:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "",
title: str = "", link: str = None) -> Optional[bool]:
"""
发送媒体列表消息
"""
@@ -127,6 +136,9 @@ class Telegram:
f"类型:{media.type.value}")
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -139,7 +151,7 @@ class Telegram:
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -168,6 +180,9 @@ class Telegram:
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -187,13 +202,15 @@ class Telegram:
"""
if image:
req = RequestUtils(proxies=settings.PROXY).get_res(image)
if req is None:
res = RequestUtils(proxies=settings.PROXY).get_res(image)
if res is None:
raise Exception("获取图片失败")
if req.content:
image_file = Path(settings.TEMP_PATH) / Path(image).name
image_file.write_bytes(req.content)
if res.content:
# 使用随机标识构建图片文件的完整路径,并写入图片内容到文件
image_file = Path(settings.TEMP_PATH) / str(uuid.uuid4())
image_file.write_bytes(res.content)
photo = InputFile(image_file)
# 发送图片到Telegram
ret = self._bot.send_photo(chat_id=userid or self._telegram_chat_id,
photo=photo,
caption=caption,

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, List, Tuple, Union
from typing import Optional, List, Tuple, Union, Dict
import cn2an
@@ -39,6 +39,10 @@ class TheMovieDbModule(_ModuleBase):
self.category = CategoryHelper()
self.scraper = TmdbScraper(self.tmdb)
@staticmethod
def get_name() -> str:
return "TheMovieDb"
def stop(self):
self.cache.save()
self.tmdb.close()
@@ -212,14 +216,28 @@ class TheMovieDbModule(_ModuleBase):
tmdbid=info.get("id"))
return info
def tmdb_info(self, tmdbid: int, mtype: MediaType) -> Optional[dict]:
def tmdb_info(self, tmdbid: int, mtype: MediaType, season: int = None) -> Optional[dict]:
"""
获取TMDB信息
:param tmdbid: int
:param mtype: 媒体类型
:param season: 季号
:return: TVDB信息
"""
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
if not season:
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
else:
return self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)
def media_category(self) -> Optional[Dict[str, list]]:
"""
获取媒体分类
:return: 获取二级分类配置字典项,需包括电影、电视剧
"""
return {
MediaType.MOVIE.value: list(self.category.movie_categorys),
MediaType.TV.value: list(self.category.tv_categorys)
}
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
@@ -318,6 +336,29 @@ class TheMovieDbModule(_ModuleBase):
force_img=force_img)
logger.info(f"{path} 刮削完成")
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
season: int = None, episode: int = None) -> Optional[str]:
"""
获取NFO文件内容文本
:param meta: 元数据
:param mediainfo: 媒体信息
:param season: 季号
:param episode: 集号
"""
if settings.SCRAP_SOURCE != "themoviedb":
return None
return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode)
def metadata_img(self, mediainfo: MediaInfo, season: int = None) -> Optional[dict]:
"""
获取图片名称和url
:param mediainfo: 媒体信息
:param season: 季号
"""
if settings.SCRAP_SOURCE != "themoviedb":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season)
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
page: int = 1) -> Optional[List[MediaInfo]]:
"""
@@ -373,9 +414,9 @@ class TheMovieDbModule(_ModuleBase):
:param season: 季
"""
season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)
if not season_info:
if not season_info or not season_info.get("episodes"):
return []
return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes", [])]
return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")]
def scheduler_job(self) -> None:
"""
@@ -531,7 +572,7 @@ class TheMovieDbModule(_ModuleBase):
detail = self.tmdb.get_person_detail(person_id=person_id)
if detail:
return schemas.MediaPerson(source="themoviedb", **detail)
return schemas.MediaPerson
return schemas.MediaPerson()
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
"""

View File

@@ -15,7 +15,6 @@ class CategoryHelper(metaclass=Singleton):
_categorys = {}
_movie_categorys = {}
_tv_categorys = {}
_anime_categorys = {}
def __init__(self):
self._category_path: Path = settings.CONFIG_PATH / "category.yaml"
@@ -25,9 +24,6 @@ class CategoryHelper(metaclass=Singleton):
"""
初始化
"""
# 二级分类策略关闭
if not settings.LIBRARY_CATEGORY:
return
try:
if not self._category_path.exists():
shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path)
@@ -44,7 +40,6 @@ class CategoryHelper(metaclass=Singleton):
if self._categorys:
self._movie_categorys = self._categorys.get('movie')
self._tv_categorys = self._categorys.get('tv')
self._anime_categorys = self._categorys.get('anime')
logger.info(f"已加载二级分类策略 category.yaml")
@property
@@ -83,15 +78,6 @@ class CategoryHelper(metaclass=Singleton):
return []
return self._tv_categorys.keys()
@property
def anime_categorys(self) -> list:
"""
获取动漫分类清单
"""
if not self._anime_categorys:
return []
return self._anime_categorys.keys()
def get_movie_category(self, tmdb_info) -> str:
"""
判断电影的分类
@@ -106,10 +92,6 @@ class CategoryHelper(metaclass=Singleton):
:param tmdb_info: 识别的TMDB中的信息
:return: 二级分类的名称
"""
genre_ids = tmdb_info.get("genre_ids") or []
if self._anime_categorys and genre_ids \
and set(genre_ids).intersection(set(settings.ANIME_GENREIDS)):
return self.get_category(self._anime_categorys, tmdb_info)
return self.get_category(self._tv_categorys, tmdb_info)
@staticmethod
@@ -144,7 +126,7 @@ class CategoryHelper(metaclass=Singleton):
info_values = [str(info_value).upper()]
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
values = [str(val).upper() for val in value.split(",") if val]
else:
values = [str(value).upper()]

View File

@@ -1,6 +1,6 @@
import traceback
from pathlib import Path
from typing import Union
from typing import Union, Optional, Tuple
from xml.dom import minidom
from requests import RequestException
@@ -26,6 +26,90 @@ class TmdbScraper:
def __init__(self, tmdb):
self.tmdb = tmdb
def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
season: int = None, episode: int = None) -> Optional[str]:
"""
获取NFO文件内容文本
:param meta: 元数据
:param mediainfo: 媒体信息
:param season: 季号
:param episode: 集号
"""
if mediainfo.type == MediaType.MOVIE:
# 电影元数据文件
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
else:
if season:
# 查询季信息
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
if episode:
# 集元数据文件
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
doc = self.__gen_tv_episode_nfo_file(episodeinfo=episodeinfo, tmdbid=mediainfo.tmdb_id,
season=season, episode=episode)
else:
# 季元数据文件
doc = self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo, season=season)
else:
# 电视剧元数据文件
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
if doc:
return doc.toprettyxml(indent=" ", encoding="utf-8")
return None
def get_metadata_img(self, mediainfo: MediaInfo, season: int = None) -> dict:
"""
获取图片名称和url
:param mediainfo: 媒体信息
:param season: 季号
"""
images = {}
if season:
# 只需要季的图片
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
if seasoninfo:
# TMDB季poster图片
poster_name, poster_url = self.get_season_poster(seasoninfo, season)
if poster_name and poster_url:
images[poster_name] = poster_url
return images
# 主媒体图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.endswith("_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
images[image_name] = attr_value
return images
@staticmethod
def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]:
"""
获取季的海报
"""
# TMDB季poster图片
sea_seq = str(season).rjust(2, '0')
if seasoninfo.get("poster_path"):
# 后缀
ext = Path(seasoninfo.get('poster_path')).suffix
# URL
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
image_name = f"season{sea_seq}-poster{ext}"
return image_name, url
@staticmethod
def __get_episode_detail(seasoninfo: dict, episode: int) -> dict:
"""
根据季信息获取集的信息
"""
for _episode_info in seasoninfo.get("episodes") or []:
if _episode_info.get("episode_number") == episode:
return _episode_info
return {}
def gen_scraper_files(self, mediainfo: MediaInfo, file_path: Path, transfer_type: str,
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False):
"""
@@ -45,15 +129,6 @@ class TmdbScraper:
self._force_nfo = force_nfo
self._force_img = force_img
def __get_episode_detail(_seasoninfo: dict, _episode: int):
"""
根据季信息获取集的信息
"""
for _episode_info in _seasoninfo.get("episodes") or []:
if _episode_info.get("episode_number") == _episode:
return _episode_info
return {}
try:
# 电影,路径为文件名 名称/名称.xxx 或者蓝光原盘目录 名称/名称
if mediainfo.type == MediaType.MOVIE:
@@ -64,17 +139,11 @@ class TmdbScraper:
self.__gen_movie_nfo_file(mediainfo=mediainfo,
file_path=file_path)
# 生成电影图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.endswith("_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = file_path.with_name(image_name)
if self._force_img or not image_path.exists():
self.__save_image(url=attr_value,
file_path=image_path)
image_dict = self.get_metadata_img(mediainfo=mediainfo)
for image_name, image_url in image_dict.items():
image_path = file_path.with_name(image_name)
if self._force_img or not image_path.exists():
self.__save_image(url=image_url, file_path=image_path)
# 电视剧,路径为每一季的文件名 名称/Season xx/名称 SxxExx.xxx
else:
# 如果有上游传入的元信息则使用,否则使用文件名识别
@@ -87,18 +156,11 @@ class TmdbScraper:
self.__gen_tv_nfo_file(mediainfo=mediainfo,
dir_path=file_path.parents[1])
# 生成根目录图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_name \
and attr_name.endswith("_path") \
and not attr_name.startswith("season") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = file_path.parent.with_name(image_name)
if self._force_img or not image_path.exists():
self.__save_image(url=attr_value,
file_path=image_path)
image_dict = self.get_metadata_img(mediainfo=mediainfo)
for image_name, image_url in image_dict.items():
image_path = file_path.parent.with_name(image_name)
if self._force_img or not image_path.exists():
self.__save_image(url=image_url, file_path=image_path)
# 查询季信息
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
if seasoninfo:
@@ -107,31 +169,14 @@ class TmdbScraper:
self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo,
season=meta.begin_season,
season_path=file_path.parent)
# TMDB季poster图片
sea_seq = str(meta.begin_season).rjust(2, '0')
if seasoninfo.get("poster_path"):
# 后缀
ext = Path(seasoninfo.get('poster_path')).suffix
# URL
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
image_path = file_path.parent.with_name(f"season{sea_seq}-poster{ext}")
# TMDB季图片
poster_name, poster_url = self.get_season_poster(seasoninfo, meta.begin_season)
if poster_name and poster_url:
image_path = file_path.parent.with_name(poster_name)
if self._force_img or not image_path.exists():
self.__save_image(url=url, file_path=image_path)
# 季的其它图片
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.startswith("season") \
and not attr_name.endswith("poster_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
image_path = file_path.parent.with_name(image_name)
if self._force_img or not image_path.exists():
self.__save_image(url=attr_value,
file_path=image_path)
self.__save_image(url=poster_url, file_path=image_path)
# 查询集详情
episodeinfo = __get_episode_detail(seasoninfo, meta.begin_episode)
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
if episodeinfo:
# 集NFO
if self._force_nfo or not file_path.with_suffix(".nfo").exists():
@@ -153,7 +198,7 @@ class TmdbScraper:
logger.error(f"{file_path} 刮削失败:{str(e)} - {traceback.format_exc()}")
@staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Element):
"""
生成公共NFO
"""
@@ -207,14 +252,15 @@ class TmdbScraper:
def __gen_movie_nfo_file(self,
mediainfo: MediaInfo,
file_path: Path):
file_path: Path = None) -> minidom.Document:
"""
生成电影的NFO描述文件
:param mediainfo: 识别后的媒体信息
:param file_path: 电影文件路径
"""
# 开始生成XML
logger.info(f"正在生成电影NFO文件{file_path.name}")
if file_path:
logger.info(f"正在生成电影NFO文件{file_path.name}")
doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "movie")
# 公共部分
@@ -229,18 +275,21 @@ class TmdbScraper:
# 年份
DomUtils.add_node(doc, root, "year", mediainfo.year or "")
# 保存
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
if file_path:
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
return doc
def __gen_tv_nfo_file(self,
mediainfo: MediaInfo,
dir_path: Path):
dir_path: Path = None) -> minidom.Document:
"""
生成电视剧的NFO描述文件
:param mediainfo: 媒体信息
:param dir_path: 电视剧根目录
"""
# 开始生成XML
logger.info(f"正在生成电视剧NFO文件{dir_path.name}")
if dir_path:
logger.info(f"正在生成电视剧NFO文件{dir_path.name}")
doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "tvshow")
# 公共部分
@@ -257,16 +306,21 @@ class TmdbScraper:
DomUtils.add_node(doc, root, "season", "-1")
DomUtils.add_node(doc, root, "episode", "-1")
# 保存
self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo"))
if dir_path:
self.__save_nfo(doc, dir_path.joinpath("tvshow.nfo"))
def __gen_tv_season_nfo_file(self, seasoninfo: dict, season: int, season_path: Path):
return doc
def __gen_tv_season_nfo_file(self, seasoninfo: dict,
season: int, season_path: Path = None) -> minidom.Document:
"""
生成电视剧季的NFO描述文件
:param seasoninfo: TMDB季媒体信息
:param season: 季号
:param season_path: 电视剧季的目录
"""
logger.info(f"正在生成季NFO文件{season_path.name}")
if season_path:
logger.info(f"正在生成季NFO文件{season_path.name}")
doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "season")
# 简介
@@ -285,14 +339,16 @@ class TmdbScraper:
# seasonnumber
DomUtils.add_node(doc, root, "seasonnumber", str(season))
# 保存
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
if season_path:
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
return doc
def __gen_tv_episode_nfo_file(self,
tmdbid: int,
episodeinfo: dict,
season: int,
episode: int,
file_path: Path):
file_path: Path = None) -> minidom.Document:
"""
生成电视剧集的NFO描述文件
:param tmdbid: TMDBID
@@ -302,7 +358,8 @@ class TmdbScraper:
:param file_path: 集文件的路径
"""
# 开始生成集的信息
logger.info(f"正在生成剧集NFO文件{file_path.name}")
if file_path:
logger.info(f"正在生成剧集NFO文件{file_path.name}")
doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "episodedetails")
# TMDBID
@@ -348,7 +405,9 @@ class TmdbScraper:
DomUtils.add_node(doc, xactor, "profile",
f"https://www.themoviedb.org/person/{actor.get('id')}")
# 保存文件
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
if file_path:
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
return doc
@retry(RequestException, logger=logger)
def __save_image(self, url: str, file_path: Path):
@@ -371,7 +430,7 @@ class TmdbScraper:
except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
def __save_nfo(self, doc, file_path: Path):
def __save_nfo(self, doc: minidom.Document, file_path: Path):
"""
保存NFO
"""

Some files were not shown because too many files have changed in this diff Show More